perf+fix(editor): wall/fence/roof 2D draft to store+leaf, finish snapping-mode parity

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 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 14:13:58 -04:00
co-authored by Claude Opus 4.8
parent bc9e075b2d
commit 167f0868ef
3 changed files with 324 additions and 175 deletions
@@ -4715,6 +4715,204 @@ function FloorplanCursorIndicator(
return <Editor2dFloorplanCursorIndicatorOverlay {...props} cursorPosition={cursorPosition} /> return <Editor2dFloorplanCursorIndicatorOverlay {...props} cursorPosition={cursorPosition} />
} }
// 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 (
<>
<FloorplanDraftLayer
anchorFill={draftStroke}
draftAnchorPoints={EMPTY_DRAFT_ANCHOR_POINTS}
draftFill={draftFill}
draftPolygonPoints={draftPolygonPoints}
draftStroke={draftStroke}
linearDraftSegment={fenceDraftSegment}
polygonDraftClosingSegment={null}
polygonDraftPolygonPoints={null}
polygonDraftPolylinePoints={null}
unitsPerPixel={unitsPerPixel}
/>
{draftWallMeasurement && (
<FloorplanDraftWallMeasurement
labelBackground={isDark ? '#0f172a' : '#ffffff'}
labelText={isDark ? '#e2e8f0' : '#171717'}
measurement={draftWallMeasurement}
measurementStroke={measurementStroke}
sceneRotationDeg={sceneRotationDeg}
unitsPerPixel={unitsPerPixel}
/>
)}
</>
)
}
const EMPTY_DRAFT_ANCHOR_POINTS: Array<{ x: number; y: number; isPrimary: boolean }> = []
export function FloorplanPanel({ export function FloorplanPanel({
/** /**
* Element to portal the compass button into. The 2D/3D navigation poses stay * 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 FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg
latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg 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<WallPlanPoint | null>(null) const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
const [draftEnd, setDraftEnd] = useState<WallPlanPoint | null>(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<WallPlanPoint | null>(null) const [fenceDraftStart, setFenceDraftStart] = useState<WallPlanPoint | null>(null)
const [fenceDraftEnd, setFenceDraftEnd] = useState<WallPlanPoint | null>(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<WallPlanPoint | null>(null) const [roofDraftStart, setRoofDraftStart] = useState<WallPlanPoint | null>(null)
const [roofDraftEnd, setRoofDraftEnd] = useState<WallPlanPoint | null>(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<WallPlanPoint[]>([]) const [ceilingDraftPoints, setCeilingDraftPoints] = useState<WallPlanPoint[]>([])
const [slabDraftPoints, setSlabDraftPoints] = useState<WallPlanPoint[]>([]) const [slabDraftPoints, setSlabDraftPoints] = useState<WallPlanPoint[]>([])
const [zoneDraftPoints, setZoneDraftPoints] = useState<WallPlanPoint[]>([]) const [zoneDraftPoints, setZoneDraftPoints] = useState<WallPlanPoint[]>([])
@@ -5879,120 +6099,10 @@ export function FloorplanPanel({
}) })
}, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon]) }, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
const draftPolygon = useMemo(() => { // The live wall / fence / roof draft preview (polygon + fence segment + wall
if (!(levelId && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))) { // measurement) moved into `FloorplanLinearDraftLayer`, which reads the per-
return null // move END points from the draft store so it re-renders per move without
} // re-rendering this panel.
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])
const activePolygonDraftPoints = useMemo(() => { const activePolygonDraftPoints = useMemo(() => {
if (isCeilingBuildActive) { if (isCeilingBuildActive) {
return ceilingDraftPoints return ceilingDraftPoints
@@ -8651,10 +8761,12 @@ export function FloorplanPanel({
} }
if (isRoofBuildActive) { if (isRoofBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey // Roof is placed as a footprint (no directional draw → polygon context:
let snappedPoint = bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint) // grid / lines / off, no angle lock). Mode-driven, matching the chip:
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { // `grid` quantizes via `getSnappedFloorplanPoint` (step 0 in non-grid
bypass: event.altKey || bypassSnap, // modes), `lines` pulls onto alignment, `off` is free. Alt forces.
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
bypass: event.altKey || !isMagneticSnapActive(),
}) })
emitFloorplanGridEvent('move', snappedPoint, event) emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) => setCursorPoint((previousPoint) =>
@@ -9225,7 +9337,6 @@ export function FloorplanPanel({
setFenceDraftStart, setFenceDraftStart,
setRoofDraftEnd, setRoofDraftEnd,
setRoofDraftStart, setRoofDraftStart,
shiftPressed,
snapPolygonDraftPoint, snapPolygonDraftPoint,
snapWallDraftPoint: snapWallDraftPointMagnetic, snapWallDraftPoint: snapWallDraftPointMagnetic,
toPoint2D, toPoint2D,
@@ -10816,6 +10927,13 @@ export function FloorplanPanel({
outlineWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH} 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. */}
<FloorplanDraftLayer <FloorplanDraftLayer
anchorFill={palette.anchor} anchorFill={palette.anchor}
draftAnchorPoints={[ draftAnchorPoints={[
@@ -10835,28 +10953,32 @@ export function FloorplanPanel({
})), })),
]} ]}
draftFill={palette.draftFill} draftFill={palette.draftFill}
draftPolygonPoints={draftPolygonPoints} draftPolygonPoints={null}
draftStroke={palette.draftStroke} draftStroke={palette.draftStroke}
linearDraftSegment={fenceDraftSegment} linearDraftSegment={null}
// The cursor-following polygon-draft preview moved to
// `FloorplanDraftCursorLayer` (reads the live cursor from the
// draft store), so this shared layer no longer carries it.
polygonDraftClosingSegment={null} polygonDraftClosingSegment={null}
polygonDraftPolygonPoints={null} polygonDraftPolygonPoints={null}
polygonDraftPolylinePoints={null} polygonDraftPolylinePoints={null}
unitsPerPixel={floorplanUnitsPerPixel} unitsPerPixel={floorplanUnitsPerPixel}
/> />
{draftWallMeasurement && ( <FloorplanLinearDraftLayer
<FloorplanDraftWallMeasurement draftFill={palette.draftFill}
labelBackground={isDark ? '#0f172a' : '#ffffff'} draftStroke={palette.draftStroke}
labelText={isDark ? '#e2e8f0' : '#171717'} fenceDraftStart={fenceDraftStart}
measurement={draftWallMeasurement} isDark={isDark}
measurementStroke={palette.measurementStroke} isFenceBuildActive={isFenceBuildActive}
sceneRotationDeg={floorplanSceneRotationDeg} isRoofBuildActive={isRoofBuildActive}
unitsPerPixel={floorplanUnitsPerPixel} isWallBuildActive={isWallBuildActive}
/> levelId={levelId}
)} measurementStroke={palette.measurementStroke}
roofDraftStart={roofDraftStart}
sceneRotationDeg={floorplanSceneRotationDeg}
unit={unit}
unitsPerPixel={floorplanUnitsPerPixel}
wallDraftStart={draftStart}
walls={walls}
/>
{/* Wall / fence endpoint, wall curve, slab / ceiling / {/* Wall / fence endpoint, wall curve, slab / ceiling /
zone vertex+midpoint+edge handles are all driven by the zone vertex+midpoint+edge handles are all driven by the
@@ -62,7 +62,6 @@ type UseFloorplanBackgroundPlacementArgs = {
setFenceDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>> setFenceDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setRoofDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>> setRoofDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setRoofDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>> setRoofDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
shiftPressed: boolean
snapWallDraftPoint: (args: { snapWallDraftPoint: (args: {
point: WallPlanPoint point: WallPlanPoint
walls: WallNode[] walls: WallNode[]
@@ -122,7 +121,6 @@ export function useFloorplanBackgroundPlacement({
setFenceDraftStart, setFenceDraftStart,
setRoofDraftEnd, setRoofDraftEnd,
setRoofDraftStart, setRoofDraftStart,
shiftPressed,
snapWallDraftPoint, snapWallDraftPoint,
snapPolygonDraftPoint, snapPolygonDraftPoint,
toPoint2D, toPoint2D,
@@ -184,11 +182,11 @@ export function useFloorplanBackgroundPlacement({
} }
if (isRoofBuildActive) { if (isRoofBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey // Footprint placement (polygon context: grid / lines / off, no angle),
const snappedPoint = alignFloorplanDraftPoint( // mode-driven to match the chip. Alt forces (skips alignment).
bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint), const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
{ bypass: event.altKey || bypassSnap }, bypass: event.altKey || !isMagneticSnapActive(),
) })
emitFloorplanGridEvent('click', snappedPoint, event) emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
@@ -202,34 +200,29 @@ export function useFloorplanBackgroundPlacement({
} }
if (isFenceBuildActive) { if (isFenceBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey // Fence draft: mode-driven (matches the chip), same as the move
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then // preview. `grid` snaps to the world XZ grid (rotation-safe via the
// Figma alignment — endpoint snap wins (same precedence as move). // `gridSnap` callback), `angles` locks 15° rays from the start, `lines`
// While a draft is open the segment locks to 15° rays from its // pulls onto walls / fences / alignment, `off` is free. Alt forces.
// 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.
const fenceStep = getSegmentGridStep() const fenceStep = getSegmentGridStep()
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap && isAngleSnapActive() const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
const fenceSnapped = snapFenceDraftPoint({ const fenceSnapped = snapFenceDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
fences, fences,
start: fenceDraftStart ?? undefined, start: fenceDraftStart ?? undefined,
angleSnap: fenceAngleSnap, angleSnap: fenceAngleSnap,
bypassSnap, magnetic: isMagneticSnapActive(),
magnetic: !bypassSnap && isMagneticSnapActive(),
gridSnap: (p) => worldGridSnap(p, fenceStep), gridSnap: (p) => worldGridSnap(p, fenceStep),
}) })
const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep) const fenceGridBase = worldGridSnap(planPoint, fenceStep)
const fenceLocked = const fenceLocked =
!bypassSnap && fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
(fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1])
const snappedPoint = const snappedPoint =
fenceLocked || fenceAngleSnap fenceLocked || fenceAngleSnap
? fenceSnapped ? fenceSnapped
: alignFloorplanDraftPoint(fenceSnapped, { : alignFloorplanDraftPoint(fenceSnapped, {
bypass: event.altKey || bypassSnap || !isMagneticSnapActive(), bypass: event.altKey || !isMagneticSnapActive(),
}) })
emitFloorplanGridEvent('click', snappedPoint, event) emitFloorplanGridEvent('click', snappedPoint, event)
@@ -310,27 +303,22 @@ export function useFloorplanBackgroundPlacement({
// / draftEnd state in the floor plan would never update, leaving // / draftEnd state in the floor plan would never update, leaving
// the dashed-line draft preview invisible. // the dashed-line draft preview invisible.
if (isWallBuildActive) { if (isWallBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey // Wall draft: mode-driven (matches the chip + the move-preview branch).
// Wall draft: grid snap (+ existing-wall endpoint/join snap), then // `grid` snaps to the world XZ grid (rotation-safe via `gridSnap`),
// Figma alignment — endpoint/join snap wins (same precedence as the // `angles` locks 15° rays from the start, `lines` pulls the endpoint
// move-preview branch), so committing onto a corner still works. // onto existing wall corners / edges + alignment, `off` is free.
// While a draft is open the segment locks to 15° rays from its // (Alt = commit a single wall, handled below — not a snap modifier.)
// 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.
const wallStep = getSegmentGridStep() const wallStep = getSegmentGridStep()
const wallAngleSnap = draftStart !== null && !bypassSnap && isAngleSnapActive() const wallAngleSnap = draftStart !== null && isAngleSnapActive()
const wallSnapped = snapWallDraftPoint({ const wallSnapped = snapWallDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
start: draftStart ?? undefined, start: draftStart ?? undefined,
angleSnap: wallAngleSnap, angleSnap: wallAngleSnap,
bypassSnap,
gridSnap: (p) => worldGridSnap(p, wallStep), gridSnap: (p) => worldGridSnap(p, wallStep),
}) })
const wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep) const wallGridBase = worldGridSnap(planPoint, wallStep)
const wallLocked = const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]
!bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1])
let snappedPoint = wallSnapped let snappedPoint = wallSnapped
if (wallLocked) { if (wallLocked) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
@@ -340,7 +328,7 @@ export function useFloorplanBackgroundPlacement({
// Figma alignment pulls the endpoint onto existing wall corners / // Figma alignment pulls the endpoint onto existing wall corners /
// edges, so it is a line snap — suppress it whenever magnetic snap // edges, so it is a line snap — suppress it whenever magnetic snap
// is off (`'off'` / `'angles'`), matching the wall-geometry 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, setFenceDraftStart,
setRoofDraftEnd, setRoofDraftEnd,
setRoofDraftStart, setRoofDraftStart,
shiftPressed,
snapWallDraftPoint, snapWallDraftPoint,
snapPolygonDraftPoint, snapPolygonDraftPoint,
toPoint2D, toPoint2D,
@@ -26,18 +26,45 @@ type FloorplanDraftPreviewState = {
* single hottest 2D update — keeping it out of panel state is what stops the * single hottest 2D update — keeping it out of panel state is what stops the
* panel re-rendering per move. `null` when idle. */ * panel re-rendering per move. `null` when idle. */
cursorPosition: SvgPoint | null 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 /** Set the snapped cursor point. No-ops (skips the store update, so
* subscribers don't re-render) when unchanged — `grid:move` fires far more * subscribers don't re-render) when unchanged — `grid:move` fires far more
* often than the snapped cell actually changes. */ * often than the snapped cell actually changes. */
setCursorPoint(point: WallPlanPoint | null): void setCursorPoint(point: WallPlanPoint | null): void
/** Set the screen-space cursor point (deduped on x/y). */ /** Set the screen-space cursor point (deduped on x/y). */
setCursorPosition(point: SvgPoint | null): void setCursorPosition(point: SvgPoint | null): void
setWallDraftEnd(point: WallPlanPoint | null): void
setFenceDraftEnd(point: WallPlanPoint | null): void
setRoofDraftEnd(point: WallPlanPoint | null): void
reset(): void reset(): void
} }
function setPlanPointField(
field: 'wallDraftEnd' | 'fenceDraftEnd' | 'roofDraftEnd',
point: WallPlanPoint | null,
) {
return (
state: FloorplanDraftPreviewState,
): Partial<FloorplanDraftPreviewState> | 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<FloorplanDraftPreviewState>((set) => ({ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set) => ({
cursorPoint: null, cursorPoint: null,
cursorPosition: null, cursorPosition: null,
wallDraftEnd: null,
fenceDraftEnd: null,
roofDraftEnd: null,
setCursorPoint: (point) => setCursorPoint: (point) =>
set((state) => { set((state) => {
const prev = state.cursorPoint const prev = state.cursorPoint
@@ -52,10 +79,23 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
if (point && prev && prev.x === point.x && prev.y === point.y) return state if (point && prev && prev.x === point.x && prev.y === point.y) return state
return { cursorPosition: point } return { cursorPosition: point }
}), }),
setWallDraftEnd: (point) => set(setPlanPointField('wallDraftEnd', point)),
setFenceDraftEnd: (point) => set(setPlanPointField('fenceDraftEnd', point)),
setRoofDraftEnd: (point) => set(setPlanPointField('roofDraftEnd', point)),
reset: () => reset: () =>
set((state) => set((state) =>
state.cursorPoint === null && state.cursorPosition === null state.cursorPoint === null &&
state.cursorPosition === null &&
state.wallDraftEnd === null &&
state.fenceDraftEnd === null &&
state.roofDraftEnd === null
? state ? state
: { cursorPoint: null, cursorPosition: null }, : {
cursorPoint: null,
cursorPosition: null,
wallDraftEnd: null,
fenceDraftEnd: null,
roofDraftEnd: null,
},
), ),
})) }))