diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 133d4058..f202ed36 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -95,6 +95,7 @@ import useEditor, { isMagneticSnapActive, selectSiteFloorplanContext, } from '../../store/use-editor' +import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview' import useInteractionScope, { useActiveHandleDrag, useEndpointReshape, @@ -4592,6 +4593,129 @@ function FloorplanStairBuildPreviewLayer({ ) } +// Leaf overlay for the cursor-following draft preview: the cursor crosshair plus +// the live polygon-draft edge (slab / zone / ceiling). Subscribes to +// `useFloorplanDraftPreview.cursorPoint` directly so a per-`grid:move` cursor +// update re-renders ONLY this tiny layer — never the (~120-220ms) FloorplanPanel. +// Everything else it needs is per-click panel state (the committed draft points) +// passed as props, so it re-renders on click via the parent and on move via the +// store. SVG mirrors the cursor-driven branches of `FloorplanDraftLayer`. +function FloorplanDraftCursorLayer({ + activePolygonDraftPoints, + isPolygonDraftBuildActive, + cursorColor, + draftFill, + draftStroke, + polygonDraftStroke, + unitsPerPixel, +}: { + activePolygonDraftPoints: WallPlanPoint[] + isPolygonDraftBuildActive: boolean + cursorColor: string + draftFill: string + draftStroke: string + polygonDraftStroke: string | undefined + unitsPerPixel: number +}) { + const cursorPoint = useFloorplanDraftPreview((s) => s.cursorPoint) + const activeStroke = polygonDraftStroke ?? draftStroke + const strokeWidth = polygonDraftStroke ? FLOORPLAN_WALL_STROKE_WIDTH : '0.08' + + const polygon = useMemo(() => { + if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) { + return null + } + return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)]) + }, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive]) + + const polyline = useMemo(() => { + if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length > 0)) { + return null + } + return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)]) + }, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive]) + + const closingSegment = useMemo(() => { + const firstPoint = activePolygonDraftPoints[0] + if ( + !(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2) || + !firstPoint + ) { + return null + } + return { + x1: toSvgX(cursorPoint[0]), + y1: toSvgY(cursorPoint[1]), + x2: toSvgX(firstPoint[0]), + y2: toSvgY(firstPoint[1]), + } + }, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive]) + + return ( + <> + {polygon && } + + {polyline && ( + + )} + + {closingSegment && ( + + )} + + {cursorPoint && ( + + + + + )} + + ) +} + +// Thin subscriber wrapper for the coordinate-badge overlay: reads the hot +// screen-space cursor position from the draft store so a per-`pointermove` +// update re-renders only the badge, not FloorplanPanel. The remaining props +// (tool / mode / colour) are per-interaction panel state passed through — they +// change rarely, never per move. +function FloorplanCursorIndicator( + props: Omit, 'cursorPosition'>, +) { + const cursorPosition = useFloorplanDraftPreview((s) => s.cursorPosition) + return +} + export function FloorplanPanel({ /** * Element to portal the compass button into. The 2D/3D navigation poses stay @@ -4751,8 +4875,33 @@ export function FloorplanPanel({ const [referenceScaleUnit, setReferenceScaleUnit] = useState( unit === 'imperial' ? 'feet' : 'meters', ) - const [cursorPoint, setCursorPoint] = useState(null) - const [floorplanCursorPosition, setFloorplanCursorPosition] = useState(null) + // The cursor point is the hottest 2D state — every build/edit tool republishes + // it on `grid:move`. It lives in `useFloorplanDraftPreview` (not panel state) + // so a per-move update re-renders only `FloorplanDraftCursorLayer`, not this + // ~200ms panel. This shim keeps the `setCursorPoint(value)` / + // `setCursorPoint(prev => …)` call sites (and their snap-SFX side effects) + // unchanged while routing the write to the store; reads go through the store. + const setCursorPoint = useCallback( + (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => { + const store = useFloorplanDraftPreview.getState() + const value = typeof next === 'function' ? next(store.cursorPoint) : next + store.setCursorPoint(value) + }, + [], + ) + // The coordinate-badge cursor position is set on every SVG `pointermove` while + // a build/select tool is active — the single hottest 2D update. It lives in + // `useFloorplanDraftPreview` (not panel state) so a move re-renders only the + // badge leaf, not this panel. Shim preserves the existing call sites (value + + // functional-updater forms) while routing the write to the store. + const setFloorplanCursorPosition = useCallback( + (next: SvgPoint | null | ((prev: SvgPoint | null) => SvgPoint | null)) => { + const store = useFloorplanDraftPreview.getState() + const value = typeof next === 'function' ? next(store.cursorPosition) : next + store.setCursorPosition(value) + }, + [], + ) const [wallEndpointDraft, setWallEndpointDraft] = useState(null) const [wallCurveDraft, setWallCurveDraft] = useState(null) const [hoveredOpeningId, setHoveredOpeningId] = useState(null) @@ -5867,37 +6016,9 @@ export function FloorplanPanel({ slabDraftPoints, zoneDraftPoints, ]) - const polygonDraftPolylinePoints = useMemo(() => { - if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length > 0)) { - return null - } - - return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)]) - }, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive]) - const polygonDraftPolygonPoints = useMemo(() => { - if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) { - return null - } - - return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)]) - }, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive]) - const polygonDraftClosingSegment = useMemo(() => { - if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) { - return null - } - - const firstPoint = activePolygonDraftPoints[0] - if (!firstPoint) { - return null - } - - return { - x1: toSvgX(cursorPoint[0]), - y1: toSvgY(cursorPoint[1]), - x2: toSvgX(firstPoint[0]), - y2: toSvgY(firstPoint[1]), - } - }, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive]) + // The cursor-following polygon-draft preview (slab / zone / ceiling) moved into + // `FloorplanDraftCursorLayer`, which reads the live cursor from the draft store + // so it re-renders per move without re-rendering this panel. const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1 @@ -6315,8 +6436,12 @@ export function FloorplanPanel({ // While the cursor drives live geometry (items, drafts, moves), `fittedViewport` changes every // pointermove. Syncing `viewport` here would call setState in a tight loop (max update depth). + // `cursorPoint` now lives in the draft store; read it non-reactively (this + // effect only re-runs when `fittedViewport` / the other transient signals + // change, and the viewport never refits mid-draft because scene data is + // stable then — so a live store read is sufficient and correct). const transientFloorplanFit = - cursorPoint != null || + useFloorplanDraftPreview.getState().cursorPoint != null || movingNode != null || endpointReshape != null || isCurveReshape || @@ -6329,7 +6454,6 @@ export function FloorplanPanel({ ) } }, [ - cursorPoint, endpointReshape, fittedViewport, isCurveReshape, @@ -10307,9 +10431,8 @@ export function FloorplanPanel({ >
- @@ -10764,24 +10882,17 @@ export function FloorplanPanel({ /> )} - {cursorPoint && ( - - - - - )} + {activeDraftAnchorPoint && ( ((set) => ({ + cursorPoint: null, + cursorPosition: null, + setCursorPoint: (point) => + set((state) => { + const prev = state.cursorPoint + if (!point && !prev) return state + if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state + return { cursorPoint: point } + }), + setCursorPosition: (point) => + set((state) => { + const prev = state.cursorPosition + if (!point && !prev) return state + if (point && prev && prev.x === point.x && prev.y === point.y) return state + return { cursorPosition: point } + }), + reset: () => + set((state) => + state.cursorPoint === null && state.cursorPosition === null + ? state + : { cursorPoint: null, cursorPosition: null }, + ), +}))