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:
co-authored by
Claude Opus 4.8
parent
bc9e075b2d
commit
167f0868ef
@@ -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,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user