feat(editor): door/window placement-feel polish + placement-state refactor (#411)

Round 8 of the opening-placement UX work. Make door/window placement feel
physical and predictable, and unify the validity/placement logic behind one
shared decision.

UX:
- Window default sill 0.5 m (DEFAULT_WINDOW_SILL_M) so fresh windows float
  slightly above the floor; existing windows keep their own sill.
- Dev-only floor "shadow" projection for windows during placement/move
  (footprint + dashed drop-line) so an elevated window's plan spot is legible.
- Move SFX: one soft grid-snap click per grid step — identical free-following
  over floor or sliding on a wall (keyed on the raw cursor; per-frame + step
  dedup), no separate snap cue (that was a "double"). Mirrored into the 2D
  floorplan-move so 2D and 3D match.
- Shift = force-place over a collision (commit allowed; ghost stays a red
  warning) + free-place (lands at the raw cursor but keeps the alignment guides
  visible). Tint flips green/red live when Shift is pressed/released stationary.
- On-wall preview is now the tinted ghost (green placeable / red colliding),
  matching the free-follow ghost, instead of a pale solid mesh + thin wireframe.
- R-flip fixes: always toggles (no initial no-op needing a second press),
  e.repeat filtered, ghost rebuilds with the live `side`, and the ghost's
  on-wall world yaw uses `itemRotation - wallAngle` so it faces exactly what
  commit places (cursorRotation was π off for the asymmetric ghost). R ownership
  follows the current pointer pane (capture-phase + stopImmediatePropagation in
  the 2D overlay) so 3D and 2D never double-flip or go dead.

Refactor / quality:
- New `resolveOpeningPlacement({collides,forcePlace}) -> {placeable,tint}` in
  shared/wall-attach-target.ts — the single source of truth the ghost tint AND
  the commit gates both consume, so they can't disagree under Shift.
- Consolidated the byte-identical `hasWallChildOverlap` into one shared impl
  (door-math/window-math re-export it).
- applyGhost gained a green "valid" tint.
- Removed dead `cursorRotation` from the move-tool targets after the yaw fix.

Docs: "2D <-> 3D behavioral parity" principle in wiki/architecture/tools.md
(+ README + AGENTS.md) — applicable behaviors must exist in both views.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-16 10:20:44 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2d053c4317
commit a0d3d9c701
18 changed files with 1027 additions and 277 deletions
+12 -4
View File
@@ -16,7 +16,15 @@ import type { WindowNode } from './schema'
* The root mesh's layer is set to EDITOR_LAYER because the invisible hitbox
* material on SCENE_LAYER would poison the WebGPU MRT pass (project gotcha).
*/
const WindowPreview = ({ node, invalid }: { node: WindowNode; invalid?: boolean }) => {
const WindowPreview = ({
node,
invalid,
valid,
}: {
node: WindowNode
invalid?: boolean
valid?: boolean
}) => {
const mesh = useMemo(() => {
const m = buildWindowPreviewMesh(node)
m.layers.set(EDITOR_LAYER)
@@ -32,9 +40,9 @@ const WindowPreview = ({ node, invalid }: { node: WindowNode; invalid?: boolean
node.sillThickness,
])
// Ghost treatment (clone + tint + raycast-off) re-applies if `invalid`
// flips; its cleanup only disposes the clones it made.
useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid])
// Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips;
// its cleanup only disposes the clones it made.
useEffect(() => applyGhost(mesh, { invalid, valid }), [mesh, invalid, valid])
// Geometry is freshly built per `mesh` and owned here — dispose it only
// when the mesh itself is replaced/unmounted, never on an `invalid` toggle.