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
+18 -4
View File
@@ -3,6 +3,7 @@
import type { Material, Mesh, Object3D, Raycaster } from 'three'
export const INVALID_GHOST_COLOR = 0xef_44_44
export const VALID_GHOST_COLOR = 0x22_c5_5e
const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {}
@@ -12,8 +13,11 @@ const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {}
* Traverses the object tree, disables raycasting on all descendants (prevents
* cursor-ray starvation), and clones visible mesh materials to set translucency.
*
* When `invalid` is true, sets color/emissive to INVALID_GHOST_COLOR and opacity ~0.4.
* Otherwise sets opacity ~0.5 while preserving the original color.
* Tint by state:
* - `invalid` (red, opacity ~0.4): off-host or colliding — can't place here.
* - `valid` (green, opacity ~0.45): on a host and placeable — the "go" cue.
* - neither (opacity ~0.5, original color): a plain translucent preview.
* `invalid` wins if both are passed.
*
* Skips: meshes whose material.visible === false (door/window root hitbox) and
* children named 'cutout'.
@@ -21,11 +25,15 @@ const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {}
* Returns cleanup that disposes only the cloned materials (never originals or geometry).
*
* @param root - The preview mesh tree (typically from buildDoorPreviewMesh / buildWindowPreviewMesh)
* @param opts - { invalid?: boolean } whether to tint red for invalid placement
* @param opts - { invalid?, valid? } placement-state tint
* @returns Cleanup function that disposes the cloned materials
*/
export function applyGhost(root: Object3D, opts?: { invalid?: boolean }): () => void {
export function applyGhost(
root: Object3D,
opts?: { invalid?: boolean; valid?: boolean },
): () => void {
const invalid = opts?.invalid ?? false
const valid = !invalid && (opts?.valid ?? false)
const cloned: Material[] = []
root.traverse((obj) => {
@@ -51,6 +59,12 @@ export function applyGhost(root: Object3D, opts?: { invalid?: boolean }): () =>
INVALID_GHOST_COLOR,
)
clone.opacity = 0.4
} else if (valid) {
;(clone as { color?: { setHex: (c: number) => void } }).color?.setHex(VALID_GHOST_COLOR)
;(clone as { emissive?: { setHex: (c: number) => void } }).emissive?.setHex(
VALID_GHOST_COLOR,
)
clone.opacity = 0.45
} else {
clone.opacity = 0.5
}
@@ -5,6 +5,7 @@ import {
getScaledDimensions,
type ItemNode,
nearestWallSegment,
useScene,
WALL_SNAP_DISTANCE_M,
type WallNode,
} from '@pascal-app/core'
@@ -187,3 +188,104 @@ export function snapLocalXToNeighbors(args: {
return bestDelta === null ? null : localX + bestDelta
}
/**
* Does a wall-hosted opening of `width × height` centred at `(clampedX,
* clampedY)` (wall-local) overlap any OTHER child of `wallId` (door / window /
* wall-mounted item)? AABB test in the wall's local face plane. `ignoreId`
* excludes the moving node itself. Returns `true` (blocked) if the wall is
* gone.
*
* Single source of truth for door + window placement collision — door-math and
* window-math had byte-identical copies of this. Y conventions differ per kind
* (items store bottom Y; doors/windows store centre Y), handled inline.
*/
export function hasWallChildOverlap(
wallId: string,
clampedX: number,
clampedY: number,
width: number,
height: number,
ignoreId?: string,
): boolean {
const nodes = useScene.getState().nodes
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode) return true
const halfW = width / 2
const halfH = height / 2
const newBottom = clampedY - halfH
const newTop = clampedY + halfH
const newLeft = clampedX - halfW
const newRight = clampedX + halfW
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId]
if (!child) continue
let childLeft: number
let childRight: number
let childBottom: number
let childTop: number
if (child.type === 'item') {
const item = child as ItemNode
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
const [w, h] = getScaledDimensions(item)
childLeft = item.position[0] - w / 2
childRight = item.position[0] + w / 2
childBottom = item.position[1] // items store bottom Y
childTop = item.position[1] + h
} else if (child.type === 'window') {
const win = child as { position: [number, number, number]; width: number; height: number }
childLeft = win.position[0] - win.width / 2
childRight = win.position[0] + win.width / 2
childBottom = win.position[1] - win.height / 2 // windows store centre Y
childTop = win.position[1] + win.height / 2
} else if (child.type === 'door') {
const door = child as { position: [number, number, number]; width: number; height: number }
childLeft = door.position[0] - door.width / 2
childRight = door.position[0] + door.width / 2
childBottom = door.position[1] - door.height / 2 // doors store centre Y
childTop = door.position[1] + door.height / 2
} else {
continue
}
const xOverlap = newLeft < childRight && newRight > childLeft
const yOverlap = newBottom < childTop && newTop > childBottom
if (xOverlap && yOverlap) return true
}
return false
}
/** Placement state for a wall-hosted opening — the SINGLE decision the preview
* tint and the commit gate both consume so they can never disagree. */
export type OpeningPlacement = {
/** Geometric overlap with another wall child (independent of modifiers). */
collides: boolean
/** May the opening be committed here? `true` unless it collides and the user
* isn't force-placing. */
placeable: boolean
/** Ghost tint: green when placeable, red when not. */
tint: 'valid' | 'invalid'
}
/**
* Resolve the placement state from the raw collision result and whether the
* user is force-placing (Shift). Force-place lifts the collision block, so the
* opening becomes placeable AND the tint goes green — the preview and the
* commit gate stay in lockstep because both read this one result.
*/
export function resolveOpeningPlacement(args: {
collides: boolean
forcePlace: boolean
}): OpeningPlacement {
const placeable = !args.collides || args.forcePlace
return {
collides: args.collides,
placeable,
tint: placeable ? 'valid' : 'invalid',
}
}
@@ -23,6 +23,12 @@ const MIN_AXIS_COMPONENT = 0.5
* guide on bypass / no-match. Returns the localX to use (X-clamped to the wall
* given `width`). `bypass` disables alignment; `bypassSnap` also skips the
* half-metre fallback.
*
* `freePlace` (Shift) is the "place anywhere, but still show me where I'd
* align" mode: the opening lands at the EXACT raw cursor (no grid snap, no
* jump-to-guide), yet the alignment guides are still computed and shown so the
* user keeps the visual reference while overriding the magnetic pull. It
* supersedes `bypass`/`bypassSnap` when set.
*/
export function resolveWallSlideAlignment(args: {
wallNode: WallNode
@@ -31,9 +37,55 @@ export function resolveWallSlideAlignment(args: {
candidates: readonly AlignmentAnchor[]
bypass: boolean
bypassSnap?: boolean
freePlace?: boolean
}): number {
const { wallNode, rawLocalX, width, candidates, bypass, bypassSnap = false } = args
const base = bypassSnap ? rawLocalX : snapToHalf(rawLocalX)
const {
wallNode,
rawLocalX,
width,
candidates,
bypass,
bypassSnap = false,
freePlace = false,
} = args
const base = bypassSnap || freePlace ? rawLocalX : snapToHalf(rawLocalX)
const dxAxis = wallNode.end[0] - wallNode.start[0]
const dzAxis = wallNode.end[1] - wallNode.start[1]
const axisLength = Math.sqrt(dxAxis * dxAxis + dzAxis * dzAxis)
// Shift / free-place: land at the raw cursor but still publish the guides so
// the user sees alignment relationships without being snapped to them. The
// guides are re-resolved at the freely-placed point so they connect to the
// opening, not the snap target.
if (freePlace) {
if (candidates.length === 0 || axisLength < 1e-6) {
useAlignmentGuides.getState().clear()
return base
}
const c = dxAxis / axisLength
const s = dzAxis / axisLength
const placedX = Math.max(width / 2, Math.min(axisLength - width / 2, base))
const shown = resolveAlignment({
moving: [
{
nodeId: '__wall-opening-draft__',
kind: 'corner',
x: wallNode.start[0] + placedX * c,
z: wallNode.start[1] + placedX * s,
},
],
candidates,
threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M,
})
const axisGuides = shown.guides.filter(
(g) => Math.abs(g.axis === 'x' ? c : s) >= MIN_AXIS_COMPONENT,
)
if (axisGuides.length === 0) useAlignmentGuides.getState().clear()
else useAlignmentGuides.getState().set(axisGuides)
return placedX
}
if (bypass || candidates.length === 0) {
useAlignmentGuides.getState().clear()
return base