From 167f0868ef0ab989f24c8ab186396d88d4cff1f8 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 25 Jun 2026 14:13:58 -0400 Subject: [PATCH] perf+fix(editor): wall/fence/roof 2D draft to store+leaf, finish snapping-mode parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perf: move the per-move wall/fence/roof draft END points into useFloorplanDraftPreview; a new FloorplanLinearDraftLayer leaf owns the live draft polygon + fence segment + wall measurement, subscribing to the store. The shared FloorplanDraftLayer keeps only the per-click anchors. Wall/fence/roof drafts now have zero per-move panel setState — buttery smooth like slab/zone. Parity: migrate the remaining legacy Shift=bypass paths to the unified mode-driven model. roof (move + click) honored only always-grid + bypassSnap — now grid/lines/off (footprint → no angle). wall + fence click-commit still used the legacy bypass while their move-preview didn't — now consistent. Wall Alt stays 'commit single wall' (open product decision, untouched). Co-Authored-By: Claude Opus 4.8 --- .../src/components/editor/floorplan-panel.tsx | 396 ++++++++++++------ .../use-floorplan-background-placement.ts | 59 +-- .../src/store/use-floorplan-draft-preview.ts | 44 +- 3 files changed, 324 insertions(+), 175 deletions(-) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 7e366238..96f08c8c 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -4715,6 +4715,204 @@ function FloorplanCursorIndicator( return } +// Leaf overlay for the live wall / fence / roof draft segment (the directional +// draws). It subscribes to the per-move END points in the draft store so a +// `grid:move` re-renders ONLY this layer, not FloorplanPanel; the per-click +// START points + render config arrive as props. Owns the draft polygon (wall + +// roof rect), the fence segment line, and the wall length/angle measurement — +// the cursor-following pieces the shared `FloorplanDraftLayer` no longer carries. +function FloorplanLinearDraftLayer({ + levelId, + wallDraftStart, + fenceDraftStart, + roofDraftStart, + isWallBuildActive, + isFenceBuildActive, + isRoofBuildActive, + walls, + unit, + draftFill, + draftStroke, + measurementStroke, + isDark, + unitsPerPixel, + sceneRotationDeg, +}: { + levelId: string | null + wallDraftStart: WallPlanPoint | null + fenceDraftStart: WallPlanPoint | null + roofDraftStart: WallPlanPoint | null + isWallBuildActive: boolean + isFenceBuildActive: boolean + isRoofBuildActive: boolean + walls: WallNode[] + unit: 'metric' | 'imperial' + draftFill: string + draftStroke: string + measurementStroke: string + isDark: boolean + unitsPerPixel: number + sceneRotationDeg: number +}) { + const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd) + const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd) + const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd) + + const draftPolygon = useMemo(() => { + if ( + !( + levelId && + wallDraftStart && + wallDraftEnd && + isSegmentLongEnough(wallDraftStart, wallDraftEnd) + ) + ) { + return null + } + const draftWall = getSharedFloorplanWall(buildDraftWall(levelId, wallDraftStart, wallDraftEnd)) + // Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move. + return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA) + }, [levelId, wallDraftStart, wallDraftEnd]) + + const draftPolygonPoints = useMemo(() => { + if (isRoofBuildActive && roofDraftStart && roofDraftEnd) { + const minX = Math.min(roofDraftStart[0], roofDraftEnd[0]) + const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0]) + const minY = Math.min(roofDraftStart[1], roofDraftEnd[1]) + const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1]) + + if (Math.abs(maxX - minX) >= 1e-6 || Math.abs(maxY - minY) >= 1e-6) { + return formatPolygonPoints([ + { x: minX, y: minY }, + { x: maxX, y: minY }, + { x: maxX, y: maxY }, + { x: minX, y: maxY }, + ]) + } + } + return draftPolygon ? formatPolygonPoints(draftPolygon) : null + }, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart]) + + const fenceDraftSegment = useMemo(() => { + if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) { + return null + } + if (getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(fenceDraftEnd)) < 1e-6) { + return null + } + return { + x1: toSvgX(fenceDraftStart[0]), + y1: toSvgY(fenceDraftStart[1]), + x2: toSvgX(fenceDraftEnd[0]), + y2: toSvgY(fenceDraftEnd[1]), + } + }, [fenceDraftEnd, fenceDraftStart, isFenceBuildActive]) + + // Live length + angle feedback for the wall draft — parity with the 3D + // `WallTool`, ported to 2D plan space. + const draftWallMeasurement = useMemo(() => { + if ( + !( + isWallBuildActive && + wallDraftStart && + wallDraftEnd && + isSegmentLongEnough(wallDraftStart, wallDraftEnd) + ) + ) { + return null + } + + const dx = wallDraftEnd[0] - wallDraftStart[0] + const dy = wallDraftEnd[1] - wallDraftStart[1] + const length = Math.hypot(dx, dy) + + const draftFromStart: WallPlanPoint = [dx, dy] + const draftFromEnd: WallPlanPoint = [-dx, -dy] + const endpoints = [ + { id: 'start', point: wallDraftStart, draftVector: draftFromStart }, + { id: 'end', point: wallDraftEnd, draftVector: draftFromEnd }, + ] as const + + type AngleLabel = { + id: string + label: string + center: WallPlanPoint + radius: number + startAngle: number + endAngle: number + midAngle: number + } + + const angleLabels: AngleLabel[] = [] + for (const endpoint of endpoints) { + const connectedWall = walls.find((wall) => + Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)), + ) + if (!connectedWall) continue + const ref = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) + if (!ref) continue + + const angle = getAngleToSegmentReference(endpoint.draftVector, ref) + if (angle === null) continue + const arc = getAngleArcToSegmentReference(endpoint.draftVector, ref) + if (!arc || arc.angle < 0.01) continue + + const refLen = Math.hypot(ref.vector[0], ref.vector[1]) + const radius = Math.max(0.32, Math.min(0.72, Math.min(length, refLen) * 0.28)) + + angleLabels.push({ + id: endpoint.id, + label: formatAngleRadians(angle), + center: endpoint.point, + radius, + startAngle: arc.startAngle, + endAngle: arc.endAngle, + midAngle: arc.midAngle, + }) + } + + return { + lengthLabel: formatMeasurement(length, unit), + midpoint: [ + (wallDraftStart[0] + wallDraftEnd[0]) / 2, + (wallDraftStart[1] + wallDraftEnd[1]) / 2, + ] as WallPlanPoint, + direction: [dx / length, dy / length] as WallPlanPoint, + angleLabels, + } + }, [isWallBuildActive, unit, wallDraftEnd, wallDraftStart, walls]) + + return ( + <> + + + {draftWallMeasurement && ( + + )} + + ) +} + +const EMPTY_DRAFT_ANCHOR_POINTS: Array<{ x: number; y: number; isPrimary: boolean }> = [] + export function FloorplanPanel({ /** * Element to portal the compass button into. The 2D/3D navigation poses stay @@ -4854,12 +5052,34 @@ export function FloorplanPanel({ FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg + // Draft START points stay in panel state (set per click). The live END points + // are the per-move hot values — they live in `useFloorplanDraftPreview` so a + // `grid:move` re-renders only `FloorplanLinearDraftLayer`, not this panel. + // Shims keep the `setXDraftEnd(value | prev => …)` call sites unchanged. const [draftStart, setDraftStart] = useState(null) - const [draftEnd, setDraftEnd] = useState(null) + const setDraftEnd = useCallback( + (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => { + const store = useFloorplanDraftPreview.getState() + store.setWallDraftEnd(typeof next === 'function' ? next(store.wallDraftEnd) : next) + }, + [], + ) const [fenceDraftStart, setFenceDraftStart] = useState(null) - const [fenceDraftEnd, setFenceDraftEnd] = useState(null) + const setFenceDraftEnd = useCallback( + (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => { + const store = useFloorplanDraftPreview.getState() + store.setFenceDraftEnd(typeof next === 'function' ? next(store.fenceDraftEnd) : next) + }, + [], + ) const [roofDraftStart, setRoofDraftStart] = useState(null) - const [roofDraftEnd, setRoofDraftEnd] = useState(null) + const setRoofDraftEnd = useCallback( + (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => { + const store = useFloorplanDraftPreview.getState() + store.setRoofDraftEnd(typeof next === 'function' ? next(store.roofDraftEnd) : next) + }, + [], + ) const [ceilingDraftPoints, setCeilingDraftPoints] = useState([]) const [slabDraftPoints, setSlabDraftPoints] = useState([]) const [zoneDraftPoints, setZoneDraftPoints] = useState([]) @@ -5879,120 +6099,10 @@ export function FloorplanPanel({ }) }, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon]) - const draftPolygon = useMemo(() => { - if (!(levelId && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))) { - return null - } - - const draftWall = getSharedFloorplanWall(buildDraftWall(levelId, draftStart, draftEnd)) - // Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move. - return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA) - }, [draftEnd, draftStart, levelId]) - // Live length + angle feedback for the wall draft — parity with the 3D - // `WallTool` (`packages/nodes/src/wall/tool.tsx`), ported to 2D plan - // space. Length renders at the segment midpoint; angle arcs sit at - // each endpoint that meets an existing wall. - const draftWallMeasurement = useMemo(() => { - if ( - !(isWallBuildActive && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd)) - ) { - return null - } - - const dx = draftEnd[0] - draftStart[0] - const dy = draftEnd[1] - draftStart[1] - const length = Math.hypot(dx, dy) - - const draftFromStart: WallPlanPoint = [dx, dy] - const draftFromEnd: WallPlanPoint = [-dx, -dy] - const endpoints = [ - { id: 'start', point: draftStart, draftVector: draftFromStart }, - { id: 'end', point: draftEnd, draftVector: draftFromEnd }, - ] as const - - type AngleLabel = { - id: string - label: string - center: WallPlanPoint - radius: number - startAngle: number - endAngle: number - midAngle: number - } - - const angleLabels: AngleLabel[] = [] - for (const endpoint of endpoints) { - const connectedWall = walls.find((wall) => - Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)), - ) - if (!connectedWall) continue - const ref = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) - if (!ref) continue - - const angle = getAngleToSegmentReference(endpoint.draftVector, ref) - if (angle === null) continue - const arc = getAngleArcToSegmentReference(endpoint.draftVector, ref) - if (!arc || arc.angle < 0.01) continue - - const refLen = Math.hypot(ref.vector[0], ref.vector[1]) - const radius = Math.max(0.32, Math.min(0.72, Math.min(length, refLen) * 0.28)) - - angleLabels.push({ - id: endpoint.id, - label: formatAngleRadians(angle), - center: endpoint.point, - radius, - startAngle: arc.startAngle, - endAngle: arc.endAngle, - midAngle: arc.midAngle, - }) - } - - return { - lengthLabel: formatMeasurement(length, unit), - midpoint: [ - (draftStart[0] + draftEnd[0]) / 2, - (draftStart[1] + draftEnd[1]) / 2, - ] as WallPlanPoint, - direction: [dx / length, dy / length] as WallPlanPoint, - angleLabels, - } - }, [draftEnd, draftStart, isWallBuildActive, unit, walls]) - const draftPolygonPoints = useMemo(() => { - if (isRoofBuildActive && roofDraftStart && roofDraftEnd) { - const minX = Math.min(roofDraftStart[0], roofDraftEnd[0]) - const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0]) - const minY = Math.min(roofDraftStart[1], roofDraftEnd[1]) - const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1]) - - if (Math.abs(maxX - minX) >= 1e-6 || Math.abs(maxY - minY) >= 1e-6) { - return formatPolygonPoints([ - { x: minX, y: minY }, - { x: maxX, y: minY }, - { x: maxX, y: maxY }, - { x: minX, y: maxY }, - ]) - } - } - - return draftPolygon ? formatPolygonPoints(draftPolygon) : null - }, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart]) - const fenceDraftSegment = useMemo(() => { - if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) { - return null - } - - if (getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(fenceDraftEnd)) < 1e-6) { - return null - } - - return { - x1: toSvgX(fenceDraftStart[0]), - y1: toSvgY(fenceDraftStart[1]), - x2: toSvgX(fenceDraftEnd[0]), - y2: toSvgY(fenceDraftEnd[1]), - } - }, [fenceDraftEnd, fenceDraftStart, isFenceBuildActive]) + // The live wall / fence / roof draft preview (polygon + fence segment + wall + // measurement) moved into `FloorplanLinearDraftLayer`, which reads the per- + // move END points from the draft store so it re-renders per move without + // re-rendering this panel. const activePolygonDraftPoints = useMemo(() => { if (isCeilingBuildActive) { return ceilingDraftPoints @@ -8651,10 +8761,12 @@ export function FloorplanPanel({ } if (isRoofBuildActive) { - const bypassSnap = shiftPressed || event.shiftKey - let snappedPoint = bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint) - snappedPoint = alignFloorplanDraftPoint(snappedPoint, { - bypass: event.altKey || bypassSnap, + // Roof is placed as a footprint (no directional draw → polygon context: + // grid / lines / off, no angle lock). Mode-driven, matching the chip: + // `grid` quantizes via `getSnappedFloorplanPoint` (step 0 in non-grid + // modes), `lines` pulls onto alignment, `off` is free. Alt forces. + const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), { + bypass: event.altKey || !isMagneticSnapActive(), }) emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => @@ -9225,7 +9337,6 @@ export function FloorplanPanel({ setFenceDraftStart, setRoofDraftEnd, setRoofDraftStart, - shiftPressed, snapPolygonDraftPoint, snapWallDraftPoint: snapWallDraftPointMagnetic, toPoint2D, @@ -10816,6 +10927,13 @@ export function FloorplanPanel({ outlineWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH} /> + {/* This shared layer now carries only the per-CLICK draft anchors + (reference-scale start + committed polygon vertices). The + cursor-following draft geometry moved to the leaves below + (`FloorplanLinearDraftLayer` for wall/fence/roof, + `FloorplanDraftCursorLayer` for polygon previews), which read + the live END points from the draft store so a per-move update + never re-renders this panel. */} - {draftWallMeasurement && ( - - )} + {/* Wall / fence endpoint, wall curve, slab / ceiling / zone vertex+midpoint+edge handles are all driven by the diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index d060067d..a7e326ad 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -62,7 +62,6 @@ type UseFloorplanBackgroundPlacementArgs = { setFenceDraftStart: React.Dispatch> setRoofDraftEnd: React.Dispatch> setRoofDraftStart: React.Dispatch> - shiftPressed: boolean snapWallDraftPoint: (args: { point: WallPlanPoint walls: WallNode[] @@ -122,7 +121,6 @@ export function useFloorplanBackgroundPlacement({ setFenceDraftStart, setRoofDraftEnd, setRoofDraftStart, - shiftPressed, snapWallDraftPoint, snapPolygonDraftPoint, toPoint2D, @@ -184,11 +182,11 @@ export function useFloorplanBackgroundPlacement({ } if (isRoofBuildActive) { - const bypassSnap = shiftPressed || event.shiftKey - const snappedPoint = alignFloorplanDraftPoint( - bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint), - { bypass: event.altKey || bypassSnap }, - ) + // Footprint placement (polygon context: grid / lines / off, no angle), + // mode-driven to match the chip. Alt forces (skips alignment). + const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), { + bypass: event.altKey || !isMagneticSnapActive(), + }) emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) @@ -202,34 +200,29 @@ export function useFloorplanBackgroundPlacement({ } if (isFenceBuildActive) { - const bypassSnap = shiftPressed || event.shiftKey - // Fence draft: grid snap (+ existing-wall/fence endpoint snap), then - // Figma alignment — endpoint snap wins (same precedence as move). - // While a draft is open the segment locks to 15° rays from its - // start unless Shift is held; Shift bypasses grid, magnetic, - // angle, and alignment snap. `gridSnap` keeps the regular snap - // on the world XZ grid even when the building is rotated. + // Fence draft: mode-driven (matches the chip), same as the move + // preview. `grid` snaps to the world XZ grid (rotation-safe via the + // `gridSnap` callback), `angles` locks 15° rays from the start, `lines` + // pulls onto walls / fences / alignment, `off` is free. Alt forces. const fenceStep = getSegmentGridStep() - const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap && isAngleSnapActive() + const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive() const fenceSnapped = snapFenceDraftPoint({ point: planPoint, walls, fences, start: fenceDraftStart ?? undefined, angleSnap: fenceAngleSnap, - bypassSnap, - magnetic: !bypassSnap && isMagneticSnapActive(), + magnetic: isMagneticSnapActive(), gridSnap: (p) => worldGridSnap(p, fenceStep), }) - const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep) + const fenceGridBase = worldGridSnap(planPoint, fenceStep) const fenceLocked = - !bypassSnap && - (fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]) + fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] const snappedPoint = fenceLocked || fenceAngleSnap ? fenceSnapped : alignFloorplanDraftPoint(fenceSnapped, { - bypass: event.altKey || bypassSnap || !isMagneticSnapActive(), + bypass: event.altKey || !isMagneticSnapActive(), }) emitFloorplanGridEvent('click', snappedPoint, event) @@ -310,27 +303,22 @@ export function useFloorplanBackgroundPlacement({ // / draftEnd state in the floor plan would never update, leaving // the dashed-line draft preview invisible. if (isWallBuildActive) { - const bypassSnap = shiftPressed || event.shiftKey - // Wall draft: grid snap (+ existing-wall endpoint/join snap), then - // Figma alignment — endpoint/join snap wins (same precedence as the - // move-preview branch), so committing onto a corner still works. - // While a draft is open the segment locks to 15° rays from its - // start unless Shift is held; Shift bypasses grid, magnetic, - // angle, and alignment snap. `gridSnap` keeps the regular snap - // on the world XZ grid even when the building is rotated. + // Wall draft: mode-driven (matches the chip + the move-preview branch). + // `grid` snaps to the world XZ grid (rotation-safe via `gridSnap`), + // `angles` locks 15° rays from the start, `lines` pulls the endpoint + // onto existing wall corners / edges + alignment, `off` is free. + // (Alt = commit a single wall, handled below — not a snap modifier.) const wallStep = getSegmentGridStep() - const wallAngleSnap = draftStart !== null && !bypassSnap && isAngleSnapActive() + const wallAngleSnap = draftStart !== null && isAngleSnapActive() const wallSnapped = snapWallDraftPoint({ point: planPoint, walls, start: draftStart ?? undefined, angleSnap: wallAngleSnap, - bypassSnap, gridSnap: (p) => worldGridSnap(p, wallStep), }) - const wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep) - const wallLocked = - !bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]) + const wallGridBase = worldGridSnap(planPoint, wallStep) + const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1] let snappedPoint = wallSnapped if (wallLocked) { useAlignmentGuides.getState().clear() @@ -340,7 +328,7 @@ export function useFloorplanBackgroundPlacement({ // Figma alignment pulls the endpoint onto existing wall corners / // edges, so it is a line snap — suppress it whenever magnetic snap // is off (`'off'` / `'angles'`), matching the wall-geometry snap. - bypass: event.altKey || bypassSnap || !isMagneticSnapActive(), + bypass: event.altKey || !isMagneticSnapActive(), }) } @@ -414,7 +402,6 @@ export function useFloorplanBackgroundPlacement({ setFenceDraftStart, setRoofDraftEnd, setRoofDraftStart, - shiftPressed, snapWallDraftPoint, snapPolygonDraftPoint, toPoint2D, diff --git a/packages/editor/src/store/use-floorplan-draft-preview.ts b/packages/editor/src/store/use-floorplan-draft-preview.ts index eb4ba391..a54ce08c 100644 --- a/packages/editor/src/store/use-floorplan-draft-preview.ts +++ b/packages/editor/src/store/use-floorplan-draft-preview.ts @@ -26,18 +26,45 @@ type FloorplanDraftPreviewState = { * single hottest 2D update — keeping it out of panel state is what stops the * panel re-rendering per move. `null` when idle. */ cursorPosition: SvgPoint | null + /** Live END point of the open wall / fence / roof draft segment — the per-move + * endpoint that drives the 2D draft polygon + measurement. Each is `null` + * unless that tool's draft is open. The START points stay in panel state + * (set per click, low-frequency). */ + wallDraftEnd: WallPlanPoint | null + fenceDraftEnd: WallPlanPoint | null + roofDraftEnd: WallPlanPoint | null /** Set the snapped cursor point. No-ops (skips the store update, so * subscribers don't re-render) when unchanged — `grid:move` fires far more * often than the snapped cell actually changes. */ setCursorPoint(point: WallPlanPoint | null): void /** Set the screen-space cursor point (deduped on x/y). */ setCursorPosition(point: SvgPoint | null): void + setWallDraftEnd(point: WallPlanPoint | null): void + setFenceDraftEnd(point: WallPlanPoint | null): void + setRoofDraftEnd(point: WallPlanPoint | null): void reset(): void } +function setPlanPointField( + field: 'wallDraftEnd' | 'fenceDraftEnd' | 'roofDraftEnd', + point: WallPlanPoint | null, +) { + return ( + state: FloorplanDraftPreviewState, + ): Partial | typeof state => { + const prev = state[field] + if (!point && !prev) return state + if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state + return { [field]: point } + } +} + export const useFloorplanDraftPreview = create((set) => ({ cursorPoint: null, cursorPosition: null, + wallDraftEnd: null, + fenceDraftEnd: null, + roofDraftEnd: null, setCursorPoint: (point) => set((state) => { const prev = state.cursorPoint @@ -52,10 +79,23 @@ export const useFloorplanDraftPreview = create((set) if (point && prev && prev.x === point.x && prev.y === point.y) return state return { cursorPosition: point } }), + setWallDraftEnd: (point) => set(setPlanPointField('wallDraftEnd', point)), + setFenceDraftEnd: (point) => set(setPlanPointField('fenceDraftEnd', point)), + setRoofDraftEnd: (point) => set(setPlanPointField('roofDraftEnd', point)), reset: () => set((state) => - state.cursorPoint === null && state.cursorPosition === null + state.cursorPoint === null && + state.cursorPosition === null && + state.wallDraftEnd === null && + state.fenceDraftEnd === null && + state.roofDraftEnd === null ? state - : { cursorPoint: null, cursorPosition: null }, + : { + cursorPoint: null, + cursorPosition: null, + wallDraftEnd: null, + fenceDraftEnd: null, + roofDraftEnd: null, + }, ), }))