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:
co-authored by
Claude Opus 4.8
parent
2d053c4317
commit
a0d3d9c701
@@ -124,6 +124,14 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
// to be consumed. That legacy flow is gone in the registry layer;
|
||||
// all entries use the action menu now.
|
||||
let hasMovedSinceStart = false
|
||||
// Live cursor location — updated on EVERY pointermove (even over the 3D
|
||||
// canvas) so R-key ownership can follow the pointer's CURRENT pane rather
|
||||
// than the sticky `hasMovedSinceStart`. Without this, once the user touched
|
||||
// the 2D pane the overlay claimed R forever and the 3D flip went dead.
|
||||
let pointerOverFloorplan = false
|
||||
const onPointerTrack = (event: PointerEvent) => {
|
||||
pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
// Skip 3D-canvas / other-UI cursor moves so the overlay only
|
||||
@@ -288,18 +296,24 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
// apply so the 2D symbol updates immediately; kinds without a facing
|
||||
// leave `flipSide` unset and R falls through to the global handler.
|
||||
//
|
||||
// Only when THIS overlay is the active mover — i.e. the user has moved
|
||||
// the pointer over the 2D pane (`hasMovedSinceStart`). The 3D move tool
|
||||
// also listens for R while a door/window `movingNode` is placed (split
|
||||
// / 3D-only view); gating on `hasMovedSinceStart` keeps the two from
|
||||
// both flipping (or double-playing the cue) on a single R press.
|
||||
// Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D
|
||||
// move tool ALSO listens for R on `window`. We own R only while the
|
||||
// cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the
|
||||
// 2D mover has actually engaged (`hasMovedSinceStart`); then we
|
||||
// `stopImmediatePropagation` (this handler is CAPTURE-phase, so it runs
|
||||
// first) so the 3D tool can't also flip. When the cursor is over the 3D
|
||||
// pane we yield — the 3D tool owns R there. (The old sticky
|
||||
// `hasMovedSinceStart`-only gate made the overlay claim R forever after
|
||||
// the first 2D move, killing the 3D flip.)
|
||||
if (event.key === 'r' || event.key === 'R') {
|
||||
if (!(session.flipSide && hasMovedSinceStart)) return
|
||||
if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return
|
||||
if (event.repeat) return
|
||||
const t = event.target as HTMLElement | null
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
session.flipSide()
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
return
|
||||
@@ -349,13 +363,18 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
setMovingNode(null)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onPointerTrack)
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('keydown', onKey)
|
||||
// Capture phase so this runs BEFORE the 3D move tool's bubble-phase R
|
||||
// listener — when the cursor is over the 2D pane, `stopImmediatePropagation`
|
||||
// then pre-empts it so only one handler flips.
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onPointerTrack)
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
// Unmount cleanup. `historyPaused === true` here means none of
|
||||
// our terminal paths (commit, Esc) ran in this overlay — they
|
||||
// each call `resumeSceneHistory` and flip the flag.
|
||||
|
||||
Reference in New Issue
Block a user