feat(editor): placement-time + resize-time opening guides

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) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-15 10:18:24 -04:00
co-authored by Claude Opus 4.8
parent 6caba97f1b
commit 86e9b3c8bf
10 changed files with 242 additions and 37 deletions
+8
View File
@@ -116,6 +116,14 @@ export type LinearResizeHandle<N> = {
anchor: HandleAnchor
currentValue: (node: N) => number
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
/**
* 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
@@ -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<AnyNode>
const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
// Let the kind publish live guides for the edge being resized.
onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
return patch
},
}
},
@@ -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
+3
View File
@@ -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<DoorNodeType>
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<DoorNodeType> {
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
+7 -11
View File
@@ -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),
})
}
+38
View File
@@ -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)
@@ -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),
})
}
+3
View File
@@ -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<WindowNodeT
return readWallLength(n, scene)
},
currentValue: (n) => 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<WindowNode
: Math.max(MIN_WINDOW_HEIGHT, anchored)
},
currentValue: (n) => n.height,
onDrag: (node) => publishOpeningResizeGuides(node, true),
apply: (initial, newHeight) => {
// Anchored edge stays in wall-local Y; opposite edge moves.
const anchorY =
+4 -11
View File
@@ -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),
})
}
+79 -12
View File
@@ -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)