Keep connected walls joined during endpoint drags

This commit is contained in:
sudhir
2026-04-16 11:43:24 +05:30
parent dee086545d
commit f563dd9763
@@ -37,7 +37,7 @@ import {
type ZoneNode as ZoneNodeType, type ZoneNode as ZoneNodeType,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Command } from 'lucide-react' import { Command, Move } from 'lucide-react'
import { import {
memo, memo,
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
@@ -239,6 +239,9 @@ type WallEndpointDragState = {
endpoint: WallEndpoint endpoint: WallEndpoint
fixedPoint: WallPlanPoint fixedPoint: WallPlanPoint
currentPoint: WallPlanPoint currentPoint: WallPlanPoint
originalStart: WallPlanPoint
originalEnd: WallPlanPoint
linkedWalls: LinkedWallSnapshot[]
} }
type WallCurveDragState = { type WallCurveDragState = {
@@ -286,6 +289,11 @@ type WallEndpointDraft = {
endpoint: WallEndpoint endpoint: WallEndpoint
start: WallPlanPoint start: WallPlanPoint
end: WallPlanPoint end: WallPlanPoint
linkedUpdates: Array<{
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
}>
} }
type WallCurveDraft = { type WallCurveDraft = {
@@ -2025,6 +2033,91 @@ function buildWallEndpointDraft(
endpoint, endpoint,
start: endpoint === 'start' ? movingPoint : fixedPoint, start: endpoint === 'start' ? movingPoint : fixedPoint,
end: endpoint === 'end' ? movingPoint : fixedPoint, end: endpoint === 'end' ? movingPoint : fixedPoint,
linkedUpdates: [],
}
}
type LinkedWallSnapshot = {
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
}
function getLinkedWallSnapshots(
walls: WallNode[],
wallId: WallNode['id'],
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
): LinkedWallSnapshot[] {
return walls.flatMap((wall) => {
if (wall.id === wallId) {
return []
}
if (
!pointsEqual(wall.start, originalStart) &&
!pointsEqual(wall.start, originalEnd) &&
!pointsEqual(wall.end, originalStart) &&
!pointsEqual(wall.end, originalEnd)
) {
return []
}
return [
{
id: wall.id,
start: [...wall.start] as WallPlanPoint,
end: [...wall.end] as WallPlanPoint,
},
]
})
}
function getLinkedWallUpdates(
linkedWalls: LinkedWallSnapshot[],
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
nextStart: WallPlanPoint,
nextEnd: WallPlanPoint,
) {
return linkedWalls.map((wall) => ({
id: wall.id,
start: pointsEqual(wall.start, originalStart)
? nextStart
: pointsEqual(wall.start, originalEnd)
? nextEnd
: wall.start,
end: pointsEqual(wall.end, originalStart)
? nextStart
: pointsEqual(wall.end, originalEnd)
? nextEnd
: wall.end,
}))
}
function buildWallEndpointDragDraft(
dragState: Pick<
WallEndpointDragState,
'wallId' | 'endpoint' | 'fixedPoint' | 'originalStart' | 'originalEnd' | 'linkedWalls'
>,
movingPoint: WallPlanPoint,
): WallEndpointDraft {
const nextDraft = buildWallEndpointDraft(
dragState.wallId,
dragState.endpoint,
dragState.fixedPoint,
movingPoint,
)
return {
...nextDraft,
linkedUpdates: getLinkedWallUpdates(
dragState.linkedWalls,
dragState.originalStart,
dragState.originalEnd,
nextDraft.start,
nextDraft.end,
),
} }
} }
@@ -5074,6 +5167,18 @@ export function FloorplanPanel() {
buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end), buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end),
) )
} }
for (const linkedUpdate of wallEndpointDraft.linkedUpdates) {
const linkedWall = nextWallById.get(linkedUpdate.id)
if (!linkedWall) {
continue
}
nextWallById.set(
linkedWall.id,
buildWallWithUpdatedEndpoints(linkedWall, linkedUpdate.start, linkedUpdate.end),
)
}
} }
if (wallCurveDraft) { if (wallCurveDraft) {
@@ -5090,19 +5195,9 @@ export function FloorplanPanel() {
return floorplanWallById return floorplanWallById
} }
const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId return new Map(
if (!previewWallId) { Array.from(displayWallById.values()).map((wall) => [wall.id, getFloorplanWall(wall)] as const),
return floorplanWallById )
}
const previewWall = displayWallById.get(previewWallId)
if (!previewWall) {
return floorplanWallById
}
const nextFloorplanWallById = new Map(floorplanWallById)
nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall))
return nextFloorplanWallById
}, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft]) }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
const wallPolygons = useMemo( const wallPolygons = useMemo(
() => () =>
@@ -5122,31 +5217,18 @@ export function FloorplanPanel() {
return wallPolygons return wallPolygons
} }
const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId const previewWalls = Array.from(displayFloorplanWallById.values())
if (!previewWallId) { const previewMiterData = calculateLevelMiters(previewWalls)
return wallPolygons
}
const previewWall = displayWallById.get(previewWallId) return previewWalls.map((wall) => {
if (!previewWall) { const polygon = getWallPlanFootprint(wall, previewMiterData)
return wallPolygons return {
} wall,
polygon,
const previewPolygon = getWallPlanFootprint( points: formatPolygonPoints(polygon),
getFloorplanWall(previewWall), }
EMPTY_WALL_MITER_DATA, })
) }, [displayFloorplanWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
return wallPolygons.map((entry) =>
entry.wall.id === previewWall.id
? {
wall: previewWall,
polygon: previewPolygon,
points: formatPolygonPoints(previewPolygon),
}
: entry,
)
}, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
const openingsPolygons = useMemo( const openingsPolygons = useMemo(
() => () =>
@@ -5992,6 +6074,21 @@ export function FloorplanPanel() {
: null, : null,
[selectedWallEntry, surfaceSize, viewBox], [selectedWallEntry, surfaceSize, viewBox],
) )
const selectedWallCornerMoveActions = useMemo(() => {
if (!selectedWallEntry) {
return []
}
return (['start', 'end'] as const).map((endpoint) => {
const point = endpoint === 'start' ? selectedWallEntry.wall.start : selectedWallEntry.wall.end
const svgPoint = toSvgPlanPoint(point)
return {
endpoint,
x: svgPoint.x,
y: svgPoint.y,
}
})
}, [selectedWallEntry])
const selectedStairActionMenuPosition = useMemo( const selectedStairActionMenuPosition = useMemo(
() => () =>
selectedStairEntry selectedStairEntry
@@ -6735,18 +6832,23 @@ export function FloorplanPanel() {
dragState.currentPoint = snappedPoint dragState.currentPoint = snappedPoint
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
setWallEndpointDraft((previousDraft) => { setWallEndpointDraft((previousDraft) => {
const nextDraft = buildWallEndpointDraft( const nextDraft = buildWallEndpointDragDraft(dragState, snappedPoint)
dragState.wallId,
dragState.endpoint,
dragState.fixedPoint,
snappedPoint,
)
if ( if (
!( !(
previousDraft && previousDraft &&
pointsEqual(previousDraft.start, nextDraft.start) && pointsEqual(previousDraft.start, nextDraft.start) &&
pointsEqual(previousDraft.end, nextDraft.end) pointsEqual(previousDraft.end, nextDraft.end) &&
previousDraft.linkedUpdates.length === nextDraft.linkedUpdates.length &&
previousDraft.linkedUpdates.every((update, index) => {
const nextUpdate = nextDraft.linkedUpdates[index]
return (
nextUpdate &&
update.id === nextUpdate.id &&
pointsEqual(update.start, nextUpdate.start) &&
pointsEqual(update.end, nextUpdate.end)
)
})
) )
) { ) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
@@ -6853,12 +6955,7 @@ export function FloorplanPanel() {
const wall = wallById.get(dragState.wallId) const wall = wallById.get(dragState.wallId)
if (wall) { if (wall) {
const nextDraft = buildWallEndpointDraft( const nextDraft = buildWallEndpointDragDraft(dragState, dragState.currentPoint)
dragState.wallId,
dragState.endpoint,
dragState.fixedPoint,
dragState.currentPoint,
)
const hasChanged = !( const hasChanged = !(
pointsEqual(nextDraft.start, wall.start) && pointsEqual(nextDraft.end, wall.end) pointsEqual(nextDraft.start, wall.start) && pointsEqual(nextDraft.end, wall.end)
) )
@@ -6868,6 +6965,12 @@ export function FloorplanPanel() {
start: nextDraft.start, start: nextDraft.start,
end: nextDraft.end, end: nextDraft.end,
}) })
for (const linkedUpdate of nextDraft.linkedUpdates) {
updateNode(linkedUpdate.id, {
start: linkedUpdate.start,
end: linkedUpdate.end,
})
}
sfxEmitter.emit('sfx:structure-build') sfxEmitter.emit('sfx:structure-build')
} }
} }
@@ -8469,6 +8572,79 @@ export function FloorplanPanel() {
}, },
[selectedWallEntry, setMovingNode, setSelection], [selectedWallEntry, setMovingNode, setSelection],
) )
const beginWallEndpointDrag = useCallback(
(
wall: WallNode,
endpoint: WallEndpoint,
pointerId: number,
movingPoint: WallPlanPoint,
) => {
if (isWallBuildActive) {
handleWallPlacementPoint(movingPoint)
return
}
if (mode !== 'select') {
return
}
clearWallPlacementDraft()
handleWallSelect(wall)
const fixedPoint = endpoint === 'start' ? wall.end : wall.start
const linkedWalls = getLinkedWallSnapshots(walls, wall.id, wall.start, wall.end)
const originalStart = [...wall.start] as WallPlanPoint
const originalEnd = [...wall.end] as WallPlanPoint
wallEndpointDragRef.current = {
pointerId,
wallId: wall.id,
endpoint,
fixedPoint,
currentPoint: movingPoint,
originalStart,
originalEnd,
linkedWalls,
}
setWallEndpointDraft(
buildWallEndpointDragDraft(
{
wallId: wall.id,
endpoint,
fixedPoint,
originalStart,
originalEnd,
linkedWalls,
},
movingPoint,
),
)
setCursorPoint(movingPoint)
},
[clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode, walls],
)
const handleSelectedWallCornerMovePointerDown = useCallback(
(endpoint: WallEndpoint, event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) {
return
}
const wall = selectedWallEntry?.wall
if (!wall) {
return
}
event.preventDefault()
event.stopPropagation()
setHoveredEndpointId(null)
sfxEmitter.emit('sfx:item-pick')
const movingPoint = endpoint === 'start' ? wall.start : wall.end
beginWallEndpointDrag(wall, endpoint, event.pointerId, movingPoint)
},
[beginWallEndpointDrag, selectedWallEntry],
)
const handleSelectedWallDelete = useCallback( const handleSelectedWallDelete = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => { (event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation() event.stopPropagation()
@@ -8725,33 +8901,9 @@ export function FloorplanPanel() {
setHoveredEndpointId(null) setHoveredEndpointId(null)
const movingPoint = endpoint === 'start' ? wall.start : wall.end const movingPoint = endpoint === 'start' ? wall.start : wall.end
beginWallEndpointDrag(wall, endpoint, event.pointerId, movingPoint)
if (isWallBuildActive) {
handleWallPlacementPoint(movingPoint)
return
}
if (mode !== 'select') {
return
}
clearWallPlacementDraft()
handleWallSelect(wall)
const fixedPoint = endpoint === 'start' ? wall.end : wall.start
wallEndpointDragRef.current = {
pointerId: event.pointerId,
wallId: wall.id,
endpoint,
fixedPoint,
currentPoint: movingPoint,
}
setWallEndpointDraft(buildWallEndpointDraft(wall.id, endpoint, fixedPoint, movingPoint))
setCursorPoint(movingPoint)
}, },
[clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode], [beginWallEndpointDrag],
) )
const handleWallCurvePointerDown = useCallback( const handleWallCurvePointerDown = useCallback(
(wall: WallNode, event: ReactPointerEvent<SVGCircleElement>) => { (wall: WallNode, event: ReactPointerEvent<SVGCircleElement>) => {
@@ -9762,6 +9914,27 @@ export function FloorplanPanel() {
/> />
</div> </div>
)} )}
{selectedWallCornerMoveActions.length > 0 &&
isFloorplanHovered &&
!movingNode &&
!curvingWall &&
selectedWallCornerMoveActions.map(({ endpoint, x, y }) => (
<button
aria-label={endpoint === 'start' ? 'Move wall start' : 'Move wall end'}
className="pointer-events-auto absolute z-30 flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-muted-foreground shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-foreground"
key={`selected-wall-corner-move-${endpoint}`}
onPointerDown={(event) => handleSelectedWallCornerMovePointerDown(endpoint, event)}
style={{
left: x,
top: y,
transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y + 6}px))`,
}}
title={endpoint === 'start' ? 'Move wall start' : 'Move wall end'}
type="button"
>
<Move className="h-4 w-4" />
</button>
))}
{selectedSlabActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && ( {selectedSlabActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div <div
className="absolute z-30" className="absolute z-30"