feat(editor): 3D viewport proximity + sill + equal-spacing guides for openings

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) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-14 20:15:52 -04:00
co-authored by Claude Opus 4.8
parent 3ac6b27eca
commit 6caba97f1b
7 changed files with 401 additions and 2 deletions
+25
View File
@@ -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)