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 {
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 =