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>
90 lines
3.0 KiB
TypeScript
90 lines
3.0 KiB
TypeScript
'use client'
|
|
|
|
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[]) => {}
|
|
|
|
/**
|
|
* Apply ghost material treatment to a preview mesh tree.
|
|
*
|
|
* Traverses the object tree, disables raycasting on all descendants (prevents
|
|
* cursor-ray starvation), and clones visible mesh materials to set translucency.
|
|
*
|
|
* 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'.
|
|
*
|
|
* 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?, valid? } placement-state tint
|
|
* @returns Cleanup function that disposes the cloned materials
|
|
*/
|
|
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) => {
|
|
// Disable raycast on every descendant to prevent cursor-ray starvation.
|
|
obj.raycast = NO_RAYCAST
|
|
|
|
const mesh = obj as Mesh
|
|
if (!mesh.isMesh) return
|
|
if (mesh.name === 'cutout') return
|
|
|
|
const original = mesh.material
|
|
const wasArray = Array.isArray(original)
|
|
|
|
const cloneOne = (mat: Material): Material | null => {
|
|
// Skip invisible materials (door/window root hitbox).
|
|
if ((mat as { visible?: boolean }).visible === false) return null
|
|
const clone = mat.clone()
|
|
clone.transparent = true
|
|
clone.depthWrite = false
|
|
if (invalid) {
|
|
;(clone as { color?: { setHex: (c: number) => void } }).color?.setHex(INVALID_GHOST_COLOR)
|
|
;(clone as { emissive?: { setHex: (c: number) => void } }).emissive?.setHex(
|
|
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
|
|
}
|
|
cloned.push(clone)
|
|
return clone
|
|
}
|
|
|
|
if (wasArray) {
|
|
const clonedMats = original.map(cloneOne).filter((m): m is Material => m !== null)
|
|
if (clonedMats.length > 0) mesh.material = clonedMats
|
|
} else {
|
|
const clone = cloneOne(original)
|
|
if (clone) mesh.material = clone
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
for (const mat of cloned) {
|
|
mat.dispose()
|
|
}
|
|
}
|
|
}
|