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>
63 lines
2.0 KiB
TypeScript
63 lines
2.0 KiB
TypeScript
import type { WallNode } from '@pascal-app/core'
|
|
|
|
/**
|
|
* Keep the door handle at the same relative height when the door is resized:
|
|
* scale it by the height ratio, then clamp to the panel's slider bounds
|
|
* [0.5, height - 0.1] so it never lands outside the (possibly shrunk) door.
|
|
* Used by both the height-resize arrow and the panel's Height slider so the
|
|
* handle tracks the door whichever way it's resized.
|
|
*/
|
|
export function scaleHandleHeight(
|
|
handleHeight: number,
|
|
oldHeight: number,
|
|
newHeight: number,
|
|
): number {
|
|
const ratio = oldHeight > 0 ? newHeight / oldHeight : 1
|
|
return Math.min(Math.max(handleHeight * ratio, 0.5), Math.max(0.5, newHeight - 0.1))
|
|
}
|
|
|
|
/**
|
|
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
|
*/
|
|
export function wallLocalToWorld(
|
|
wallNode: WallNode,
|
|
localX: number,
|
|
localY: number,
|
|
levelYOffset = 0,
|
|
slabElevation = 0,
|
|
): [number, number, number] {
|
|
const wallAngle = Math.atan2(
|
|
wallNode.end[1] - wallNode.start[1],
|
|
wallNode.end[0] - wallNode.start[0],
|
|
)
|
|
return [
|
|
wallNode.start[0] + localX * Math.cos(wallAngle),
|
|
slabElevation + localY + levelYOffset,
|
|
wallNode.start[1] + localX * Math.sin(wallAngle),
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Clamps door center X so it stays fully within wall bounds.
|
|
* Y is always height/2 — doors sit at floor level.
|
|
*/
|
|
export function clampToWall(
|
|
wallNode: WallNode,
|
|
localX: number,
|
|
width: number,
|
|
height: number,
|
|
): { clampedX: number; clampedY: number } {
|
|
const dx = wallNode.end[0] - wallNode.start[0]
|
|
const dz = wallNode.end[1] - wallNode.start[1]
|
|
const wallLength = Math.sqrt(dx * dx + dz * dz)
|
|
|
|
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
|
|
const clampedY = height / 2 // Doors always sit at floor level
|
|
return { clampedX, clampedY }
|
|
}
|
|
|
|
// Wall-child overlap is shared by door + window placement (one source of
|
|
// truth in `shared/wall-attach-target.ts`). Re-exported here so existing
|
|
// `./door-math` importers don't change.
|
|
export { hasWallChildOverlap } from '../shared/wall-attach-target'
|