From 6caba97f1b6387796ed432c2496c4f9e9fdcdd5f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 14 Jun 2026 20:15:52 -0400 Subject: [PATCH] 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)