From 1f829d52ed3c8e066f00e0fc8deec722c74015e1 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 14 Jun 2026 01:54:45 -0400 Subject: [PATCH 1/6] feat(editor): draggable move handle for wall-hosted doors & windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doors and windows could only be moved via the floating action menu — their 3D handle rig declared width/height resize arrows but no move grip, and Ctrl/Meta-drag was a no-op for them. Add a press-drag move cross and make direct-drag work for every bespoke-mover kind. - door/window: add a `tap-action` `move-cross` handle (plane node-normal, portal grandparent, `engageMoveDrag`) mirroring the item wall grip. It routes through the existing per-kind move tool (3D `affordanceTools.move`, 2D `floorplanMoveTarget`) — wall-bound slide + re-host onto another wall — so the grip, the floating Move button, and the 2D plan's move dot share one pipeline. Grab-drag-release commits without a second click. - canDirectMoveNode: gate Ctrl/Meta-drag on `movable || affordanceTools.move` (the 3D-mountable move paths) instead of `movable` only, so doors/windows/ walls/slabs/stairs/… are draggable in 3D as they already are in 2D. Floorplan-only movers (zone) stay excluded — no 3D tool mounts. The floating helper auto-syncs (it reads canDirectMoveNode). - TapActionArrow: honor `plane: 'node-normal'` by tilting the move cross [π/2,0,0] into the wall face — previously ignored, so the item wall grip rendered flat too. Now door/window/wall-item crosses lie in the wall. - use-node-events: split the drag-suppression gate. `inputDragging` still suppresses SELECTION events (the synthesized release-click would re-select), but no longer suppresses SPATIAL events (enter/move/leave) — a surface-following move tool runs with `inputDragging` set and needs wall:move to track the cursor. General consumers that must ignore drags (viewer hover, box-select) already self-gate on `inputDragging`; the editor's select-hover and paint-preview enter handlers now gate on it too. - handle-arrow: make handle hit areas inert while `placementDragMode` is set, so a move grip riding the dragged node can't intercept the ray and starve the move tool's surface raycast. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/registry/index.ts | 1 + packages/core/src/registry/registry.ts | 14 ++++++++ .../editor/handles/handle-arrow.tsx | 16 +++++++++ .../components/editor/node-arrow-handles.tsx | 16 ++++++--- .../components/editor/selection-manager.tsx | 12 ++++++- .../src/lib/direct-manipulation.test.ts | 15 ++++++-- .../editor/src/lib/direct-manipulation.ts | 6 +++- packages/nodes/src/door/definition.ts | 22 ++++++++++++ packages/nodes/src/window/definition.ts | 22 ++++++++++++ packages/viewer/src/hooks/use-node-events.ts | 36 +++++++++++-------- 10 files changed, 137 insertions(+), 23 deletions(-) diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 8dcfc58f..a0b58e53 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -18,6 +18,7 @@ export { discoverPlugins, getHostRefFields, getSelectableKinds, + hasRegistry3DMoveTool, isDrawnViaTool, isDrawnViaToolKind, isPresettable, diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 051176fc..57aaabcf 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -146,6 +146,20 @@ export function isRegistryMovable(kind: string): boolean { return false } +/** + * Whether the kind has a move tool that MOUNTS in the 3D viewport — the + * generic `capabilities.movable` mover or a bespoke `affordanceTools.move`. + * Narrower than {@link isRegistryMovable}, which also accepts floorplan-only + * movers (e.g. zone) that have no 3D tool. Gates 3D direct move: Ctrl/Meta-drag + * and the move-cross grip. Kept beside `isRegistryMovable` so the 2D and 3D + * movability predicates can't drift apart. + */ +export function hasRegistry3DMoveTool(kind: string): boolean { + const def = nodeRegistry.get(kind) + if (!def) return false + return def.capabilities.movable !== undefined || def.affordanceTools?.move !== undefined +} + /** * Whether the kind can be saved as a reusable preset. Default: an * explicit `capabilities.presettable` boolean wins; otherwise the kind diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index 0a91fc9f..caf71ffe 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -12,12 +12,27 @@ import { DoubleSide, ExtrudeGeometry, type Group, + type Intersection, + Mesh, + type Raycaster, Shape, TorusGeometry, } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' +import useEditor from '../../../store/use-editor' + +// While a press-drag move is in flight (`placementDragMode`), the move tool +// owns the pointer and the handle rig rides the moving node — so a handle hit +// area would sit under the cursor and starve the tool's surface raycast +// (`wall:move` for openings, `grid:move` for free movers), freezing the drag. +// Make every handle hit area inert for the duration; the indicator mesh still +// renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible. +function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void { + if (useEditor.getState().placementDragMode) return + Mesh.prototype.raycast.call(this, raycaster, intersects) +} export const ARROW_SCALE = 0.65 export const ARROW_COLOR = '#8381ed' @@ -382,6 +397,7 @@ export function InvisibleHandleHitArea({ onPointerDown={onPointerDown} onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave} + raycast={hitAreaRaycast} renderOrder={HIT_AREA_RENDER_ORDER} scale={scale} /> diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 07fd9fe1..5dba030f 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -70,6 +70,10 @@ const _resizePositionW = new Vector3() const _resizeRay = new Ray() const _resizeRayW = new Vector3() +// Tilt that stands a flat XZ-plane move cross up into a node's facing plane +// (its local XY = a wall face) for `plane: 'node-normal'` handles. +const NODE_NORMAL_TILT: [number, number, number] = [Math.PI / 2, 0, 0] + function axisVector(axis: 'x' | 'y' | 'z', target: Vector3) { target.set(0, 0, 0) if (axis === 'x') target.x = 1 @@ -1230,7 +1234,7 @@ function TranslateArrow({ // The cross is built flat in the XZ plane. On a wall, tilt it up about X so // it lies in the item-local XY plane (= the wall face). - const iconRotation: [number, number, number] = isWallPlane ? [Math.PI / 2, 0, 0] : [0, 0, 0] + const iconRotation: [number, number, number] = isWallPlane ? NODE_NORMAL_TILT : [0, 0, 0] return ( ) } diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index ace508cc..71d32281 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1101,7 +1101,11 @@ export const SelectionManager = () => { } const onEnter = (event: NodeEvent) => { - if (boxSelectHandled) return + // A host-driven drag (handle resize/rotate) sets `inputDragging`. + // useNodeEvents now emits hover events during such a drag so surface + // move tools keep tracking the cursor — but paint preview must not fire + // mid-drag, so gate on `inputDragging` here too. + if (boxSelectHandled || useViewer.getState().inputDragging) return const interaction = getPaintInteraction(event) if (!interaction) return @@ -1665,6 +1669,11 @@ export const SelectionManager = () => { if (movingNode || curvingWall || curvingFence) return const onEnter = (event: NodeEvent) => { + // A host-driven drag (handle resize/rotate, box-select) sets + // `inputDragging`. useNodeEvents still emits hover events during it so + // surface move tools keep tracking — but the select-hover outline must + // stay put, so don't repaint under the cursor mid-drag. + if (useViewer.getState().inputDragging) return const node = event.node const currentPhase = useEditor.getState().phase @@ -1692,6 +1701,7 @@ export const SelectionManager = () => { } const onLeave = (event: NodeEvent) => { + if (useViewer.getState().inputDragging) return const nodeId = event?.node?.id if (nodeId && useViewer.getState().hoveredId === nodeId) { useViewer.setState({ hoveredId: null }) diff --git a/packages/editor/src/lib/direct-manipulation.test.ts b/packages/editor/src/lib/direct-manipulation.test.ts index 377d49cd..61297d99 100644 --- a/packages/editor/src/lib/direct-manipulation.test.ts +++ b/packages/editor/src/lib/direct-manipulation.test.ts @@ -62,14 +62,16 @@ describe('resolveDirectRotationDragDelta', () => { }) describe('canDirectMoveNode', () => { - test('excludes floorplan-only move targets from 3D direct move', () => { + // Accepts kinds with a 3D-mountable move tool (`movable` or + // `affordanceTools.move`); floorplan-only movers (zone) are excluded. + test('rejects floorplan-only move targets (no 3D tool mounts)', () => { const kind = 'direct-move-floorplan-only-test' registerTestDefinition(kind, { floorplanMoveTarget: {} as never }) expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false) }) - test('excludes bespoke move tools from 3D direct move', () => { + test('accepts kinds with a bespoke move tool', () => { const kind = 'direct-move-bespoke-tool-test' registerTestDefinition(kind, { affordanceTools: { @@ -77,7 +79,7 @@ describe('canDirectMoveNode', () => { } as never, }) - expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false) + expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true) }) test('accepts nodes with the generic movable capability', () => { @@ -90,4 +92,11 @@ describe('canDirectMoveNode', () => { expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true) }) + + test('rejects kinds with no registered move path', () => { + const kind = 'direct-move-none-test' + registerTestDefinition(kind, {}) + + expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false) + }) }) diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts index 54564e57..db2649a7 100644 --- a/packages/editor/src/lib/direct-manipulation.ts +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -4,6 +4,7 @@ import { createSceneApi, DEFAULT_ANGLE_STEP, type HandleDescriptor, + hasRegistry3DMoveTool, nodeRegistry, type SceneApi, useScene, @@ -34,7 +35,10 @@ export function canDirectRotateNode(node: AnyNode): boolean { } export function canDirectMoveNode(node: AnyNode): boolean { - return nodeRegistry.get(node.type)?.capabilities?.movable !== undefined + // 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that + // mounts in 3D — distinct from `isRegistryMovable`, which also accepts + // floorplan-only movers (zone) for the 2D plan. + return hasRegistry3DMoveTool(node.type) } export function snapDirectRotationDelta(delta: number, free: boolean): number { diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index f006e263..ba1b1d16 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -19,6 +19,9 @@ const SIDE_HANDLE_OFFSET = 0.24 const HEIGHT_HANDLE_OFFSET = 0.24 const MIN_DOOR_HEIGHT = 0.5 const MIN_DOOR_WIDTH = 0.3 +// How far the move cross floats off the wall face (+Z, the door's facing +// normal) so it's grabbable instead of buried in the leaf/frame. +const MOVE_HANDLE_LIFT = 0.12 function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { if (!door.wallId) return Number.POSITIVE_INFINITY @@ -112,7 +115,26 @@ function doorHeightHandle(): HandleDescriptor { } } +// Press-drag move grip at the door centre, standing in the wall face. Routes +// through the same move tool as the floating Move button (3D +// `affordanceTools.move`, 2D `floorplanMoveTarget`) — wall slide + re-host onto +// another wall — but `engageMoveDrag` commits on release, with no second click. +function doorMoveHandle(): HandleDescriptor { + return { + kind: 'tap-action', + shape: 'move-cross', + plane: 'node-normal', + portal: 'grandparent', + cursor: 'move', + onActivate: (node, _scene, editor) => editor.engageMoveDrag(node), + placement: { + position: () => [0, 0, MOVE_HANDLE_LIFT], + }, + } +} + const doorHandles: HandleDescriptor[] = [ + doorMoveHandle(), doorWidthHandle('left'), doorWidthHandle('right'), doorHeightHandle(), diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 96f8b11c..1e32a160 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -18,6 +18,9 @@ const SIDE_HANDLE_OFFSET = 0.24 const HEIGHT_HANDLE_OFFSET = 0.24 const MIN_WINDOW_HEIGHT = 0.3 const MIN_WINDOW_WIDTH = 0.3 +// How far the move cross floats off the wall face (+Z, the window's facing +// normal) so it's grabbable instead of buried in the sash/frame. +const MOVE_HANDLE_LIFT = 0.12 function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { if (!w.wallId) return Number.POSITIVE_INFINITY @@ -113,7 +116,26 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor { + return { + kind: 'tap-action', + shape: 'move-cross', + plane: 'node-normal', + portal: 'grandparent', + cursor: 'move', + onActivate: (node, _scene, editor) => editor.engageMoveDrag(node), + placement: { + position: () => [0, 0, MOVE_HANDLE_LIFT], + }, + } +} + const windowHandles: HandleDescriptor[] = [ + windowMoveHandle(), windowWidthHandle('left'), windowWidthHandle('right'), windowHeightHandle('top'), diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index a33beab3..98fd5d04 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -36,52 +36,60 @@ export function useNodeEvents(node: NodeByKind, type: emitter.emit(eventKey, payload as never) } - // Suppress node pointer events while an interaction drag is in - // progress. `cameraDragging` covers orbit/pan/dolly; `inputDragging` - // covers host-driven drags (editor handle arrows etc.). Without - // this, the synthesized click on pointerup would reroute selection - // to whatever mesh the cursor lands on at release. - const isInteractionActive = () => { + // Camera drags (orbit / pan / dolly) suppress ALL node pointer events. + // + // `inputDragging` (host-driven drags: handle arrows, press-drag moves) + // additionally suppresses the SELECTION events — without it the click + // synthesized on pointer-release would reroute selection to whatever mesh + // sits under the cursor at release. It must NOT suppress the SPATIAL events + // (`enter` / `move` / `leave`): a surface-following move tool — a door / + // window sliding along a wall — runs WITH `inputDragging` set and depends on + // those events to track the cursor. Consumers that should ignore drag-time + // spatial events gate on `inputDragging` themselves (the editor's hover and + // paint paths, box-select), so emitting them during a drag only reaches the + // active move tool that wants them. + const spatialSuppressed = () => useViewer.getState().cameraDragging + const selectionSuppressed = () => { const s = useViewer.getState() return s.cameraDragging || s.inputDragging } return { onPointerDown: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (selectionSuppressed()) return if (e.button !== 0) return emit('pointerdown', e) }, onPointerUp: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (selectionSuppressed()) return if (e.button !== 0) return emit('pointerup', e) // Synthesize a click event on pointer up to be more forgiving than R3F's default onClick // which often fails if the mouse moves even 1 pixel. emit('click', e) }, - onClick: (e: ThreeEvent) => { + onClick: (_e: ThreeEvent) => { // Disable default R3F click since we synthesize it on pointerup // This prevents double-clicks from firing twice. }, onPointerEnter: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (spatialSuppressed()) return emit('enter', e) }, onPointerLeave: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (spatialSuppressed()) return emit('leave', e) }, onPointerMove: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (spatialSuppressed()) return emit('move', e) }, onDoubleClick: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (selectionSuppressed()) return emit('double-click', e) }, onContextMenu: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (selectionSuppressed()) return emit('context-menu', e) }, } From 8a5c232685c9d4c6ce8849fec0d7aa55555d85a4 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 14 Jun 2026 10:16:15 -0400 Subject: [PATCH 2/6] =?UTF-8?q?feat(core):=20opening-guides=20service=20?= =?UTF-8?q?=E2=80=94=20proximity/alignment=20geometry=20for=20openings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure, Three.js-free wall-local geometry that will power directly-visible proximity guides for doors and windows (rendered in the follow-up phases): - sill / head height (floor → bottom edge, top edge → wall top) - edge-to-edge proximity clearance to the nearest neighbour on each side (or the wall end), with overlap suppression - along-wall alignment (edge/centre coincidence with a neighbour) - vertical alignment (shared sill / centre / top — "same sill height") - Figma-style equal-spacing run detection across a series of 3+ openings Single `computeOpeningGuides` entry plus exported detectors; 23 unit tests. Codex-reviewed — equal-spacing uses a longest-equal-window scan (a greedy first-gap anchor dropped valid runs), edge gaps suppress straddling overlaps, and the alignment detectors guard against the moving opening appearing in its own sibling list. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/services/index.ts | 20 + .../core/src/services/opening-guides.test.ts | 241 +++++++++++ packages/core/src/services/opening-guides.ts | 404 ++++++++++++++++++ 3 files changed, 665 insertions(+) create mode 100644 packages/core/src/services/opening-guides.test.ts create mode 100644 packages/core/src/services/opening-guides.ts diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index b3eb1a26..a57aa6eb 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -49,6 +49,26 @@ export { moveToward, resolveMovable, } from './movement' +export { + type AlongWallAlignment, + type AlongWallFeature, + computeEdgeGaps, + computeOpeningGuides, + DEFAULT_OPENING_GUIDE_TOLERANCES, + detectAlongWallAlignment, + detectEqualSpacing, + detectVerticalAlignment, + type EdgeGap, + type EqualSpacingRun, + type OpeningGuideInput, + type OpeningGuides, + type OpeningGuideTolerances, + type OpeningSpan, + type SillHeadGuide, + type VerticalAlignment, + type VerticalFeature, + type WallExtent, +} from './opening-guides' export { DEFAULT_ANGLE_STEP, DEFAULT_GRID_STEP, diff --git a/packages/core/src/services/opening-guides.test.ts b/packages/core/src/services/opening-guides.test.ts new file mode 100644 index 00000000..648800b4 --- /dev/null +++ b/packages/core/src/services/opening-guides.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from 'bun:test' +import { + computeEdgeGaps, + computeOpeningGuides, + detectAlongWallAlignment, + detectEqualSpacing, + detectVerticalAlignment, + type OpeningSpan, + type WallExtent, +} from './opening-guides' + +function span(id: string, centerS: number, width: number, centerY = 1, height = 1): OpeningSpan { + return { id, centerS, width, centerY, height } +} + +const WALL: WallExtent = { length: 10, height: 2.5 } + +describe('detectEqualSpacing', () => { + test('returns null for fewer than three openings', () => { + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + expect(detectEqualSpacing([a, b], 'b', 0.03, 0.02)).toBeNull() + }) + + test('detects a run of equal gaps across three openings', () => { + // width 1 each: a[0,1] b[2,3] c[4,5] → two gaps of 1m. + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + const c = span('c', 4.5, 1) + const run = detectEqualSpacing([a, b, c], 'b', 0.03, 0.02) + expect(run).not.toBeNull() + expect(run?.gap).toBeCloseTo(1) + expect(run?.segments).toHaveLength(2) + expect(run?.openingIds).toEqual(['a', 'b', 'c']) + expect(run?.segments[0]).toEqual({ fromS: 1, toS: 2 }) + expect(run?.segments[1]).toEqual({ fromS: 3, toS: 4 }) + }) + + test('extends a run across four openings (three gaps)', () => { + const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.5, 1), span('d', 6.5, 1)] + const run = detectEqualSpacing(openings, 'c', 0.03, 0.02) + expect(run?.segments).toHaveLength(3) + expect(run?.openingIds).toEqual(['a', 'b', 'c', 'd']) + }) + + test('returns null when gaps differ beyond tolerance', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 2.5, 1) // [2,3] → gap 1 + const c = span('c', 5, 1) // [4.5,5.5] → gap 1.5 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull() + }) + + test('returns null when the moving opening is not part of the equal run', () => { + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + const c = span('c', 4.5, 1) // a,b,c form equal gaps of 1 + const d = span('d', 10, 1) // far right, breaks the run + expect(detectEqualSpacing([a, b, c, d], 'd', 0.03, 0.02)).toBeNull() + }) + + test('a near-zero (touching) gap breaks a run', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 1.505, 1) // [1.005,2.005] → gap 0.005 < minGap + const c = span('c', 3.005, 1) // [2.505,3.505] → gap 0.5 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull() + }) + + test('honours the equal-spacing tolerance', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 2.5, 1) // [2,3] → gap 1.0 + const c = span('c', 4.52, 1) // [4.02,5.02] → gap 1.02 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)?.segments).toHaveLength(2) + expect(detectEqualSpacing([a, b, c], 'b', 0.01, 0.02)).toBeNull() + }) +}) + +describe('computeEdgeGaps', () => { + test('measures clearance to the nearest neighbour on each side', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const left = span('l', 2, 1) // [1.5,2.5] + const right = span('r', 8, 1) // [7.5,8.5] + const gaps = computeEdgeGaps(moving, [left, right], WALL, 0.02) + const byside = Object.fromEntries(gaps.map((g) => [g.side, g])) + expect(byside.left?.distance).toBeCloseTo(2) + expect(byside.left?.target).toBe('opening') + expect(byside.left?.targetId).toBe('l') + expect(byside.right?.distance).toBeCloseTo(2) + expect(byside.right?.targetId).toBe('r') + }) + + test('falls back to wall ends with no neighbour', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const gaps = computeEdgeGaps(moving, [], WALL, 0.02) + const byside = Object.fromEntries(gaps.map((g) => [g.side, g])) + expect(byside.left?.target).toBe('wall-start') + expect(byside.left?.distance).toBeCloseTo(4.5) + expect(byside.right?.target).toBe('wall-end') + expect(byside.right?.distance).toBeCloseTo(4.5) + }) + + test('omits a side that is flush / overlapping (below minGap)', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const flush = span('l', 4, 1) // [3.5,4.5] right edge touches moving left + const gaps = computeEdgeGaps(moving, [flush], WALL, 0.02) + expect(gaps.find((g) => g.side === 'left')).toBeUndefined() + expect(gaps.find((g) => g.side === 'right')?.target).toBe('wall-end') + }) +}) + +describe('detectAlongWallAlignment', () => { + test('detects edge-to-edge alignment within tolerance', () => { + const moving = span('m', 5, 2) // [4,6] + const sib = span('s', 7.05, 2) // left edge 6.05 + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('right') + expect(a?.targetFeature).toBe('left') + expect(a?.snap).toBeCloseTo(0.05) + expect(a?.s).toBeCloseTo(6.05) + }) + + test('detects centre alignment', () => { + const moving = span('m', 5, 2) + const sib = span('s', 5.03, 0.5) // centre 5.03, edges far from moving edges + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('center') + expect(a?.targetFeature).toBe('center') + expect(a?.snap).toBeCloseTo(0.03) + }) + + test('returns null when nothing is within tolerance', () => { + const moving = span('m', 5, 2) + const sib = span('s', 9, 2) + expect(detectAlongWallAlignment(moving, [sib], 0.08)).toBeNull() + }) +}) + +describe('detectVerticalAlignment', () => { + test('detects a shared sill within tolerance', () => { + const moving = span('m', 5, 1, 1.5, 1) // sill 1.0 + const sib = span('s', 8, 1, 2.04, 2) // sill 1.04 + const a = detectVerticalAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('sill') + expect(a?.targetFeature).toBe('sill') + expect(a?.snap).toBeCloseTo(0.04) + expect(a?.y).toBeCloseTo(1.04) + }) + + test('returns null when sills/tops differ beyond tolerance', () => { + const moving = span('m', 5, 1, 1.5, 1) // sill 1, top 2, centre 1.5 + const sib = span('s', 8, 1, 0.4, 0.4) // sill 0.2, top 0.6, centre 0.4 + expect(detectVerticalAlignment(moving, [sib], 0.08)).toBeNull() + }) +}) + +describe('computeOpeningGuides', () => { + test('includes sill/head for windows', () => { + const moving = span('m', 5, 1, 1.5, 1) // bottom 1, top 2 + const guides = computeOpeningGuides({ + moving, + siblings: [], + wall: WALL, + includeVertical: true, + }) + expect(guides.sillHead?.sill).toBeCloseTo(1) + expect(guides.sillHead?.head).toBeCloseTo(0.5) // 2.5 - 2 + expect(guides.sillHead?.bottomY).toBeCloseTo(1) + expect(guides.sillHead?.topY).toBeCloseTo(2) + }) + + test('omits vertical guides for doors (sit on the floor)', () => { + const moving = span('m', 5, 1, 1, 2) + const sib = span('s', 8, 1, 1, 2) + const guides = computeOpeningGuides({ + moving, + siblings: [sib], + wall: WALL, + includeVertical: false, + }) + expect(guides.sillHead).toBeNull() + expect(guides.vertical).toBeNull() + // along-wall + proximity still computed for doors + expect(guides.gaps.length).toBeGreaterThan(0) + }) + + test('combines proximity and equal-spacing in one pass', () => { + const moving = span('b', 2.5, 1) + const guides = computeOpeningGuides({ + moving, + siblings: [span('a', 0.5, 1), span('c', 4.5, 1)], + wall: WALL, + includeVertical: true, + }) + expect(guides.gaps).toHaveLength(2) + expect(guides.equalSpacing?.gap).toBeCloseTo(1) + expect(guides.equalSpacing?.openingIds).toEqual(['a', 'b', 'c']) + }) +}) + +describe('opening-guides — review regressions', () => { + test('detectEqualSpacing finds a run that starts partway through a drifting sequence', () => { + // gaps 1.00, 1.02, 1.04 — only [b,c,d] is equal within 0.03 and includes the + // moving opening; a first-gap-anchored greedy scan used to drop it. + const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.52, 1), span('d', 6.56, 1)] + const run = detectEqualSpacing(openings, 'd', 0.03, 0.02) + expect(run?.openingIds).toEqual(['b', 'c', 'd']) + expect(run?.gap).toBeCloseTo(1.03) + expect(run?.segments).toHaveLength(2) + }) + + test('detectEqualSpacing prefers the leftmost run on a length tie', () => { + // gaps 1,1,2,2 with the moving opening in the middle — two equal-length runs. + const openings = [ + span('a', 0.5, 1), + span('b', 2.5, 1), + span('c', 4.5, 1), + span('d', 7.5, 1), + span('e', 10.5, 1), + ] + expect(detectEqualSpacing(openings, 'c', 0.03, 0.02)?.openingIds).toEqual(['a', 'b', 'c']) + }) + + test('computeEdgeGaps suppresses both sides when a sibling overlaps', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const containing = span('s', 5, 2) // [4,6] straddles both edges + expect(computeEdgeGaps(moving, [containing], WALL, 0.02)).toEqual([]) + }) + + test('alignment detectors ignore the moving opening if present in siblings', () => { + const moving = span('m', 5, 2, 1.5, 1) + expect(detectAlongWallAlignment(moving, [moving], 0.08)).toBeNull() + expect(detectVerticalAlignment(moving, [moving], 0.08)).toBeNull() + }) + + test('detectAlongWallAlignment reports a negative snap when the feature is past the target', () => { + const moving = span('m', 5, 2) // centre 5 + const sib = span('s', 4.96, 0.5) // centre 4.96 + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('center') + expect(a?.snap).toBeCloseTo(-0.04) + }) +}) diff --git a/packages/core/src/services/opening-guides.ts b/packages/core/src/services/opening-guides.ts new file mode 100644 index 00000000..5f0da29e --- /dev/null +++ b/packages/core/src/services/opening-guides.ts @@ -0,0 +1,404 @@ +// Proximity / alignment guides for wall-hosted openings (doors, windows). +// +// Pure geometry over a single host wall's LOCAL frame — no Three.js, no scene +// store, no React — so it runs identically for the 3D viewport and the 2D +// floor plan and is unit-testable in isolation. Callers extract the spans from +// the scene graph (an opening's `position[0]` is its along-wall centre, its +// `position[1]` its vertical centre with the wall base at y=0) and feed them in; +// the renderers transform the returned wall-local coordinates back to world +// (3D) or plan (2D). +// +// What it produces, mirroring the affordances architects expect (and Figma's +// smart guides): +// - sill/head : a window's bottom edge → floor and top edge → wall top. +// - edge gaps : along-wall clearance to the nearest neighbour opening (or +// the wall end) on each side. +// - alongWall : the moving opening's edge/centre lining up with a +// neighbour's edge/centre along the wall. +// - vertical : two openings sharing a sill / head / vertical centre. +// - equalSpacing : a run of 3+ openings with (near-)equal gaps between them. +// +// Detection is passive — it reports what currently coincides within tolerance +// and the snap delta that would make it exact, leaving the snap decision to the +// caller's manipulation policy (grid vs. alignment vs. Shift bypass). + +/** An opening's footprint in its host wall's local frame. */ +export type OpeningSpan = { + id: string + /** Centre along the wall, measured from `wall.start` (m). */ + centerS: number + /** Along-wall extent (m). */ + width: number + /** Vertical centre above the wall base (floor at y=0) (m). */ + centerY: number + /** Vertical extent (m). */ + height: number +} + +export type WallExtent = { + /** Wall length (m). */ + length: number + /** Wall height (m). */ + height: number +} + +export type OpeningGuideTolerances = { + /** Max distance for an edge/centre to count as aligned with a neighbour (m). */ + align: number + /** Max difference between two gaps for them to count as equal (m). */ + equalSpacing: number + /** Gaps below this are treated as touching/overlap noise and ignored (m). */ + minGap: number +} + +export const DEFAULT_OPENING_GUIDE_TOLERANCES: OpeningGuideTolerances = { + // Parity with the along-wall snap threshold (`ALONG_WALL_ALIGN_THRESHOLD_M`). + align: 0.08, + equalSpacing: 0.03, + minGap: 0.02, +} + +/** Which along-wall feature of an opening a guide references. */ +export type AlongWallFeature = 'left' | 'center' | 'right' +/** Which vertical feature of an opening a guide references. */ +export type VerticalFeature = 'sill' | 'center' | 'top' + +export type SillHeadGuide = { + /** Floor (y=0) → the opening's bottom edge (m). */ + sill: number + /** Wall-local y of the bottom edge. */ + bottomY: number + /** The opening's top edge → the wall top (m). */ + head: number + /** Wall-local y of the top edge. */ + topY: number +} + +export type EdgeGap = { + side: 'left' | 'right' + /** Clearance along the wall (m). */ + distance: number + /** Wall-local s of the moving opening's edge. */ + fromS: number + /** Wall-local s of the neighbour edge / wall end. */ + toS: number + target: 'opening' | 'wall-start' | 'wall-end' + /** Set when `target === 'opening'`. */ + targetId?: string +} + +export type AlongWallAlignment = { + /** Wall-local s the two features share. */ + s: number + movingFeature: AlongWallFeature + targetId: string + targetFeature: AlongWallFeature + /** Delta to add to the moving opening's `centerS` to make them coincide. */ + snap: number +} + +export type VerticalAlignment = { + /** Wall-local y the two features share. */ + y: number + movingFeature: VerticalFeature + targetId: string + targetFeature: VerticalFeature + /** Delta to add to the moving opening's `centerY` to make them coincide. */ + snap: number +} + +export type EqualSpacingRun = { + /** The repeated gap value (average of the run's gaps) (m). */ + gap: number + /** The equal-gap segments along the wall, in order (left → right). */ + segments: { fromS: number; toS: number }[] + /** Participating opening ids, ordered along the wall, including the moving one. */ + openingIds: string[] +} + +export type OpeningGuides = { + sillHead: SillHeadGuide | null + gaps: EdgeGap[] + alongWall: AlongWallAlignment | null + vertical: VerticalAlignment | null + equalSpacing: EqualSpacingRun | null +} + +export type OpeningGuideInput = { + moving: OpeningSpan + /** Other openings on the SAME wall (the moving opening excluded). */ + siblings: readonly OpeningSpan[] + wall: WallExtent + /** + * Whether to compute vertical (sill/head/vertical-alignment) guides. True for + * windows; false for doors, which sit on the floor so their sill is always 0. + */ + includeVertical: boolean + tolerances?: Partial +} + +const leftEdge = (s: OpeningSpan) => s.centerS - s.width / 2 +const rightEdge = (s: OpeningSpan) => s.centerS + s.width / 2 +const bottomEdge = (s: OpeningSpan) => s.centerY - s.height / 2 +const topEdge = (s: OpeningSpan) => s.centerY + s.height / 2 + +function alongWallFeatureCoord(s: OpeningSpan, feature: AlongWallFeature): number { + if (feature === 'left') return leftEdge(s) + if (feature === 'right') return rightEdge(s) + return s.centerS +} + +function verticalFeatureCoord(s: OpeningSpan, feature: VerticalFeature): number { + if (feature === 'sill') return bottomEdge(s) + if (feature === 'top') return topEdge(s) + return s.centerY +} + +const ALONG_WALL_FEATURES: AlongWallFeature[] = ['left', 'center', 'right'] +const VERTICAL_FEATURES: VerticalFeature[] = ['sill', 'center', 'top'] + +/** + * Edge-to-edge clearance from the moving opening to the nearest neighbour on + * each side, falling back to the wall ends when there is no neighbour — the + * "how much wall is left here" reading. Returns 0–2 gaps (one per side); a side + * is omitted when its clearance is below `minGap` (the opening is flush against + * or overlapping that neighbour). + */ +export function computeEdgeGaps( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + wall: WallExtent, + minGap: number, +): EdgeGap[] { + const movingLeft = leftEdge(moving) + const movingRight = rightEdge(moving) + + // A sibling that straddles one of the moving opening's edges is an OVERLAP, + // not a neighbour: there is no clearance on that side, and we must not fall + // back to the wall end (which would report a misleading distance measured + // "through" the overlapping opening). + const leftCrossed = siblings.some((s) => leftEdge(s) < movingLeft && rightEdge(s) > movingLeft) + const rightCrossed = siblings.some((s) => leftEdge(s) < movingRight && rightEdge(s) > movingRight) + + let leftNeighbour: { s: number; id: string } | null = null + let rightNeighbour: { s: number; id: string } | null = null + for (const sib of siblings) { + const sibRight = rightEdge(sib) + const sibLeft = leftEdge(sib) + // Entirely to the left of the moving opening → candidate left neighbour. + if (sibRight <= movingLeft && (leftNeighbour === null || sibRight > leftNeighbour.s)) { + leftNeighbour = { s: sibRight, id: sib.id } + } + // Entirely to the right → candidate right neighbour. + if (sibLeft >= movingRight && (rightNeighbour === null || sibLeft < rightNeighbour.s)) { + rightNeighbour = { s: sibLeft, id: sib.id } + } + } + + const gaps: EdgeGap[] = [] + + if (!leftCrossed) { + const leftToS = leftNeighbour ? leftNeighbour.s : 0 + const leftDistance = movingLeft - leftToS + if (leftDistance >= minGap) { + gaps.push({ + side: 'left', + distance: leftDistance, + fromS: movingLeft, + toS: leftToS, + target: leftNeighbour ? 'opening' : 'wall-start', + targetId: leftNeighbour?.id, + }) + } + } + + if (!rightCrossed) { + const rightToS = rightNeighbour ? rightNeighbour.s : wall.length + const rightDistance = rightToS - movingRight + if (rightDistance >= minGap) { + gaps.push({ + side: 'right', + distance: rightDistance, + fromS: movingRight, + toS: rightToS, + target: rightNeighbour ? 'opening' : 'wall-end', + targetId: rightNeighbour?.id, + }) + } + } + + return gaps +} + +/** + * The closest coincidence between any of the moving opening's edges/centre and + * any sibling's edges/centre along the wall, within `tolerance`. Edge-to-edge + * and centre-to-centre are weighed equally; the single closest pair wins + * (matching the one-guide-per-axis behaviour of the floor-plane resolver). + */ +export function detectAlongWallAlignment( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + tolerance: number, +): AlongWallAlignment | null { + let best: AlongWallAlignment | null = null + let bestAbs = tolerance + for (const movingFeature of ALONG_WALL_FEATURES) { + const movingCoord = alongWallFeatureCoord(moving, movingFeature) + for (const sib of siblings) { + if (sib.id === moving.id) continue + for (const targetFeature of ALONG_WALL_FEATURES) { + const targetCoord = alongWallFeatureCoord(sib, targetFeature) + const diff = targetCoord - movingCoord + const abs = Math.abs(diff) + if (abs <= bestAbs && (best === null || abs < bestAbs)) { + bestAbs = abs + best = { + s: targetCoord, + movingFeature, + targetId: sib.id, + targetFeature, + snap: diff, + } + } + } + } + } + return best +} + +/** + * The closest coincidence between the moving opening's sill/centre/top and any + * sibling's sill/centre/top, within `tolerance` — the "these two windows share + * a sill height" detector. Same single-best-match policy as the along-wall + * variant. + */ +export function detectVerticalAlignment( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + tolerance: number, +): VerticalAlignment | null { + let best: VerticalAlignment | null = null + let bestAbs = tolerance + for (const movingFeature of VERTICAL_FEATURES) { + const movingCoord = verticalFeatureCoord(moving, movingFeature) + for (const sib of siblings) { + if (sib.id === moving.id) continue + for (const targetFeature of VERTICAL_FEATURES) { + const targetCoord = verticalFeatureCoord(sib, targetFeature) + const diff = targetCoord - movingCoord + const abs = Math.abs(diff) + if (abs <= bestAbs && (best === null || abs < bestAbs)) { + bestAbs = abs + best = { + y: targetCoord, + movingFeature, + targetId: sib.id, + targetFeature, + snap: diff, + } + } + } + } + } + return best +} + +/** + * Figma-style equal-spacing detection: order all openings along the wall, look + * at the clearances BETWEEN consecutive openings, and return the longest run of + * ≥2 consecutive gaps that are equal within `tolerance` and that the moving + * opening participates in (so the badges only appear while the drag is actually + * forming or extending a series). Returns null when no such run exists. + * + * Gaps below `minGap` (touching/overlapping openings) break a run — a row of + * flush openings is not "equally spaced". + */ +export function detectEqualSpacing( + allOpenings: readonly OpeningSpan[], + movingId: string, + tolerance: number, + minGap: number, +): EqualSpacingRun | null { + if (allOpenings.length < 3) return null + const sorted = [...allOpenings].sort((a, b) => a.centerS - b.centerS) + const movingIndex = sorted.findIndex((s) => s.id === movingId) + if (movingIndex < 0) return null + + // Clearance between opening i and i+1. + const gaps: { value: number; fromS: number; toS: number }[] = [] + for (let i = 0; i < sorted.length - 1; i++) { + const a = sorted[i] + const b = sorted[i + 1] + if (!a || !b) continue + const fromS = rightEdge(a) + const toS = leftEdge(b) + gaps.push({ value: toS - fromS, fromS, toS }) + } + + // Longest contiguous window of gaps that are (a) each ≥ minGap and (b) + // mutually equal within tolerance (window max − min ≤ tolerance), spanning at + // least 2 gaps and including the moving opening. Brute force over windows + // (openings per wall are few). A first-gap-anchored greedy scan is NOT + // equivalent: it drops a valid run that begins partway through a drifting + // sequence — e.g. gaps 1.00, 1.02, 1.04 with the moving opening at the end, + // where [1.02, 1.04] is a real run. On a length tie the leftmost window wins, + // for determinism. + let best: EqualSpacingRun | null = null + for (let lo = 0; lo < gaps.length; lo++) { + let min = Number.POSITIVE_INFINITY + let max = Number.NEGATIVE_INFINITY + for (let hi = lo; hi < gaps.length; hi++) { + const gap = gaps[hi] + if (!gap || gap.value < minGap) break // a sub-minGap gap can't join a run + min = Math.min(min, gap.value) + max = Math.max(max, gap.value) + if (max - min > tolerance) break // extending only widens the spread + const gapCount = hi - lo + 1 + if (gapCount < 2) continue + const firstOpening = lo // gap i sits between openings i and i+1 + const lastOpening = hi + 1 + if (movingIndex < firstOpening || movingIndex > lastOpening) continue + if (best !== null && gapCount <= best.segments.length) continue + const windowGaps = gaps.slice(lo, hi + 1) + best = { + gap: windowGaps.reduce((sum, g) => sum + g.value, 0) / windowGaps.length, + segments: windowGaps.map((g) => ({ fromS: g.fromS, toS: g.toS })), + openingIds: sorted.slice(firstOpening, lastOpening + 1).map((s) => s.id), + } + } + } + return best +} + +/** + * Compute every proximity/alignment guide for the moving opening in one pass. + * Pure: feed it the moving opening's wall-local span, its same-wall siblings, + * and the wall extent; render the result in whichever view. + */ +export function computeOpeningGuides(input: OpeningGuideInput): OpeningGuides { + const tol = { ...DEFAULT_OPENING_GUIDE_TOLERANCES, ...input.tolerances } + const { moving, siblings, wall, includeVertical } = input + + const sillHead: SillHeadGuide | null = includeVertical + ? { + sill: bottomEdge(moving), + bottomY: bottomEdge(moving), + head: wall.height - topEdge(moving), + topY: topEdge(moving), + } + : null + + return { + sillHead, + gaps: computeEdgeGaps(moving, siblings, wall, tol.minGap), + alongWall: detectAlongWallAlignment(moving, siblings, tol.align), + vertical: includeVertical ? detectVerticalAlignment(moving, siblings, tol.align) : null, + equalSpacing: detectEqualSpacing( + [moving, ...siblings], + moving.id, + tol.equalSpacing, + tol.minGap, + ), + } +} From 3ac6b27eca6ab8e5b244fa25230b0476cdccc309 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 14 Jun 2026 17:25:49 -0400 Subject: [PATCH 3/6] feat(editor): 2D plan proximity + equal-spacing guides for openings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route the door/window floor-plan placement dimensions through the new opening-guides service: - edge-to-edge clearance to the nearest neighbour (or wall end) on each side, now with overlap suppression (previously nearest-only, ad-hoc). - Figma-style equal-spacing — a "=" badge per gap on the wall centreline whenever the moving opening is part of a run of 3+ (near-)equally-spaced openings. Adds the `equal-spacing-badge` FloorplanGeometry primitive, its 2D renderer (distinct pink accent), and overlay registration. Shown while placing/moving. Sill height + vertical alignment are 3D-only (a top-down plan has no vertical axis) and land in the next phase. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/registry/types.ts | 14 +++ .../renderers/floorplan-registry-layer.tsx | 53 +++++++++ .../shared/opening-placement-dimensions.ts | 112 ++++++++++-------- 3 files changed, 127 insertions(+), 52 deletions(-) diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 03cbd617..cc073c50 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -440,6 +440,20 @@ export type FloorplanGeometry = /** Rotation in radians. The renderer auto-flips to keep text upright. */ angle: number } + /** + * Equal-spacing badge — a small accent pill marking one gap in a run of + * (near-)equally-spaced openings (the 2D counterpart of Figma's "=" distance + * chips). Emitted once per equal gap so the repeated value reads as a rhythm. + * `text` is the shared gap distance; `angle` orients the pill along the wall + * (the renderer auto-flips it upright). + */ + | { + kind: 'equal-spacing-badge' + point: FloorplanPoint + text: string + /** Rotation in radians. */ + angle: number + } /** * Architect's dimension overlay — extension lines from the edge * endpoints out past the dimension line, two dimension line halves diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 120f642c..9c83ecfa 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -1656,6 +1656,58 @@ function InteractiveGeometry({ ) } + case 'equal-spacing-badge': { + // A distinct accent (Figma-style "=" rhythm) so equal spacing reads + // apart from the orange placement dimensions. Same screen-upright flip + // as the dimension-label case above. + const accent = '#ec4899' + let degrees = (g.angle * 180) / Math.PI + let screenDegrees = degrees + sceneRotationDeg + screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180 + if (screenDegrees > 90) degrees -= 180 + else if (screenDegrees <= -90) degrees += 180 + + const label = `= ${g.text}` + const padX = unitsPerPixel * 6 + const padY = unitsPerPixel * 3 + const fontSize = Math.max(unitsPerPixel * 10, 0.08) + const textWidth = label.length * unitsPerPixel * 6.2 + const plateW = textWidth + padX * 2 + const plateH = fontSize + padY * 2 + return ( + + + + {label} + + + ) + } case 'dimension': { if (!palette) return <> const stroke = g.stroke ?? palette.measurementStroke @@ -1959,6 +2011,7 @@ const OVERLAY_KINDS = new Set([ 'rotate-arrow', 'dimension', 'dimension-label', + 'equal-spacing-badge', ]) /** diff --git a/packages/nodes/src/shared/opening-placement-dimensions.ts b/packages/nodes/src/shared/opening-placement-dimensions.ts index 04960755..42d375fb 100644 --- a/packages/nodes/src/shared/opening-placement-dimensions.ts +++ b/packages/nodes/src/shared/opening-placement-dimensions.ts @@ -1,10 +1,13 @@ import { type AnyNode, type AnyNodeId, + computeOpeningGuides, type DoorNode, type FloorplanGeometry, + type FloorplanPoint, type GeometryContext, isCurvedWall, + type OpeningSpan, type WallNode, type WindowNode, } from '@pascal-app/core' @@ -49,79 +52,84 @@ export function buildOpeningPlacementDimensions( // walls) via ctx.resolve to compute the centroid. const outwardNormal = computeOutwardNormal(wall, ctx, dirX, dirZ) - const halfWidth = opening.width / 2 - const startDist = opening.position[0] - halfWidth - const endDist = opening.position[0] + halfWidth + const wallThickness = wall.thickness ?? 0.1 + const halfThickness = wallThickness / 2 + const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32 - // Walk wall.children to find adjacent openings (door OR window). - // ctx.siblings only includes same-kind nodes; doors + windows need - // each other so we go via the parent's children directly. + // Outer-face projection for the placement dimensions (so extension lines stay + // short and the layout matches the legacy treatment); centreline projection + // for the equal-spacing badges, which sit on the solid wall between openings. + const facePoint = (along: number): readonly [number, number] => [ + x1 + dirX * along + outwardNormal[0] * halfThickness, + z1 + dirZ * along + outwardNormal[1] * halfThickness, + ] + const centrePoint = (along: number): FloorplanPoint => [x1 + dirX * along, z1 + dirZ * along] + const round = (value: number) => Number.parseFloat(value.toFixed(2)) + + // This wall's OTHER openings as wall-local spans. `ctx.siblings` only includes + // same-kind nodes; doors and windows need each other, so resolve the wall's + // children directly. const childIds = ((wall as unknown as { children?: AnyNodeId[] }).children ?? []) as AnyNodeId[] - let leftBoundary: number | null = null - let rightBoundary: number | null = null + const siblings: OpeningSpan[] = [] for (const childId of childIds) { if (childId === opening.id) continue const sibling = ctx.resolve(childId) as AnyNode | undefined if (!sibling || (sibling.type !== 'door' && sibling.type !== 'window')) continue const sib = sibling as DoorNode | WindowNode - const sibStart = sib.position[0] - sib.width / 2 - const sibEnd = sib.position[0] + sib.width / 2 - if (sibEnd <= startDist && (leftBoundary === null || sibEnd > leftBoundary)) { - leftBoundary = sibEnd - } - if (sibStart >= endDist && (rightBoundary === null || sibStart < rightBoundary)) { - rightBoundary = sibStart - } + siblings.push({ + id: sib.id, + centerS: sib.position[0], + width: sib.width, + centerY: sib.position[1], + height: sib.height, + }) } - const leftFromDist = leftBoundary ?? 0 - const rightToDist = rightBoundary ?? wallLength - - // Place the dimension line at a constant offset from the wall's - // outer face — same value the legacy uses for its placement - // measurements (`FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET`). The - // dimension's `start` / `end` are points on that outer face (not - // the wall centerline), so the extension lines stay short and the - // overall layout matches the legacy treatment 1:1. - const wallThickness = wall.thickness ?? 0.1 - const halfThickness = wallThickness / 2 - const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32 - - // Project a point on the wall axis at distance `along` onto the - // wall's outer face by adding `halfThickness * outwardNormal`. - const facePoint = (along: number): readonly [number, number] => [ - x1 + dirX * along + outwardNormal[0] * halfThickness, - z1 + dirZ * along + outwardNormal[1] * halfThickness, - ] + const guides = computeOpeningGuides({ + moving: { + id: opening.id, + centerS: opening.position[0], + width: opening.width, + centerY: opening.position[1], + height: opening.height, + }, + siblings, + wall: { length: wallLength, height: wall.height ?? 2.5 }, + // The 2D plan is top-down: sill/head height and vertical alignment aren't + // representable here — those belong to the 3D viewport. + includeVertical: false, + }) const out: FloorplanGeometry[] = [] - const leftDistance = startDist - leftFromDist - if (leftDistance >= 0.01) { + // Edge-to-edge clearance to the nearest neighbour (or wall end) on each side. + for (const gap of guides.gaps) { + const lo = Math.min(gap.fromS, gap.toS) + const hi = Math.max(gap.fromS, gap.toS) out.push({ kind: 'dimension', - start: facePoint(leftFromDist), - end: facePoint(startDist), + start: facePoint(lo), + end: facePoint(hi), offsetNormal: outwardNormal, offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, extensionOvershoot: 0.12, - text: `${Number.parseFloat(leftDistance.toFixed(2))}m`, + text: `${round(gap.distance)}m`, stroke: '#f97316', }) } - const rightDistance = rightToDist - endDist - if (rightDistance >= 0.01) { - out.push({ - kind: 'dimension', - start: facePoint(endDist), - end: facePoint(rightToDist), - offsetNormal: outwardNormal, - offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, - extensionOvershoot: 0.12, - text: `${Number.parseFloat(rightDistance.toFixed(2))}m`, - stroke: '#f97316', - }) + // Equal-spacing rhythm — a "=" badge per equal gap, on the wall centreline. + if (guides.equalSpacing) { + const wallAngle = Math.atan2(dz, dx) + const text = `${round(guides.equalSpacing.gap)}m` + for (const seg of guides.equalSpacing.segments) { + out.push({ + kind: 'equal-spacing-badge', + point: centrePoint((seg.fromS + seg.toS) / 2), + text, + angle: wallAngle, + }) + } } return out From 6caba97f1b6387796ed432c2496c4f9e9fdcdd5f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 14 Jun 2026 20:15:52 -0400 Subject: [PATCH 4/6] feat(editor): 3D viewport proximity + sill + equal-spacing guides for openings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the opening-guides service into the 3D door/window move tools and render the wall-plane guides as the spatial twin of the 2D plan guides: - sill / head height (floor → bottom edge, top edge → wall top) — windows only - edge-to-edge proximity dimensions to the nearest neighbour each side - a sill-alignment line + SNAP when a window shares a neighbour's sill / centre / top (competes with the 0.5m grid, Shift bypasses) — the chosen "snap + guide" behaviour - Figma-style equal-spacing "=" badges across a run of openings Adds `useOpeningGuides` (editor store) + `OpeningGuides3DLayer` (raw THREE.Line overlays + Html pills, mounted beside Alignment3DGuideLayer) and a thin `opening-guides-runtime` helper (collect siblings / sill snap / publish / clear) called from the door + window move-tools at their per-tick `applyPreview` hook; guides clear on commit / cancel / leave / roof-hover / unmount. Guides render in the move cursor's building-local frame (reuses `wallLocalToWorld`) so they track the dragged opening exactly. Codex-reviewed (roof-hover stale-guide clear, collapsed-dimension suppression). Placement-time guides reuse the same helper and are the next step. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/opening-guides-3d-layer.tsx | 130 ++++++++++++++ .../src/components/tools/tool-manager.tsx | 4 + packages/editor/src/index.tsx | 5 + .../editor/src/store/use-opening-guides.ts | 33 ++++ packages/nodes/src/door/move-tool.tsx | 25 +++ .../src/shared/opening-guides-runtime.ts | 161 ++++++++++++++++++ packages/nodes/src/window/move-tool.tsx | 45 ++++- 7 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 packages/editor/src/components/editor/opening-guides-3d-layer.tsx create mode 100644 packages/editor/src/store/use-opening-guides.ts create mode 100644 packages/nodes/src/shared/opening-guides-runtime.ts diff --git a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx new file mode 100644 index 00000000..f6ba7d3f --- /dev/null +++ b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx @@ -0,0 +1,130 @@ +'use client' + +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { memo, useEffect, useMemo } from 'react' +import { BufferGeometry, Line as ThreeLine, Vector3 } from 'three' +import { LineBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../lib/constants' +import useOpeningGuides, { + type OpeningGuide3D, + type OpeningGuideVec3, +} from '../../store/use-opening-guides' +import { formatMeasurement } from './measurement-pill' + +const DIMENSION_COLOR = 0x81_8c_f8 // indigo — a neutral measurement +const ALIGN_COLOR = 0xef_44_44 // red — a snapped alignment (matches the 2D guide accent) +const DIMENSION_PILL = '#6366f1' +const BADGE_PILL = '#ec4899' // pink — matches the 2D equal-spacing badge + +// Shared depth-test-off materials so the guides read on top of the wall and +// don't rebuild GPU buffers as guides churn during a drag. +const dimensionMaterial = new LineBasicNodeMaterial({ + color: DIMENSION_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, +}) +const alignMaterial = new LineBasicNodeMaterial({ + color: ALIGN_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, +}) + +const mid = (a: OpeningGuideVec3, b: OpeningGuideVec3): OpeningGuideVec3 => [ + (a[0] + b[0]) / 2, + (a[1] + b[1]) / 2, + (a[2] + b[2]) / 2, +] + +/** + * Wall-plane proximity / alignment guides for the 3D editor — the spatial twin + * of the floor-plan placement dimensions + equal-spacing badges. Subscribes to + * `useOpeningGuides` (published by the door/window move tools each drag tick) and + * draws sill/head + edge-proximity dimensions, a sill-alignment line, and + * equal-spacing badges. Coordinates are already in the move tool's render frame + * (the producer reuses the cursor's `wallLocalToWorld`, so they share the cursor's + * building-local frame), so this layer mounts beside `Alignment3DGuideLayer` and + * renders them as-is. + */ +export const OpeningGuides3DLayer = memo(function OpeningGuides3DLayer() { + const guides = useOpeningGuides((s) => s.guides) + const unit = useViewer((s) => s.unit) + if (guides.length === 0) return null + return ( + <> + {guides.map((guide, i) => ( + + ))} + + ) +}) + +function OpeningGuide({ guide, unit }: { guide: OpeningGuide3D; unit: 'metric' | 'imperial' }) { + if (guide.kind === 'badge') { + return ( + +
+ {`= ${formatMeasurement(guide.value, unit)}`} +
+ + ) + } + + const material = guide.kind === 'align-line' ? alignMaterial : dimensionMaterial + return ( + <> + + {guide.kind === 'dimension' ? ( + +
+ {formatMeasurement(guide.value, unit)} +
+ + ) : null} + + ) +} + +function GuideSegment({ + from, + to, + material, +}: { + from: OpeningGuideVec3 + to: OpeningGuideVec3 + material: LineBasicNodeMaterial +}) { + // Build a concrete THREE.Line and mount it via : the intrinsic + // JSX element collides with React's SVG , so keeps + // the typing clean and gives us direct control of layers + renderOrder. + const line = useMemo(() => { + const geometry = new BufferGeometry().setFromPoints([new Vector3(...from), new Vector3(...to)]) + const object = new ThreeLine(geometry, material) + object.frustumCulled = false + object.layers.set(EDITOR_LAYER) + object.renderOrder = 1000 + return object + }, [from, to, material]) + useEffect(() => () => line.geometry.dispose(), [line]) + return +} diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index d942d25f..95df3d10 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -10,6 +10,7 @@ import { useViewer } from '@pascal-app/viewer' import { type ComponentType, lazy, Suspense } from 'react' import useEditor, { type Phase, type Tool } from '../../store/use-editor' import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer' +import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer' import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer' import { ElevatorTool } from './elevator/elevator-tool' import { MoveTool } from './item/move-tool' @@ -283,6 +284,9 @@ export const ToolManager: React.FC = () => { tools above. Lives inside the building-local group so the building-local guide coords render at the right world position. */} + {/* Wall-plane proximity / sill / equal-spacing guides for openings, + published by the door/window move tools in the same world frame. */} + {/* "Magnetic" beacon at the active wall-draft snap point. */} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 3cb0ed02..49141fc2 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -300,6 +300,11 @@ export type { WorkspaceMode, } from './store/use-editor' export { default as useEditor } from './store/use-editor' +export { + default as useOpeningGuides, + type OpeningGuide3D, + type OpeningGuideVec3, +} from './store/use-opening-guides' export { type PaletteView, type PaletteViewProps, diff --git a/packages/editor/src/store/use-opening-guides.ts b/packages/editor/src/store/use-opening-guides.ts new file mode 100644 index 00000000..ca2cc71a --- /dev/null +++ b/packages/editor/src/store/use-opening-guides.ts @@ -0,0 +1,33 @@ +// Ephemeral store for the 3D opening proximity/alignment guides published by the +// door/window move + placement tools during a drag — the wall-plane counterpart +// of `useAlignmentGuides` (which only carries floor-plane XZ guides). Guides are +// already transformed into the move tool's render frame — the same building-local +// frame as the drag cursor (ToolManager's group) — so the renderer stays dumb. +// Producers clear on commit, cancel, leave, and unmount. + +import { create } from 'zustand' + +export type OpeningGuideVec3 = [number, number, number] + +export type OpeningGuide3D = + // A measured line + distance pill: sill (floor → bottom edge), head (top edge + // → wall top), or along-wall edge-to-edge proximity. + | { kind: 'dimension'; from: OpeningGuideVec3; to: OpeningGuideVec3; value: number } + // A dashed line connecting two openings that share a sill / centre / top. + | { kind: 'align-line'; from: OpeningGuideVec3; to: OpeningGuideVec3 } + // A Figma-style "=" badge marking one gap in an equal-spacing run. + | { kind: 'badge'; at: OpeningGuideVec3; value: number } + +type OpeningGuidesState = { + guides: OpeningGuide3D[] + set(guides: OpeningGuide3D[]): void + clear(): void +} + +const useOpeningGuides = create((set) => ({ + guides: [], + set: (guides) => set({ guides }), + clear: () => set({ guides: [] }), +})) + +export default useOpeningGuides diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 8f18bf88..e5ae2dcf 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -28,6 +28,7 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { clearOpeningGuides3D, publishOpeningGuides3D } from '../shared/opening-guides-runtime' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, @@ -125,6 +126,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } // Alignment candidates — anchors of every OTHER alignable object (the @@ -253,6 +255,26 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => target.cursorRotation, target.valid, ) + + publishOpeningGuides3D({ + wall: target.wallNode, + movingId: movingDoorNode.id, + centerS: target.clampedX, + centerY: target.clampedY, + width: movingDoorNode.width, + height: movingDoorNode.height, + // Doors sit on the floor — no sill/head or vertical alignment guides. + includeVertical: false, + nodes: useScene.getState().nodes, + toWorld: (s, y) => + wallLocalToWorld( + target.wallNode, + s, + y, + getLevelYOffset(), + getSlabElevation(target.event), + ), + }) } const onWallEnter = (event: WallEvent) => { @@ -416,6 +438,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => lastTarget = null lastRoofEvent = event useLiveTransforms.getState().clear(movingDoorNode.id) + // Opening guides are wall-specific; clear them when over a roof face. + clearOpeningGuides3D() if (currentHostId !== target.segment.id) { useScene.getState().updateNode(movingDoorNode.id, { position: target.position, @@ -599,6 +623,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } useLiveTransforms.getState().clear(movingDoorNode.id) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts new file mode 100644 index 00000000..9b5db582 --- /dev/null +++ b/packages/nodes/src/shared/opening-guides-runtime.ts @@ -0,0 +1,161 @@ +// Runtime glue between the pure `computeOpeningGuides` geometry (core) and the +// editor's 3D guide store, used by the door/window move + placement tools. Lives +// in `nodes` (not core) because it talks to the editor store; kept thin so each +// tool's per-tick hook is a single call. + +import { + type AnyNode, + type AnyNodeId, + computeOpeningGuides, + detectVerticalAlignment, + type OpeningSpan, + type WallNode, +} from '@pascal-app/core' +import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor' + +// Parity with `snapLocalXToNeighbors`' along-wall threshold. +const SILL_SNAP_THRESHOLD_M = 0.08 +// Hide a dimension that has collapsed to nothing (sill flush to the floor, or +// head flush to the wall top) so it doesn't render a zero-length "0m" pill. +const MIN_DIMENSION_M = 0.02 + +/** Maps a wall-local point (s along the wall, y above the wall base) to the move + * tool's render frame — the caller passes its own `wallLocalToWorld` closure so + * the guides land in exactly the same (building-local) frame as the drag cursor. */ +type ToWorld = (s: number, y: number) => [number, number, number] + +/** The moving opening's same-wall neighbours, as wall-local spans. */ +export function collectOpeningSiblings( + wall: WallNode, + movingId: string, + nodes: Record, +): OpeningSpan[] { + const out: OpeningSpan[] = [] + const childIds = Array.isArray(wall.children) ? wall.children : [] + for (const childId of childIds) { + if (childId === movingId) continue + const node = nodes[childId as AnyNodeId] + if (!node || (node.type !== 'door' && node.type !== 'window')) continue + out.push({ + id: node.id, + centerS: node.position[0], + width: node.width, + centerY: node.position[1], + height: node.height, + }) + } + return out +} + +/** + * Vertical sill/centre/top snap for a window — the chosen "snap + guide" + * behaviour. Returns the snapped wall-local Y when a sibling sill/centre/top is + * within threshold, else null so the caller falls back to the grid. Mirrors + * `snapLocalXToNeighbors` on the vertical axis. + */ +export function resolveSillSnap(args: { + wall: WallNode + movingId: string + localX: number + localY: number + width: number + height: number + nodes: Record +}): number | null { + const siblings = collectOpeningSiblings(args.wall, args.movingId, args.nodes) + const match = detectVerticalAlignment( + { + id: args.movingId, + centerS: args.localX, + width: args.width, + centerY: args.localY, + height: args.height, + }, + siblings, + SILL_SNAP_THRESHOLD_M, + ) + return match ? args.localY + match.snap : null +} + +/** Compute and publish the 3D opening guides for the current drag tick. */ +export function publishOpeningGuides3D(args: { + wall: WallNode + movingId: string + centerS: number + centerY: number + width: number + height: number + includeVertical: boolean + toWorld: ToWorld + nodes: Record +}): void { + const { wall, centerS, centerY, width, toWorld } = args + const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + const wallHeight = wall.height ?? 2.5 + const siblings = collectOpeningSiblings(wall, args.movingId, args.nodes) + const guides = computeOpeningGuides({ + moving: { id: args.movingId, centerS, width, centerY, height: args.height }, + siblings, + wall: { length: wallLength, height: wallHeight }, + includeVertical: args.includeVertical, + }) + + const out: OpeningGuide3D[] = [] + + if (guides.sillHead) { + if (guides.sillHead.sill > MIN_DIMENSION_M) { + out.push({ + kind: 'dimension', + from: toWorld(centerS, 0), + to: toWorld(centerS, guides.sillHead.bottomY), + value: guides.sillHead.sill, + }) + } + if (guides.sillHead.head > MIN_DIMENSION_M) { + out.push({ + kind: 'dimension', + from: toWorld(centerS, guides.sillHead.topY), + to: toWorld(centerS, wallHeight), + value: guides.sillHead.head, + }) + } + } + + for (const gap of guides.gaps) { + out.push({ + kind: 'dimension', + from: toWorld(gap.fromS, centerY), + to: toWorld(gap.toS, centerY), + value: gap.distance, + }) + } + + if (guides.vertical) { + const target = siblings.find((s) => s.id === guides.vertical?.targetId) + if (target) { + const lo = Math.min(centerS - width / 2, target.centerS - target.width / 2) + const hi = Math.max(centerS + width / 2, target.centerS + target.width / 2) + out.push({ + kind: 'align-line', + from: toWorld(lo, guides.vertical.y), + to: toWorld(hi, guides.vertical.y), + }) + } + } + + if (guides.equalSpacing) { + for (const seg of guides.equalSpacing.segments) { + out.push({ + kind: 'badge', + at: toWorld((seg.fromS + seg.toS) / 2, centerY), + value: guides.equalSpacing.gap, + }) + } + } + + useOpeningGuides.getState().set(out) +} + +export function clearOpeningGuides3D(): void { + useOpeningGuides.getState().clear() +} diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index eb6076b9..6de64920 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -29,6 +29,11 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { + clearOpeningGuides3D, + publishOpeningGuides3D, + resolveSillSnap, +} from '../shared/opening-guides-runtime' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, @@ -151,6 +156,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } // Alignment candidates — anchors of every OTHER alignable object (the @@ -206,8 +212,21 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX) const targetRawLocalY = dragAnchor.startY + (rawLocalY - dragAnchor.rawY) - const targetLocalY = - event.nativeEvent?.shiftKey === true ? targetRawLocalY : snapToHalf(targetRawLocalY) + // Vertical sill alignment (snap + guide): a sibling's sill/centre/top wins + // over the 0.5m grid when within threshold; Shift bypasses both. + const bypassY = event.nativeEvent?.shiftKey === true + const sillSnapped = bypassY + ? null + : resolveSillSnap({ + wall: event.node, + movingId: movingWindowNode.id, + localX: targetLocalX, + localY: targetRawLocalY, + width: movingWindowNode.width, + height: movingWindowNode.height, + nodes: useScene.getState().nodes, + }) + const targetLocalY = bypassY ? targetRawLocalY : (sillSnapped ?? snapToHalf(targetRawLocalY)) const localX = resolveWallSlideAlignment({ wallNode: event.node, rawLocalX: targetLocalX, @@ -284,6 +303,25 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode target.cursorRotation, target.valid, ) + + publishOpeningGuides3D({ + wall: target.wallNode, + movingId: movingWindowNode.id, + centerS: target.clampedX, + centerY: target.clampedY, + width: movingWindowNode.width, + height: movingWindowNode.height, + includeVertical: true, + nodes: useScene.getState().nodes, + toWorld: (s, y) => + wallLocalToWorld( + target.wallNode, + s, + y, + getLevelYOffset(), + getSlabElevation(target.event), + ), + }) } const onWallEnter = (event: WallEvent) => { @@ -459,6 +497,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode lastTarget = null lastRoofEvent = event useLiveTransforms.getState().clear(movingWindowNode.id) + // Opening guides are wall-specific; clear them when over a roof face. + clearOpeningGuides3D() if (currentHostId !== target.segment.id) { useScene.getState().updateNode(movingWindowNode.id, { position: target.position, @@ -644,6 +684,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } useLiveTransforms.getState().clear(movingWindowNode.id) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) From 86e9b3c8bf47b5993fdc660c96bb359100da6e5a Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 15 Jun 2026 10:18:24 -0400 Subject: [PATCH 5/6] feat(editor): placement-time + resize-time opening guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the opening proximity guides to two more interactions: - PLACEMENT: the door/window placement tools publish the same 3D guides (sill/head, edge proximity, sill alignment, equal-spacing) while a NEW opening is being dropped; window placement also snaps its sill to a neighbour's sill/centre/top (Shift bypass) — so "two windows aligned" reads during placement, not just move. - RESIZE: a new `onDrag` hook on the linear-resize handle descriptor lets the door/window width/height arrows publish live guides for the edge being resized — proximity to neighbours as the width grows, and the live sill/head as a window's height changes. The generic LinearArrow stays kind-agnostic; only door/window declare the hook. Refactor (Codex review follow-up): one `publishOpeningGuidesForWallEvent` wrapper now backs all four wall-event publish sites (door/window move + placement) over a shared `makeWallToWorld`; window placement's repeated sill-snap is a single `resolvePlacementY` helper. Opening guides clear on commit / leave / cancel / roof-hover / unmount (mirroring the alignment-guide lifecycle) and on resize end. Codex-reviewed — no blockers; lifecycle/leaks, coordinate frame, sill-snap precedence, and resize disposal confirmed. Typecheck + biome + 23 core + 170 nodes tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/registry/handles.ts | 8 ++ .../components/editor/node-arrow-handles.tsx | 10 +- .../editor/opening-guides-3d-layer.tsx | 4 +- packages/nodes/src/door/definition.ts | 3 + packages/nodes/src/door/move-tool.tsx | 18 ++-- packages/nodes/src/door/tool.tsx | 38 ++++++++ .../src/shared/opening-guides-runtime.ts | 89 ++++++++++++++++++ packages/nodes/src/window/definition.ts | 3 + packages/nodes/src/window/move-tool.tsx | 15 +-- packages/nodes/src/window/tool.tsx | 91 ++++++++++++++++--- 10 files changed, 242 insertions(+), 37 deletions(-) diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 2d69058b..5824255c 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -116,6 +116,14 @@ export type LinearResizeHandle = { anchor: HandleAnchor currentValue: (node: N) => number apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + /** + * Optional per-tick hook fired while this handle is being dragged, with the + * live (in-progress, override-merged) node. A pure side-channel for transient + * feedback — doors/windows use it to publish proximity / sill guides for the + * edge being resized. The return value is ignored; the resize itself is driven + * by `apply`. + */ + onDrag?: (node: N, sceneApi: SceneApi) => void /** * Cross-node redirect. By default the drag's live override + the * committed write both land on the SELECTED node. When this returns diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 5dba030f..515dc048 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -47,6 +47,7 @@ import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useEditor from '../../store/use-editor' +import useOpeningGuides from '../../store/use-opening-guides' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { formatAngleRadians } from '../tools/shared/segment-angle' import { @@ -593,6 +594,9 @@ function LinearArrow({ // floating dimension pill (via `activeHandleDrag`) and its own in-world // chip is suppressed — matches the wall height handle. const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined + // Optional per-tick feedback hook (doors/windows publish proximity/sill guides + // for the edge being resized); cleared when the drag ends. + const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined const placementSceneApi = useMemo(() => createSceneApi(useScene), []) const basePosition = descriptor.placement.position(node, placementSceneApi) // `freezeOffset` (in node-local frame) cancels the mesh's `position` @@ -675,6 +679,7 @@ function LinearArrow({ if (measureLabel) { useEditor.getState().setActiveHandleDrag(null) } + if (onDrag) useOpeningGuides.getState().clear() }, move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => { const currentPointer = @@ -690,7 +695,10 @@ function LinearArrow({ ? snapScalar(rawNext, gridSnapStep) : rawNext const next = Math.min(maxBound, Math.max(minBound, snappedNext)) - return descriptor.apply(initialNode as never, next, sceneApi) as Partial + const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial + // Let the kind publish live guides for the edge being resized. + onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi) + return patch }, } }, diff --git a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx index f6ba7d3f..fb7299f6 100644 --- a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx +++ b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx @@ -43,8 +43,8 @@ const mid = (a: OpeningGuideVec3, b: OpeningGuideVec3): OpeningGuideVec3 => [ /** * Wall-plane proximity / alignment guides for the 3D editor — the spatial twin * of the floor-plan placement dimensions + equal-spacing badges. Subscribes to - * `useOpeningGuides` (published by the door/window move tools each drag tick) and - * draws sill/head + edge-proximity dimensions, a sill-alignment line, and + * `useOpeningGuides` (published by the door/window move, placement, and resize + * interactions each drag tick) and draws sill/head + edge-proximity dimensions, a sill-alignment line, and * equal-spacing badges. Coordinates are already in the move tool's render frame * (the producer reuses the cursor's `wallLocalToWorld`, so they share the cursor's * building-local frame), so this layer mounts beside `Alignment3DGuideLayer` and diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index ba1b1d16..9cf3d2a0 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -6,6 +6,7 @@ import type { RoofSegmentNode, WallNode, } from '@pascal-app/core' +import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { scaleHandleHeight } from './door-math' @@ -56,6 +57,7 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor return readWallLength(n, scene) }, currentValue: (n) => n.width, + onDrag: (node) => publishOpeningResizeGuides(node, false), apply: (initial, newWidth) => { // Anchored edge stays fixed in wall-local coords. Door rotation is // applied by the inner ride group (the renderer mounts a nested @@ -98,6 +100,7 @@ function doorHeightHandle(): HandleDescriptor { return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom) }, currentValue: (n) => n.height, + onDrag: (node) => publishOpeningResizeGuides(node, false), apply: (initial, newHeight) => { const bottom = initial.position[1] - initial.height / 2 // Scale the handle so it tracks the door instead of staying glued to a diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index e5ae2dcf..d3799bab 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -28,7 +28,10 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { clearOpeningGuides3D, publishOpeningGuides3D } from '../shared/opening-guides-runtime' +import { + clearOpeningGuides3D, + publishOpeningGuidesForWallEvent, +} from '../shared/opening-guides-runtime' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, @@ -256,7 +259,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => target.valid, ) - publishOpeningGuides3D({ + publishOpeningGuidesForWallEvent({ wall: target.wallNode, movingId: movingDoorNode.id, centerS: target.clampedX, @@ -265,15 +268,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => height: movingDoorNode.height, // Doors sit on the floor — no sill/head or vertical alignment guides. includeVertical: false, - nodes: useScene.getState().nodes, - toWorld: (s, y) => - wallLocalToWorld( - target.wallNode, - s, - y, - getLevelYOffset(), - getSlabElevation(target.event), - ), + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(target.event), }) } diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 0aa4c8c1..78dda6e7 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -25,6 +25,10 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { + clearOpeningGuides3D, + publishOpeningGuidesForWallEvent, +} from '../shared/opening-guides-runtime' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, @@ -86,6 +90,7 @@ const DoorTool: React.FC = () => { const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } // Alignment candidates — anchors of every alignable object; refreshed @@ -110,18 +115,21 @@ const DoorTool: React.FC = () => { const [x, y, z] = event.localPosition updateCursor([x, y + FALLBACK_HEIGHT / 2, z], 0, false) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } const showRoofFallbackCursor = (event: RoofEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } const showWallFallbackCursor = (event: WallEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } const onWallEnter = (event: WallEvent) => { @@ -191,6 +199,18 @@ const DoorTool: React.FC = () => { cursorRotation, valid, ) + + publishOpeningGuidesForWallEvent({ + wall: event.node, + movingId: node.id, + centerS: clampedX, + centerY: clampedY, + width, + height, + includeVertical: false, + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(event), + }) event.stopPropagation() } @@ -298,6 +318,20 @@ const DoorTool: React.FC = () => { cursorRotation, valid, ) + + if (draftRef.current) { + publishOpeningGuidesForWallEvent({ + wall: event.node, + movingId: draftRef.current.id, + centerS: clampedX, + centerY: clampedY, + width, + height, + includeVertical: false, + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(event), + }) + } event.stopPropagation() } @@ -386,6 +420,7 @@ const DoorTool: React.FC = () => { triggerSFX('sfx:structure-build') alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() + clearOpeningGuides3D() event.stopPropagation() } @@ -444,6 +479,8 @@ const DoorTool: React.FC = () => { useScene.getState().createNode(node, segment.id as AnyNodeId) draftRef.current = node } + // Opening guides are wall-specific; clear them while over a roof face. + clearOpeningGuides3D() updateRoofCursor(target, event.node as RoofNode) event.stopPropagation() } @@ -533,6 +570,7 @@ const DoorTool: React.FC = () => { destroyDraft() hideCursor() useAlignmentGuides.getState().clear() + clearOpeningGuides3D() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts index 9b5db582..dcc22b49 100644 --- a/packages/nodes/src/shared/opening-guides-runtime.ts +++ b/packages/nodes/src/shared/opening-guides-runtime.ts @@ -9,6 +9,9 @@ import { computeOpeningGuides, detectVerticalAlignment, type OpeningSpan, + sceneRegistry, + spatialGridManager, + useScene, type WallNode, } from '@pascal-app/core' import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor' @@ -159,3 +162,89 @@ export function publishOpeningGuides3D(args: { export function clearOpeningGuides3D(): void { useOpeningGuides.getState().clear() } + +/** Wall-local (s along the wall, y above the wall base) → the move tool's render + * frame, given the level Y offset + slab elevation. Shared by the wall-event + * publisher (which already has them) and the resize publisher (which derives + * them from the scene). Same frame as `wallLocalToWorld`. */ +function makeWallToWorld(wall: WallNode, levelYOffset: number, slabElevation: number): ToWorld { + const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return (s, y) => [ + wall.start[0] + s * cos, + slabElevation + y + levelYOffset, + wall.start[1] + s * sin, + ] +} + +/** Like {@link makeWallToWorld} but derives the level Y + slab elevation from the + * scene, for callers without a wall event — i.e. the resize handles. */ +export function wallToWorld(wall: WallNode): ToWorld { + const levelId = wall.parentId as AnyNodeId | undefined + const levelYOffset = levelId ? (sceneRegistry.nodes.get(levelId)?.position.y ?? 0) : 0 + const slabElevation = spatialGridManager.getSlabElevationForWall( + wall.parentId ?? '', + wall.start, + wall.end, + ) + return makeWallToWorld(wall, levelYOffset, slabElevation) +} + +/** + * Publish 3D opening guides for an opening being placed or moved on a wall via a + * wall event. The caller passes the level Y + slab elevation it already computed + * for the drag cursor, so the guides share the cursor's frame exactly — the one + * place the door/window move + placement tools publish from. + */ +export function publishOpeningGuidesForWallEvent(args: { + wall: WallNode + movingId: string + centerS: number + centerY: number + width: number + height: number + includeVertical: boolean + levelYOffset: number + slabElevation: number +}): void { + const { wall, levelYOffset, slabElevation, ...rest } = args + publishOpeningGuides3D({ + ...rest, + wall, + nodes: useScene.getState().nodes, + toWorld: makeWallToWorld(wall, levelYOffset, slabElevation), + }) +} + +/** + * Publish 3D opening guides for an opening being RESIZED via a handle arrow. + * Resolves the host wall + transform from the scene (no wall event), then reuses + * the shared publish. Doors pass `includeVertical: false` (they sit on the + * floor); windows pass `true` so a height drag also shows the live sill/head. + */ +export function publishOpeningResizeGuides( + node: { + id: string + parentId?: string | null + position: readonly [number, number, number] + width: number + height: number + }, + includeVertical: boolean, +): void { + const nodes = useScene.getState().nodes + const wall = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + if (wall?.type !== 'wall') return + publishOpeningGuides3D({ + wall, + movingId: node.id, + centerS: node.position[0], + centerY: node.position[1], + width: node.width, + height: node.height, + includeVertical, + nodes, + toWorld: wallToWorld(wall), + }) +} diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 1e32a160..bca9a2ad 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -6,6 +6,7 @@ import type { WallNode, WindowNode as WindowNodeType, } from '@pascal-app/core' +import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { buildWindowFloorplan } from './floorplan' @@ -50,6 +51,7 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor n.width, + onDrag: (node) => publishOpeningResizeGuides(node, true), apply: (initial, newWidth) => { const rotY = initial.rotation[1] const armX = Math.cos(rotY) @@ -97,6 +99,7 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor n.height, + onDrag: (node) => publishOpeningResizeGuides(node, true), apply: (initial, newHeight) => { // Anchored edge stays in wall-local Y; opposite edge moves. const anchorY = diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 6de64920..6f64a6de 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -31,7 +31,7 @@ import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { clearOpeningGuides3D, - publishOpeningGuides3D, + publishOpeningGuidesForWallEvent, resolveSillSnap, } from '../shared/opening-guides-runtime' import { @@ -304,7 +304,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode target.valid, ) - publishOpeningGuides3D({ + publishOpeningGuidesForWallEvent({ wall: target.wallNode, movingId: movingWindowNode.id, centerS: target.clampedX, @@ -312,15 +312,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode width: movingWindowNode.width, height: movingWindowNode.height, includeVertical: true, - nodes: useScene.getState().nodes, - toWorld: (s, y) => - wallLocalToWorld( - target.wallNode, - s, - y, - getLevelYOffset(), - getSlabElevation(target.event), - ), + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(target.event), }) } diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 26552ebc..2e92c0f6 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -26,6 +26,11 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { + clearOpeningGuides3D, + publishOpeningGuidesForWallEvent, + resolveSillSnap, +} from '../shared/opening-guides-runtime' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, @@ -90,6 +95,7 @@ const WindowTool: React.FC = () => { const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } // Alignment candidates — anchors of every alignable object; refreshed @@ -115,18 +121,46 @@ const WindowTool: React.FC = () => { const [x, y, z] = event.localPosition updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } const showRoofFallbackCursor = (event: RoofEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } const showWallFallbackCursor = (event: WallEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() + } + + // Sill alignment (snap + guide): a sibling sill/centre/top wins over the + // 0.5m grid when within threshold; Shift bypasses both. `movingId` is the + // draft's id once it exists (so it's excluded from the sibling scan), or '' + // before the draft is created (nothing to exclude yet). + const resolvePlacementY = (args: { + event: WallEvent + movingId: string + localX: number + width: number + height: number + }): number => { + const rawY = args.event.localPosition[1] + if (args.event.nativeEvent?.shiftKey === true) return rawY + const sillY = resolveSillSnap({ + wall: args.event.node, + movingId: args.movingId, + localX: args.localX, + localY: rawY, + width: args.width, + height: args.height, + nodes: useScene.getState().nodes, + }) + return sillY ?? snapToHalf(rawY) } const onWallEnter = (event: WallEvent) => { @@ -169,10 +203,7 @@ const WindowTool: React.FC = () => { bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, bypassSnap: event.nativeEvent?.shiftKey === true, }) - const localY = - event.nativeEvent?.shiftKey === true - ? event.localPosition[1] - : snapToHalf(event.localPosition[1]) + const localY = resolvePlacementY({ event, movingId: '', localX, width, height }) const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) @@ -201,6 +232,18 @@ const WindowTool: React.FC = () => { cursorRotation, valid, ) + + publishOpeningGuidesForWallEvent({ + wall: event.node, + movingId: node.id, + centerS: clampedX, + centerY: clampedY, + width, + height, + includeVertical: true, + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(event), + }) event.stopPropagation() } @@ -236,10 +279,13 @@ const WindowTool: React.FC = () => { bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, bypassSnap: event.nativeEvent?.shiftKey === true, }) - const localY = - event.nativeEvent?.shiftKey === true - ? event.localPosition[1] - : snapToHalf(event.localPosition[1]) + const localY = resolvePlacementY({ + event, + movingId: draftRef.current?.id ?? '', + localX, + width, + height, + }) const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) @@ -313,6 +359,20 @@ const WindowTool: React.FC = () => { cursorRotation, valid, ) + + if (draftRef.current) { + publishOpeningGuidesForWallEvent({ + wall: event.node, + movingId: draftRef.current.id, + centerS: clampedX, + centerY: clampedY, + width, + height, + includeVertical: true, + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(event), + }) + } event.stopPropagation() } @@ -334,10 +394,13 @@ const WindowTool: React.FC = () => { bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, bypassSnap: event.nativeEvent?.shiftKey === true, }) - const localY = - event.nativeEvent?.shiftKey === true - ? event.localPosition[1] - : snapToHalf(event.localPosition[1]) + const localY = resolvePlacementY({ + event, + movingId: draftRef.current.id, + localX, + width: draftRef.current.width, + height: draftRef.current.height, + }) const { clampedX, clampedY } = clampToWall( event.node, localX, @@ -404,6 +467,7 @@ const WindowTool: React.FC = () => { triggerSFX('sfx:structure-build') alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() + clearOpeningGuides3D() event.stopPropagation() } @@ -467,6 +531,8 @@ const WindowTool: React.FC = () => { useScene.getState().createNode(node, segment.id as AnyNodeId) draftRef.current = node } + // Opening guides are wall-specific; clear them while over a roof face. + clearOpeningGuides3D() updateRoofCursor(target, event.node as RoofNode) event.stopPropagation() } @@ -550,6 +616,7 @@ const WindowTool: React.FC = () => { destroyDraft() hideCursor() useAlignmentGuides.getState().clear() + clearOpeningGuides3D() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) From 69aa272004b84d62d1ee235abe683cd08b1074ff Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 15 Jun 2026 12:36:06 -0400 Subject: [PATCH 6/6] perf(editor): stabilize 3D opening-guide rendering, remove per-tick GPU churn Reuse one THREE.Line + preallocated position buffer per guide slot, mutating endpoints in place each drag tick instead of rebuilding the geometry, line, and two Vector3s and re-uploading the GPU buffer every frame. Key guides by a stable semantic id (sill / head / gap:side / vertical / spacing:i) so a slot that persists keeps its React element and drei pill mounted as the guide set churns, rather than remounting under shifting index keys. Also: make useOpeningGuides.clear() a no-op when already empty so the common no-guide hover frame doesn't push a fresh [] and re-render to the same nothing; dispose the move-tool cursor EdgesGeometry on unmount; and memoize the placement-tool cursor EdgesGeometry (static fallback dims) so it isn't reallocated and orphaned on every render during placement. Reviewed by Codex (peer + adversarial): no correctness, hook-order, or GPU-leak regressions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/opening-guides-3d-layer.tsx | 44 ++++++++++++------- .../editor/src/store/use-opening-guides.ts | 16 +++++-- packages/nodes/src/door/move-tool.tsx | 1 + packages/nodes/src/door/tool.tsx | 16 ++++--- .../src/shared/opening-guides-runtime.ts | 15 +++++-- packages/nodes/src/window/move-tool.tsx | 1 + packages/nodes/src/window/tool.tsx | 16 ++++--- 7 files changed, 77 insertions(+), 32 deletions(-) diff --git a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx index fb7299f6..8b39ef50 100644 --- a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx +++ b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx @@ -2,8 +2,8 @@ import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { memo, useEffect, useMemo } from 'react' -import { BufferGeometry, Line as ThreeLine, Vector3 } from 'three' +import { memo, useEffect, useLayoutEffect, useMemo } from 'react' +import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import useOpeningGuides, { @@ -56,8 +56,8 @@ export const OpeningGuides3DLayer = memo(function OpeningGuides3DLayer() { if (guides.length === 0) return null return ( <> - {guides.map((guide, i) => ( - + {guides.map((guide) => ( + ))} ) @@ -114,17 +114,31 @@ function GuideSegment({ to: OpeningGuideVec3 material: LineBasicNodeMaterial }) { - // Build a concrete THREE.Line and mount it via : the intrinsic - // JSX element collides with React's SVG , so keeps - // the typing clean and gives us direct control of layers + renderOrder. - const line = useMemo(() => { - const geometry = new BufferGeometry().setFromPoints([new Vector3(...from), new Vector3(...to)]) - const object = new ThreeLine(geometry, material) - object.frustumCulled = false - object.layers.set(EDITOR_LAYER) - object.renderOrder = 1000 - return object - }, [from, to, material]) + // Build the THREE.Line once with a preallocated 2-point position buffer and + // mount it via (the intrinsic JSX element collides with + // React's SVG ). `material` is a module-level constant, so this memo + // runs exactly once per mounted slot; subsequent drag ticks mutate the + // existing buffer in place via the layout effect below rather than rebuilding + // the geometry, line, and GPU buffer every frame. + const { line, position } = useMemo(() => { + const position = new Float32BufferAttribute(new Float32Array(6), 3) + const geometry = new BufferGeometry() + geometry.setAttribute('position', position) + const line = new ThreeLine(geometry, material) + line.frustumCulled = false + line.layers.set(EDITOR_LAYER) + line.renderOrder = 1000 + return { line, position } + }, [material]) + + const [fx, fy, fz] = from + const [tx, ty, tz] = to + useLayoutEffect(() => { + position.setXYZ(0, fx, fy, fz) + position.setXYZ(1, tx, ty, tz) + position.needsUpdate = true + }, [position, fx, fy, fz, tx, ty, tz]) + useEffect(() => () => line.geometry.dispose(), [line]) return } diff --git a/packages/editor/src/store/use-opening-guides.ts b/packages/editor/src/store/use-opening-guides.ts index ca2cc71a..6b9cf2c8 100644 --- a/packages/editor/src/store/use-opening-guides.ts +++ b/packages/editor/src/store/use-opening-guides.ts @@ -9,14 +9,19 @@ import { create } from 'zustand' export type OpeningGuideVec3 = [number, number, number] +// A stable identity per guide slot (`sill`, `head`, `gap:left`, `vertical`, +// `spacing:0`, …) so the renderer can key by semantic role: as the guide set +// churns each drag tick, a slot that persists keeps its React element — and its +// drei `` portal — mounted instead of remounting when the list shape +// shifts under index keys. export type OpeningGuide3D = // A measured line + distance pill: sill (floor → bottom edge), head (top edge // → wall top), or along-wall edge-to-edge proximity. - | { kind: 'dimension'; from: OpeningGuideVec3; to: OpeningGuideVec3; value: number } + | { kind: 'dimension'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3; value: number } // A dashed line connecting two openings that share a sill / centre / top. - | { kind: 'align-line'; from: OpeningGuideVec3; to: OpeningGuideVec3 } + | { kind: 'align-line'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3 } // A Figma-style "=" badge marking one gap in an equal-spacing run. - | { kind: 'badge'; at: OpeningGuideVec3; value: number } + | { kind: 'badge'; id: string; at: OpeningGuideVec3; value: number } type OpeningGuidesState = { guides: OpeningGuide3D[] @@ -27,7 +32,10 @@ type OpeningGuidesState = { const useOpeningGuides = create((set) => ({ guides: [], set: (guides) => set({ guides }), - clear: () => set({ guides: [] }), + // No-op when already empty so the common no-guide hover frame (fallback + // cursor, invalid target, roof hover) doesn't push a fresh `[]` and notify + // subscribers — the layer would re-render to the same nothing every tick. + clear: () => set((s) => (s.guides.length > 0 ? { guides: [] } : s)), })) export default useOpeningGuides diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index d3799bab..ec6e37de 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -644,6 +644,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => boxGeo.dispose() return geo }, [movingDoorNode]) + useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 78dda6e7..d93971cd 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -22,7 +22,7 @@ import { useAlignmentGuides, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { @@ -585,10 +585,16 @@ const DoorTool: React.FC = () => { } }, []) - // Cursor geometry: door outline. - const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) - const edgesGeo = new EdgesGeometry(boxGeo) - boxGeo.dispose() + // Cursor geometry: door outline. Static dims, so build it once and dispose on + // unmount rather than reallocating (and orphaning) an EdgesGeometry on every + // re-render during placement. + const edgesGeo = useMemo(() => { + const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) + const geo = new EdgesGeometry(boxGeo) + boxGeo.dispose() + return geo + }, []) + useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts index dcc22b49..1729e923 100644 --- a/packages/nodes/src/shared/opening-guides-runtime.ts +++ b/packages/nodes/src/shared/opening-guides-runtime.ts @@ -105,10 +105,14 @@ export function publishOpeningGuides3D(args: { const out: OpeningGuide3D[] = [] + // Stable `id`s keyed on the guide's semantic role (not list position) so the + // 3D layer can keep a persisting slot's element + `` pill mounted as the + // set churns each tick — see `OpeningGuide3D`. if (guides.sillHead) { if (guides.sillHead.sill > MIN_DIMENSION_M) { out.push({ kind: 'dimension', + id: 'sill', from: toWorld(centerS, 0), to: toWorld(centerS, guides.sillHead.bottomY), value: guides.sillHead.sill, @@ -117,6 +121,7 @@ export function publishOpeningGuides3D(args: { if (guides.sillHead.head > MIN_DIMENSION_M) { out.push({ kind: 'dimension', + id: 'head', from: toWorld(centerS, guides.sillHead.topY), to: toWorld(centerS, wallHeight), value: guides.sillHead.head, @@ -127,6 +132,7 @@ export function publishOpeningGuides3D(args: { for (const gap of guides.gaps) { out.push({ kind: 'dimension', + id: `gap:${gap.side}`, from: toWorld(gap.fromS, centerY), to: toWorld(gap.toS, centerY), value: gap.distance, @@ -140,6 +146,7 @@ export function publishOpeningGuides3D(args: { const hi = Math.max(centerS + width / 2, target.centerS + target.width / 2) out.push({ kind: 'align-line', + id: 'vertical', from: toWorld(lo, guides.vertical.y), to: toWorld(hi, guides.vertical.y), }) @@ -147,13 +154,15 @@ export function publishOpeningGuides3D(args: { } if (guides.equalSpacing) { - for (const seg of guides.equalSpacing.segments) { + const { gap, segments } = guides.equalSpacing + segments.forEach((seg, i) => { out.push({ kind: 'badge', + id: `spacing:${i}`, at: toWorld((seg.fromS + seg.toS) / 2, centerY), - value: guides.equalSpacing.gap, + value: gap, }) - } + }) } useOpeningGuides.getState().set(out) diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 6f64a6de..2793e57b 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -702,6 +702,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode boxGeo.dispose() return geo }, [movingWindowNode]) + useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 2e92c0f6..ccbce2fd 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -23,7 +23,7 @@ import { useAlignmentGuides, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { @@ -631,10 +631,16 @@ const WindowTool: React.FC = () => { } }, []) - // Cursor geometry: window outline rectangle. - const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) - const edgesGeo = new EdgesGeometry(boxGeo) - boxGeo.dispose() + // Cursor geometry: window outline rectangle. Static dims, so build it once and + // dispose on unmount rather than reallocating (and orphaning) an EdgesGeometry + // on every re-render during placement. + const edgesGeo = useMemo(() => { + const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) + const geo = new EdgesGeometry(boxGeo) + boxGeo.dispose() + return geo + }, []) + useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return (