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
@@ -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