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
+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)