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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user