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)