Keep connected walls joined during endpoint drags
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user