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
+1
View File
@@ -34,6 +34,7 @@ Read the relevant page in `wiki/architecture/` **before** writing code. The page
- Adding a node type → `node-schemas.md`, `renderers.md`, `systems.md`
- Adding a tool → `tools.md`, `spatial-queries.md`, `events.md`
- Adding / changing a placement or move interaction → `tools.md` ("2D ↔ 3D behavioral parity": applicable behaviors must exist in both views; port the change to the sibling 2D/3D file in the same PR)
- Adding a system → `systems.md`, `scene-registry.md`
- Anything in `packages/viewer``viewer-isolation.md`, `layers.md`
- Anything touching selection → `selection-managers.md`, `scene-registry.md`, `events.md`
@@ -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.
+5 -69
View File
@@ -1,12 +1,4 @@
import {
type AnyNodeId,
type DoorNode,
getScaledDimensions,
type ItemNode,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import type { WallNode } from '@pascal-app/core'
/**
* Keep the door handle at the same relative height when the door is resized:
@@ -64,63 +56,7 @@ export function clampToWall(
return { clampedX, clampedY }
}
/**
* Checks if a proposed door position overlaps any existing wall children.
* Handles item, window, and door types.
*/
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, childRight: number, childBottom: number, 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]
childTop = item.position[1] + h
} else if (child.type === 'window') {
const win = child as WindowNode
childLeft = win.position[0] - win.width / 2
childRight = win.position[0] + win.width / 2
childBottom = win.position[1] - win.height / 2
childTop = win.position[1] + win.height / 2
} else if (child.type === 'door') {
const door = child as DoorNode
childLeft = door.position[0] - door.width / 2
childRight = door.position[0] + door.width / 2
childBottom = door.position[1] - door.height / 2
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
}
// 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'
+45 -7
View File
@@ -8,12 +8,13 @@ import {
type WallNode,
WallNode as WallNodeSchema,
} from '@pascal-app/core'
import { snapToHalf, usePlacementPreview } from '@pascal-app/editor'
import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
import {
findClosestWallInPlan,
projectWallLocalPointToPlan,
resolveOpeningPlacement,
snapLocalXToNeighbors,
} from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './door-math'
@@ -87,6 +88,25 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
// the cursor as a ghost (like the 3D move) and is NOT committable — a door
// needs a wall. Starts true so a click before any move keeps the door put.
let onWall = true
// Shift force-place (last apply's modifier) — lets `canCommit` allow an
// overlapping placement, matching the 3D move. Read in `canCommit` so a Shift-
// held commit over a collision lands instead of reverting.
let forcePlace = false
// Move SFX — parity with the 3D `MoveDoorTool`: ONE soft `sfx:grid-snap` click
// per grid step, identical free-following or sliding on a wall, keyed on the
// RAW cursor (not the snapped along-wall value). No separate floor→wall cue —
// that distinct sound was the "double" the user heard. 2D `apply` runs once per
// pointermove, so the step-key dedup is sufficient (no per-frame guard needed).
const STEP_M = 0.1
let lastStepKey: string | null = null
const tickGridStep = (...coords: number[]) => {
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
if (key !== lastStepKey) {
lastStepKey = key
triggerSFX('sfx:grid-snap')
}
}
// Off-wall: float the faithful door symbol at the cursor (via a synthetic
// wall fed to the placement-preview layer) and hide the real node, so the
@@ -104,14 +124,23 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
end: [planPoint[0] + half, planPoint[1]],
thickness: 0.1,
})
// Reflect the R-flip on the floating ghost so its swing-arc faces the side
// that will be committed (the synthetic wall is plan-X aligned, so a back
// facing is a π yaw; the symbol builder also reads `side`).
const ghostSide: DoorNode['side'] = flipped
? node.side === 'front'
? 'back'
: 'front'
: node.side
const ghost = {
...node,
side: ghostSide,
parentId: wall.id,
wallId: wall.id,
roofSegmentId: undefined,
roofFace: undefined,
position: [half, node.position[1], 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
rotation: [0, flipped ? Math.PI : 0, 0] as [number, number, number],
visible: true,
} as DoorNode
usePlacementPreview.getState().set(ghost, wall)
@@ -125,6 +154,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
},
apply({ planPoint, modifiers }) {
lastApply = { planPoint, modifiers }
forcePlace = modifiers.shiftKey === true
// Drop any stale live transform left by the 3D `MoveDoorTool` (it
// publishes one on every wall hover). The 2D registry layer renders
// door/window from `useLiveTransforms` IN PREFERENCE to the scene node,
@@ -140,7 +170,9 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) {
// Off any wall — free-follow the cursor (not committable).
// Off any wall — free-follow the cursor (not committable). Click per grid
// cell as the ghost slides over open floor.
tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1])
freeFollow(resolvedPlanPoint)
return
}
@@ -168,6 +200,11 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
// One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`,
// not the snapped value) so the wall slide ticks at the same cadence as the
// off-wall ghost — the same SFX, no separate snap cue.
tickGridStep(hit.localX)
// Apply the R-flip on top of the wall-derived side.
const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side
const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0)
@@ -204,9 +241,10 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
if (!onWall) return false
const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined
if (!live || live.type !== 'door') return false
// Block commit if the door overlaps any other wall child at its
// current position. The 3D port has the same guard.
const overlapping = hasWallChildOverlap(
// Block commit if the door overlaps another wall child — UNLESS Shift
// force-places (same `placeable` rule as the 3D move + the shared
// `resolveOpeningPlacement`).
const collides = hasWallChildOverlap(
live.parentId as string,
live.position[0],
live.position[1],
@@ -214,7 +252,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
live.height,
live.id,
)
return !overlapping
return resolveOpeningPlacement({ collides, forcePlace }).placeable
},
commit() {
// Own the atomic write so the overlay takes the deterministic
+226 -34
View File
@@ -14,7 +14,6 @@ import {
type WallEvent,
} from '@pascal-app/core'
import {
calculateCursorRotation,
calculateItemRotation,
consumePlacementDragRelease,
EDITOR_LAYER,
@@ -26,7 +25,7 @@ import {
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
@@ -38,8 +37,10 @@ import {
type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveOpeningPlacement } from '../shared/wall-attach-target'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
import DoorPreview from './preview'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef_44_44,
@@ -51,6 +52,40 @@ const edgeMaterial = new LineBasicNodeMaterial({
const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
const cursorGroupRef = useRef<Group>(null!)
// The door preview ghost. Shown for the WHOLE move so the user always sees a
// translucent door tinted by placement state — red off-wall or colliding,
// green on a valid wall — exactly like the free-follow ghost. The real node
// stays hidden until commit (the wall still cuts its hole from the node data,
// so the opening reads correctly behind the ghost). `null` = not previewing
// (committed / torn down). See the matching `DoorPreview` tint.
const [ghostPose, setGhostPose] = useState<{
position: [number, number, number]
rotationY: number
tint: 'valid' | 'invalid'
// The door's facing side at the cursor. R-flip changes it mid-placement and
// the door geometry's swing/hinge depends on it, so the ghost must rebuild
// with the LIVE side — otherwise the preview shows the pre-flip orientation
// while commit places the flipped one.
side: DoorNode['side']
} | null>(null)
// Ghost preview node: the moving door with a zeroed transform + the live
// facing side. `updateDoorMesh` bakes `position`/`rotation` into the mesh (the
// `<group>` wrapper already places it, so we zero those to avoid a double
// offset) and reads `side` for the swing/hinge direction — so the ghost
// matches exactly what commit will place, including an R-flip. Falls back to
// the moving node's own side when no pose is active.
const ghostSide = ghostPose?.side ?? movingDoorNode.side
const ghostNode = useMemo(
() => ({
...movingDoorNode,
side: ghostSide,
position: [0, 0, 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
}),
[movingDoorNode, ghostSide],
)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
@@ -76,6 +111,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: movingDoorNode.roofSegmentId,
roofFace: movingDoorNode.roofFace,
metadata: movingDoorNode.metadata,
// Free-follow hides the node (visible:false); every revert path must
// restore the original visibility or an existing door cancelled over open
// floor would stay invisible.
visible: movingDoorNode.visible,
}
if (!isNew) {
@@ -95,6 +134,35 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
// pointermove (shared DOM timeStamp) — that's the only thing that snaps.
let freeFollowing = false
let lastMeshEventTime = -1
// Last open-floor cursor point (level-local X/Z), so an R-flip or Shift change
// while free-following can re-run the ghost at the same spot with the new
// facing/tint — no pointer move required.
let lastFloorPoint: [number, number] | null = null
// Live Shift state (force-place). Tracked here so the preview tint can be
// re-evaluated when Shift is pressed/released with the pointer stationary —
// the stored WallEvent carries a STALE shiftKey from the last move.
let shiftHeld = false
// Movement SFX: ONE soft `sfx:grid-snap` click each time the door crosses a
// grid step — identical whether free-following over open floor or sliding
// along a wall, so the two feel the same (the user's ask). Always keyed on
// the RAW cursor position (continuous ~0.1m cadence), never the snapped
// along-wall value, so the wall slide ticks at the same rate as the ghost.
// Two guards prevent a doubled/flammed cue: `lastStepKey` (emit only when
// the quantized cell changes) AND `lastTickFrame` (at most one tick per DOM
// pointermove — a wall mesh can emit `wall:move` more than once per move, and
// the grid + wall paths can both run). No separate snap cue: a distinct
// floor→wall sound was the "double" the user heard.
const STEP_M = 0.1
let lastStepKey: string | null = null
let lastTickFrame = -1
const tickGridStep = (frame: number, ...coords: number[]) => {
if (frame === lastTickFrame) return
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
if (key === lastStepKey) return
lastStepKey = key
lastTickFrame = frame
triggerSFX('sfx:grid-snap')
}
// The door's chosen facing side. R flips it mid-placement (front ↔ back,
// same as the committed-selected R flip) so the user can reorient before
// committing. Initialised from the moving node's side.
@@ -104,7 +172,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: string
side: DoorNode['side']
itemRotation: number
cursorRotation: number
clampedX: number
clampedY: number
valid: boolean
@@ -143,6 +210,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
setGhostPose(null)
}
// Alignment candidates — anchors of every OTHER alignable object (the
@@ -172,8 +240,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
return {
side,
itemRotation: calculateItemRotation(event.normal) + rotationOffset,
cursorRotation:
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset,
}
}
@@ -185,7 +251,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
if (event.node.parentId !== getLevelId()) return
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
const { side, itemRotation } = getPlacementOrientation(event)
const rawLocalX = event.localPosition[0]
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
@@ -201,8 +267,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
rawLocalX: targetLocalX,
width: movingDoorNode.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
// Alt still hard-disables alignment (no guides). Shift = free-place:
// land at the raw cursor but keep showing the alignment guides.
bypass: event.nativeEvent?.altKey === true,
freePlace: event.nativeEvent?.shiftKey === true,
})
const { clampedX, clampedY } = clampToWall(
event.node,
@@ -225,7 +293,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: event.node.id,
side,
itemRotation,
cursorRotation,
clampedX,
clampedY,
valid,
@@ -234,6 +301,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
// Same click as the off-wall ghost: one grid-snap tick per grid step,
// keyed on the RAW cursor along-wall position (not the snapped clampedX,
// whose ~0.5m jumps would tick at a different cadence). Per-frame guard
// collapses any duplicate wall events on the same pointermove.
tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0])
// Keep the REAL node hidden and show a tinted ghost in the wall opening —
// green when placeable, red when it collides — the same translucent ghost
// the free-follow uses, so validity reads at a glance. The node position is
// still written (so the wall cuts the hole at the right spot) but
// `visible:false` keeps the pale solid mesh from competing with the ghost.
if (currentHostId !== target.wallId) {
useScene.getState().updateNode(movingDoorNode.id, {
position: [target.clampedX, target.clampedY, 0],
@@ -243,6 +320,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
visible: false,
})
markHostDirty(currentHostId)
currentHostId = target.wallId
@@ -260,17 +338,37 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
})
markHostDirtyThrottled(target.wallId)
updateCursor(
wallLocalToWorld(
// Position the tinted ghost at the wall opening (world frame), facing the
// wall normal + the live side (so an R-flip shows correctly). The
// wireframe cursor is no longer used on a wall. Tint comes from the SHARED
// placement decision — green when placeable (incl. Shift force-place over a
// collision), red otherwise — the SAME `placeable` the commit gate uses.
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
const placement = resolveOpeningPlacement({
collides: !target.valid,
forcePlace: shiftHeld,
})
// The committed door is a CHILD of the wall mesh (group yaw = -wallAngle)
// with wall-local `itemRotation` (0 front / π back). The ghost is a
// scene-root world-space group, so its world yaw must be
// `-wallAngle + itemRotation` to face the same way as commit.
// `cursorRotation` (the old symmetric-wireframe yaw) is π off here.
const wallAngle = Math.atan2(
target.wallNode.end[1] - target.wallNode.start[1],
target.wallNode.end[0] - target.wallNode.start[0],
)
setGhostPose({
position: wallLocalToWorld(
target.wallNode,
target.clampedX,
target.clampedY,
getLevelYOffset(),
getSlabElevation(target.event),
),
target.cursorRotation,
target.valid,
)
rotationY: target.itemRotation - wallAngle,
tint: placement.tint,
side: target.side,
})
publishOpeningGuidesForWallEvent({
wall: target.wallNode,
@@ -351,6 +449,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
// The moving node is hidden during free-follow; the committed door
// must be visible regardless of the pre-commit free-follow state.
visible: true,
})
useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id
@@ -364,6 +465,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
useScene.temporal.getState().resume()
@@ -375,6 +477,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: target.wallId,
roofSegmentId: undefined,
metadata: {},
visible: true,
})
if (original.parentId && original.parentId !== target.wallId) {
@@ -400,7 +503,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
if (event.node.parentId !== getLevelId()) return
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
if (!target?.valid) return
// Shift force-places: commit even when the door overlaps another opening.
// The preview keeps its red invalid tint as a warning; Shift just lifts the
// commit block. Read shift from THIS event so it's never stale at commit.
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
commitToWall(target)
event.stopPropagation()
}
@@ -420,13 +527,29 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
lastRoofEvent = null
}
// Free-follow: the door rides the cursor over empty floor, parented to the
// level like an item node (lifted so it stands on the floor). No wall to
// attach to, so it is not committable here.
const freeFollowAt = (localX: number, localZ: number) => {
// Reveal the real door node + drop the ghost. Used by the roof-face path,
// which previews with the real mesh (the ghost-tint flow is wall-specific).
const revealRealNode = () => {
setGhostPose(null)
const live = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined
if (live && live.visible === false) {
useScene.getState().updateNode(movingDoorNode.id, { visible: true })
}
}
// Free-follow: over open floor there's no wall to host the door, so instead
// of dragging the real (pale, near-invisible-on-grid) node around we hide it
// and float a red translucent ghost at the cursor — same treatment the raw
// `DoorTool` build path uses. The node still re-parents to the level so a
// later wall-snap / commit has a clean base, but stays `visible:false` until
// a wall is hovered.
const freeFollowAt = (localX: number, localZ: number, frame: number) => {
freeFollowing = true
lastTarget = null
lastRoofEvent = null
// Click per grid cell as the ghost slides over open floor (X+Z) — the
// same `tickGridStep` the on-wall slide uses, so both feel identical.
tickGridStep(frame, localX, localZ)
hideCursor()
useLiveTransforms.getState().clear(movingDoorNode.id)
const levelId = getLevelId()
@@ -445,6 +568,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: undefined,
roofSegmentId: undefined,
roofFace: undefined,
visible: false,
})
currentHostId = levelId
} else {
@@ -452,8 +576,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
position: [localX, y, localZ],
rotation: [0, yaw, 0],
side: sideOverride,
visible: false,
})
}
// Float the red (invalid — no wall) ghost at the cursor, level-Y lifted so
// it stands on the floor, matching the door's chosen facing (sideOverride
// carries the R-flip so the ghost swing direction matches commit).
setGhostPose({
position: [localX, getLevelYOffset() + y, localZ],
rotationY: yaw,
tint: 'invalid',
side: sideOverride,
})
}
const onGridMove = (event: GridEvent) => {
@@ -468,7 +602,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
// so snapping engages only when the cursor ray actually hovers a wall
// (`onWallMove`). Over open floor the door just follows the cursor.
const [x, , z] = event.localPosition
freeFollowAt(x, z)
lastFloorPoint = [x, z]
freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1)
}
// ── Roof-segment wall faces ─────────────────────────────────────
@@ -505,6 +640,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useLiveTransforms.getState().clear(movingDoorNode.id)
// Opening guides are wall-specific; clear them when over a roof face.
clearOpeningGuides3D()
// On a roof face the real mesh is the preview — drop the free-follow ghost
// and reveal the node.
revealRealNode()
if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingDoorNode.id, {
position: target.position,
@@ -514,6 +652,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: undefined,
roofSegmentId: target.segment.id,
roofFace: target.face.id,
visible: true,
})
markHostDirty(currentHostId)
currentHostId = target.segment.id
@@ -531,7 +670,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = resolveRoofMoveTarget(event)
if (!target?.valid) return
// Shift force-places over a colliding roof-face target too (see onWallClick).
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
committed = true
const segmentId = target.segment.id
@@ -553,6 +694,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: segmentId,
roofFace: target.face.id,
parentId: segmentId,
visible: true,
})
useScene.getState().createNode(node, segmentId as AnyNodeId)
placedId = node.id
@@ -566,6 +708,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
useScene.temporal.getState().resume()
@@ -578,6 +721,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: segmentId,
roofFace: target.face.id,
metadata: {},
visible: true,
})
if (original.parentId && original.parentId !== segmentId) {
@@ -622,6 +766,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
if (original.parentId) markHostDirty(original.parentId)
}
@@ -633,8 +778,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
// Free-following over open floor can't commit (no wall). A wall hover
// target commits via commitToWall; a roof face via onRoofClick.
if (lastTarget?.valid && !freeFollowing) {
// target commits via commitToWall; a roof face via onRoofClick. Shift
// force-places over a colliding wall target (the tint stays red as a
// warning); read shift from this pointerup so it's current at commit.
if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) {
commitToWall(lastTarget)
return
}
@@ -655,23 +802,30 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
) {
return
}
// Only act where a flip is meaningful — on a wall hover or while
// free-following. On a roof face leave it to the default R (no flip, no
// sfx) so the cue never fires without a visible effect.
const onWall = lastTarget !== null
if (!(onWall || freeFollowing)) return
// Ignore OS key-repeat so a held R doesn't flip many times per press.
if (e.repeat) return
e.preventDefault()
// ALWAYS toggle the persistent flip intent — never a no-op. (The old gate
// dropped R before the first pointermove, so initial-placement R needed a
// second press.) Then re-render whatever preview is current so the flip
// shows live and matches what commit will write.
sideOverride = sideOverride === 'front' ? 'back' : 'front'
triggerSFX('sfx:item-rotate')
if (onWall) {
if (lastTarget) {
// On a wall: re-resolve with the flipped side and re-preview.
const next = resolveMoveTarget(lastTarget!.event)
const next = resolveMoveTarget(lastTarget.event)
if (next) {
lastTarget = next
applyPreview(next)
}
} else if (lastFloorPoint) {
// Free-following: re-run at the same spot so the floating ghost rebuilds
// with the flipped side (its swing/hinge geometry depends on `side`).
freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1)
} else {
// Free-following on the level: flip the draft's facing in place.
// No preview yet (R pressed before the first pointermove at initial
// placement): flip the hidden node so the FIRST preview/commit already
// reflects the chosen side.
useScene.getState().updateNode(movingDoorNode.id, {
side: sideOverride,
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0],
@@ -679,6 +833,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
}
// Shift toggles force-place. Track it live and re-run the on-wall preview so
// the tint flips green↔red the instant Shift is pressed/released, even with
// the pointer stationary — the ghost and the commit gate read the same
// `placeable`. (Commit gates still read shift fresh from their own event.)
const onShiftToggle = (e: KeyboardEvent) => {
if (e.key !== 'Shift') return
const held = e.type === 'keydown'
if (held === shiftHeld) return
shiftHeld = held
if (!committed && lastTarget) applyPreview(lastTarget)
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
@@ -691,6 +857,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keydown', onShiftToggle)
window.addEventListener('keyup', onShiftToggle)
return () => {
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
@@ -711,9 +879,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
if (original.parentId) markHostDirty(original.parentId)
}
} else if (current && current.visible === false) {
// Safety net: a fresh (isNew) clone isn't marked `isTransient`, so the
// branch above skips it. If we unmount mid-free-follow it would be left
// hidden — reveal it so it never becomes an invisible orphan. (The
// `place-preset` movingNode subscription deletes a truly-cancelled
// clone separately.)
useScene.getState().updateNode(movingDoorNode.id, { visible: true })
}
useLiveTransforms.getState().clear(movingDoorNode.id)
useAlignmentGuides.getState().clear()
@@ -731,6 +907,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keydown', onShiftToggle)
window.removeEventListener('keyup', onShiftToggle)
}
}, [movingDoorNode, exitMoveMode])
@@ -747,9 +925,23 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
</group>
<>
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
</group>
{/* Placement ghost shown for the whole move (the real pale node stays
hidden): red off-wall / colliding, green on a valid wall. Uses the
moving node's own dimensions so the ghost matches its type. */}
{ghostPose && (
<group position={ghostPose.position} rotation-y={ghostPose.rotationY}>
<DoorPreview
invalid={ghostPose.tint === 'invalid'}
node={ghostNode}
valid={ghostPose.tint === 'valid'}
/>
</group>
)}
</>
)
}
+12 -4
View File
@@ -16,16 +16,24 @@ import type { DoorNode } from './schema'
* The root mesh's layer is set to EDITOR_LAYER because the invisible hitbox
* material on SCENE_LAYER would poison the WebGPU MRT pass (project gotcha).
*/
const DoorPreview = ({ node, invalid }: { node: DoorNode; invalid?: boolean }) => {
const DoorPreview = ({
node,
invalid,
valid,
}: {
node: DoorNode
invalid?: boolean
valid?: boolean
}) => {
const mesh = useMemo(() => {
const m = buildDoorPreviewMesh(node)
m.layers.set(EDITOR_LAYER)
return m
}, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount])
// Ghost treatment (clone + tint + raycast-off) re-applies if `invalid`
// flips; its cleanup only disposes the clones it made.
useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid])
// Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips;
// its cleanup only disposes the clones it made.
useEffect(() => applyGhost(mesh, { invalid, valid }), [mesh, invalid, valid])
// Geometry is freshly built per `mesh` and owned here — dispose it only
// when the mesh itself is replaced/unmounted, never on an `invalid` toggle.
+49 -13
View File
@@ -74,13 +74,25 @@ const DoorTool: React.FC = () => {
// Off-host floating ghost: the real door geometry follows the cursor over
// the grid (tinted invalid). Mutually exclusive with the on-host draft —
// when a draft + wireframe is shown this is null and vice-versa.
// when a draft + wireframe is shown this is null and vice-versa. `side`
// carries the R-flip so the floating ghost faces the side that will be
// committed (its swing/hinge geometry depends on `side`).
const [fallbackPose, setFallbackPose] = useState<{
position: [number, number, number]
rotationY: number
side: DoorNode['side']
} | null>(null)
const ghostStub = useMemo(() => DoorNode.parse({ position: [0, 0, 0], rotation: [0, 0, 0] }), [])
// Ghost preview node — zeroed transform + the live facing side (rebuilds on R).
const ghostStub = useMemo(
() =>
DoorNode.parse({
position: [0, 0, 0],
rotation: [0, 0, 0],
side: fallbackPose?.side ?? 'front',
}),
[fallbackPose?.side],
)
useEffect(() => {
useScene.temporal.getState().pause()
@@ -98,6 +110,9 @@ const DoorTool: React.FC = () => {
// re-applied to the last wall hover so the flip shows live before commit.
let sideFlip = false
let lastWallEvent: WallEvent | null = null
// Last open-floor cursor point (level-local X/Z) so an R-flip while
// free-following can re-render the floating ghost with the new facing.
let lastFloorPoint: [number, number, number] | null = null
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
@@ -149,9 +164,15 @@ const DoorTool: React.FC = () => {
// Off-host fallback: hide the wireframe outline and float the real door
// geometry (tinted invalid) at the cursor so the armed tool is visible.
// `sideFlip` (R) flips the facing: a back facing is a π yaw on the floating
// ghost, and the door swing/hinge geometry reads `side`.
const showGhostAt = (position: [number, number, number]) => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
setFallbackPose({ position, rotationY: 0 })
setFallbackPose({
position,
rotationY: sideFlip ? Math.PI : 0,
side: sideFlip ? 'back' : 'front',
})
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
}
@@ -176,13 +197,16 @@ const DoorTool: React.FC = () => {
bypassSnap: boolean,
ignoreId?: string,
) => {
// bypassSnap is set by Shift (see callers). Shift = free-place: land at the
// raw cursor but keep the along-wall guides visible. bypass (Alt) still
// hard-disables alignment.
const localX = resolveWallSlideAlignment({
wallNode: wall,
rawLocalX,
width,
candidates: alignmentCandidates,
bypass,
bypassSnap,
bypass: bypass && !bypassSnap,
freePlace: bypassSnap,
})
const { clampedX, clampedY } = clampToWall(wall, localX, width, height)
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
@@ -403,7 +427,8 @@ const DoorTool: React.FC = () => {
bypassSnap,
draftRef.current.id,
)
if (!valid) return
// Shift force-places over a collision (the draft stays red as a warning).
if (!valid && !bypassSnap) return
commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation)
event.stopPropagation()
@@ -433,8 +458,9 @@ const DoorTool: React.FC = () => {
hostKind = null
lastWallEvent = null
const [x, y, z] = event.localPosition
lastFloorPoint = [x, y + FALLBACK_HEIGHT / 2, z]
destroyDraft()
showGhostAt([x, y + FALLBACK_HEIGHT / 2, z])
showGhostAt(lastFloorPoint)
}
// ── Roof-segment wall faces ─────────────────────────────────────
@@ -497,7 +523,9 @@ const DoorTool: React.FC = () => {
const onRoofClick = (event: RoofEvent) => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
if (!target?.valid) return
// Shift force-places over a colliding roof-face target (see onWallClick).
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
const { segment, face, position } = target
const draft = draftRef.current
@@ -568,18 +596,26 @@ const DoorTool: React.FC = () => {
}
// R flips the door's facing side mid-placement (front ↔ back), like the
// committed-selected R flip. Only meaningful while snapped to a wall (the
// off-wall ghost has no orientation), so it acts only then — re-applying
// the last wall hover so the snapped preview flips live.
// committed-selected R flip. ALWAYS toggles the persistent flip intent —
// never a no-op (the old `!lastWallEvent` guard dropped R off-wall and before
// the first wall hover, so it "needed two presses"). Then re-renders whatever
// preview is current so the flip shows live and matches commit.
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'r' && e.key !== 'R') return
if (!lastWallEvent) return
if (e.repeat) return
const t = e.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
e.preventDefault()
sideFlip = !sideFlip
triggerSFX('sfx:item-rotate')
onWallHover(lastWallEvent)
if (lastWallEvent) {
// On a wall: re-resolve + re-preview with the flipped side.
onWallHover(lastWallEvent)
} else if (lastFloorPoint) {
// Off-wall: re-render the floating ghost (showGhostAt reads `sideFlip`).
showGhostAt(lastFloorPoint)
}
// else: no preview yet — `sideFlip` is set, so the first hover/follow uses it.
}
emitter.on('wall:enter', onWallHover)
+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
@@ -0,0 +1,111 @@
'use client'
import { EDITOR_LAYER } from '@pascal-app/editor'
import { useEffect, useMemo } from 'react'
import { BufferGeometry, Float32BufferAttribute, LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
/**
* Floor "shadow" projection for a window during placement / move.
*
* Windows sit elevated above the floor, so over open ground (and on a wall)
* it's hard to read where the window actually is in plan. This draws its
* projection on the floor: a small footprint segment directly below the window
* (its plan extent along the wall) plus a DASHED vertical line dropping from the
* window centre to that footprint — like a shadow tether. Placement aid only;
* never shown on a committed window.
*
* Rendered in WORLD space (the tool positions the ghost in world space too), so
* it's mounted as a sibling of the ghost — NOT inside the ghost's rotated/offset
* group. `centerY` is the window centre's world Y; `floorY` is the level floor.
*/
const FOOTPRINT_COLOR = 0x38_bd_f8
const DROP_COLOR = 0x38_bd_f8
const footprintMaterial = new LineBasicNodeMaterial({
color: FOOTPRINT_COLOR,
transparent: true,
opacity: 0.85,
depthWrite: false,
})
const dropMaterial = new LineBasicNodeMaterial({
color: DROP_COLOR,
transparent: true,
opacity: 0.6,
depthWrite: false,
})
// Dash geometry for the vertical drop: alternating on/off segments so it reads
// as a dashed tether without a dashed-line material (unavailable in three/webgpu).
const DASH = 0.12
const GAP = 0.08
export function WindowFloorProjection({
centerX,
centerZ,
centerY,
floorY,
width,
rotationY,
}: {
centerX: number
centerZ: number
centerY: number
floorY: number
width: number
rotationY: number
}) {
// Footprint: a short segment of length `width` along the window's wall axis,
// centred under the window on the floor. The window faces `rotationY` about Y
// (its width runs along the wall), so the along-wall direction is
// (cos, -sin) in XZ.
const footprint = useMemo(() => {
const half = width / 2
const dirX = Math.cos(rotationY)
const dirZ = -Math.sin(rotationY)
const position = new Float32BufferAttribute(new Float32Array(6), 3)
const geometry = new BufferGeometry()
geometry.setAttribute('position', position)
const line = new LineSegments(geometry, footprintMaterial)
line.frustumCulled = false
line.layers.set(EDITOR_LAYER)
line.renderOrder = 1000
line.raycast = () => {}
position.setXYZ(0, centerX - dirX * half, floorY + 0.002, centerZ - dirZ * half)
position.setXYZ(1, centerX + dirX * half, floorY + 0.002, centerZ + dirZ * half)
position.needsUpdate = true
return line
}, [centerX, centerZ, floorY, width, rotationY])
// Dashed vertical tether from the window centre down to the footprint.
const drop = useMemo(() => {
const span = Math.max(centerY - floorY, 0)
const segs: number[] = []
let y = floorY
while (y < floorY + span) {
const top = Math.min(y + DASH, floorY + span)
segs.push(centerX, y, centerZ, centerX, top, centerZ)
y += DASH + GAP
}
const position = new Float32BufferAttribute(new Float32Array(segs), 3)
const geometry = new BufferGeometry()
geometry.setAttribute('position', position)
const line = new LineSegments(geometry, dropMaterial)
line.frustumCulled = false
line.layers.set(EDITOR_LAYER)
line.renderOrder = 1000
line.raycast = () => {}
return line
}, [centerX, centerZ, centerY, floorY])
useEffect(() => () => footprint.geometry.dispose(), [footprint])
useEffect(() => () => drop.geometry.dispose(), [drop])
return (
<>
<primitive object={footprint} />
<primitive object={drop} />
</>
)
}
+43 -7
View File
@@ -8,15 +8,16 @@ import {
WallNode as WallNodeSchema,
type WindowNode,
} from '@pascal-app/core'
import { snapToHalf, usePlacementPreview } from '@pascal-app/editor'
import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
import {
findClosestWallInPlan,
projectWallLocalPointToPlan,
resolveOpeningPlacement,
snapLocalXToNeighbors,
} from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './window-math'
import { clampToWall, DEFAULT_WINDOW_SILL_M, hasWallChildOverlap } from './window-math'
/**
* 2D floor-plan move handler for window. Same shape as door (see
@@ -56,8 +57,8 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
// created at y=0, which would sit the window's centre on the floor (half
// below ground); default those to a realistic sill so it floats above
// the floor in 2D too. Same rule as the 3D `MoveWindowTool` (`getSillCenterY`).
const DEFAULT_SILL = 0.9
const startLocalY = node.position[1] > 0.1 ? node.position[1] : DEFAULT_SILL + node.height / 2
const startLocalY =
node.position[1] > 0.1 ? node.position[1] : DEFAULT_WINDOW_SILL_M + node.height / 2
// Track the last successful placement so `commit()` can write it
// atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`.
@@ -82,6 +83,23 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
// See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor
// as a ghost and isn't committable (it needs a wall). Starts true.
let onWall = true
// Shift force-place (last apply's modifier) — lets `canCommit` allow an
// overlapping placement, matching the 3D move.
let forcePlace = false
// Move SFX — parity with the 3D `MoveWindowTool` (see `doorFloorplanMoveTarget`):
// ONE soft `sfx:grid-snap` click per grid step, identical free-following or on a
// wall, keyed on the RAW cursor. No separate floor→wall cue (that was the
// "double"). 2D `apply` runs once per pointermove, so the step-key dedup suffices.
const STEP_M = 0.1
let lastStepKey: string | null = null
const tickGridStep = (...coords: number[]) => {
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
if (key !== lastStepKey) {
lastStepKey = key
triggerSFX('sfx:grid-snap')
}
}
const freeFollow = (planPoint: readonly [number, number]) => {
onWall = false
@@ -95,14 +113,22 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
end: [planPoint[0] + half, planPoint[1]],
thickness: 0.1,
})
// Reflect the R-flip on the floating ghost so it faces the side that will
// be committed (see `doorFloorplanMoveTarget.freeFollow`).
const ghostSide: WindowNode['side'] = flipped
? node.side === 'front'
? 'back'
: 'front'
: node.side
const ghost = {
...node,
side: ghostSide,
parentId: wall.id,
wallId: wall.id,
roofSegmentId: undefined,
roofFace: undefined,
position: [half, startLocalY, 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
rotation: [0, flipped ? Math.PI : 0, 0] as [number, number, number],
visible: true,
} as WindowNode
usePlacementPreview.getState().set(ghost, wall)
@@ -116,6 +142,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
},
apply({ planPoint, modifiers }) {
lastApply = { planPoint, modifiers }
forcePlace = modifiers.shiftKey === true
// Drop any stale live transform left by the 3D `MoveWindowTool` — see
// `doorFloorplanMoveTarget.apply`. Without this the 2D registry layer
// keeps rendering the window at the 3D tool's last hover (it prefers
@@ -129,6 +156,8 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) {
// Off any wall — free-follow. Click per grid cell over open floor.
tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1])
freeFollow(resolvedPlanPoint)
return
}
@@ -160,6 +189,11 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
node.height,
)
// One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`)
// so the wall slide ticks at the same cadence as the off-wall ghost — same
// SFX, no separate snap cue.
tickGridStep(hit.localX)
const side: WindowNode['side'] = flipped
? hit.side === 'front'
? 'back'
@@ -192,7 +226,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
if (!onWall) return false
const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined
if (!live || live.type !== 'window') return false
const overlapping = hasWallChildOverlap(
// Block on overlap UNLESS Shift force-places — same `placeable` rule as
// the 3D move + the shared `resolveOpeningPlacement`.
const collides = hasWallChildOverlap(
live.parentId as string,
live.position[0],
live.position[1],
@@ -200,7 +236,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
live.height,
live.id,
)
return !overlapping
return resolveOpeningPlacement({ collides, forcePlace }).placeable
},
commit() {
// Own the atomic write so the overlay takes the deterministic
+217 -34
View File
@@ -14,7 +14,6 @@ import {
WindowNode,
} from '@pascal-app/core'
import {
calculateCursorRotation,
calculateItemRotation,
consumePlacementDragRelease,
EDITOR_LAYER,
@@ -27,7 +26,7 @@ import {
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
@@ -40,8 +39,16 @@ import {
type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveOpeningPlacement } from '../shared/wall-attach-target'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
import { WindowFloorProjection } from './floor-projection'
import WindowPreview from './preview'
import {
clampToWall,
DEFAULT_WINDOW_SILL_M,
hasWallChildOverlap,
wallLocalToWorld,
} from './window-math'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef_44_44,
@@ -65,6 +72,38 @@ const edgeMaterial = new LineBasicNodeMaterial({
const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {
const cursorGroupRef = useRef<Group>(null!)
// The window preview ghost. Shown for the WHOLE move so the user always sees
// a translucent window tinted by placement state — red off-wall or colliding,
// green on a valid wall. The real node stays hidden until commit (the wall
// still cuts its hole from the node data). `null` = not previewing. See the
// matching `WindowPreview` tint and `MoveDoorTool` for the full rationale.
const [ghostPose, setGhostPose] = useState<{
position: [number, number, number]
rotationY: number
tint: 'valid' | 'invalid'
// Level floor world-Y, for the floor "shadow" projection (drop-line + footprint).
floorY: number
// Live facing side — R-flip changes it and the window geometry depends on it,
// so the ghost must rebuild with the live side (see `MoveDoorTool`).
side: WindowNode['side']
} | null>(null)
// Ghost preview node: the moving window with a zeroed transform + the live
// facing side (the ghost is positioned by the `<group position>` wrapper;
// `updateWindowMesh` bakes the node's own position/rotation in, so passing the
// live node would double-offset). Rebuilds on an R-flip so the preview matches
// what commit will place.
const ghostSide = ghostPose?.side ?? movingWindowNode.side
const ghostNode = useMemo(
() => ({
...movingWindowNode,
side: ghostSide,
position: [0, 0, 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
}),
[movingWindowNode, ghostSide],
)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
@@ -91,6 +130,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: movingWindowNode.roofSegmentId,
roofFace: movingWindowNode.roofFace,
metadata: movingWindowNode.metadata,
// Free-follow hides the node (visible:false); revert paths restore this.
visible: movingWindowNode.visible,
}
// In move mode (existing window) mark it transient so its mesh skips the live wall CSG
@@ -113,6 +154,29 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
// mesh event owns the same pointermove — that's the only thing that snaps.
let freeFollowing = false
let lastMeshEventTime = -1
// Last open-floor cursor point (level-local X/Z), so an R-flip while free-
// following can re-run the ghost at the same spot with the new facing.
let lastFloorPoint: [number, number] | null = null
// Live Shift state (force-place) — lets the preview tint re-evaluate when
// Shift is pressed/released with the pointer stationary (see `MoveDoorTool`).
let shiftHeld = false
// Movement SFX: ONE soft `sfx:grid-snap` click per grid step — identical
// whether free-following over floor or sliding along a wall (the user's
// ask). Always keyed on the RAW cursor (continuous ~0.1m cadence), never the
// snapped along-wall value. Guards: `lastStepKey` (cell change) +
// `lastTickFrame` (one tick per DOM pointermove). No separate snap cue — a
// distinct floor→wall sound was the "double" the user heard. See `MoveDoorTool`.
const STEP_M = 0.1
let lastStepKey: string | null = null
let lastTickFrame = -1
const tickGridStep = (frame: number, ...coords: number[]) => {
if (frame === lastTickFrame) return
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
if (key === lastStepKey) return
lastStepKey = key
lastTickFrame = frame
triggerSFX('sfx:grid-snap')
}
// The window's chosen facing side. R flips it mid-placement (front ↔ back),
// matching the committed-selected R flip. Initialised from the moving node.
let sideOverride: WindowNode['side'] = movingWindowNode.side
@@ -128,7 +192,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: string
side: WindowNode['side']
itemRotation: number
cursorRotation: number
clampedX: number
clampedY: number
valid: boolean
@@ -160,12 +223,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
// Sill-center height used while the window isn't on a wall (free-follow and
// proximity). Fresh preset clones are created at position [0,0,0], which
// would bury half the window below the floor; default such windows to a
// ~0.9m sill so the ghost floats at a realistic height. An existing window
// keeps its own sill.
const DEFAULT_SILL = 0.9
// small sill so the ghost floats slightly above the ground. An existing
// window keeps its own sill.
const getSillCenterY = () => {
const y = movingWindowNode.position[1]
return y > 0.1 ? y : DEFAULT_SILL + movingWindowNode.height / 2
return y > 0.1 ? y : DEFAULT_WINDOW_SILL_M + movingWindowNode.height / 2
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
@@ -178,6 +240,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
setGhostPose(null)
}
// Alignment candidates — anchors of every OTHER alignable object (the
@@ -214,8 +277,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const side = sideOverride ?? faceSide
const rotationOffset = side !== faceSide ? Math.PI : 0
const itemRotation = calculateItemRotation(event.normal) + rotationOffset
const cursorRotation =
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset
const rawLocalX = event.localPosition[0]
const rawLocalY = event.localPosition[1]
@@ -256,8 +317,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
rawLocalX: targetLocalX,
width: movingWindowNode.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
// Alt still hard-disables alignment (no guides). Shift = free-place:
// land at the raw cursor but keep showing the along-wall guides.
bypass: event.nativeEvent?.altKey === true,
freePlace: event.nativeEvent?.shiftKey === true,
})
const { clampedX, clampedY } = clampToWall(
event.node,
@@ -281,7 +344,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: event.node.id,
side,
itemRotation,
cursorRotation,
clampedX,
clampedY,
valid,
@@ -290,6 +352,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
// Same click as the off-wall ghost: one grid-snap tick per grid step,
// keyed on the RAW cursor along-wall position (not the snapped clampedX).
// Per-frame guard collapses duplicate wall events on the same pointermove.
tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0])
// Keep the REAL node hidden and show a tinted ghost in the wall opening —
// green when placeable, red when it collides — matching the free-follow
// ghost so validity reads at a glance (see MoveDoorTool). The node position
// is still written so the wall cuts the hole at the right spot.
if (currentHostId !== target.wallId) {
useScene.getState().updateNode(movingWindowNode.id, {
position: [target.clampedX, target.clampedY, 0],
@@ -299,6 +369,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
visible: false,
})
markHostDirty(currentHostId)
currentHostId = target.wallId
@@ -316,17 +387,28 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
})
markHostDirtyThrottled(target.wallId)
updateCursor(
wallLocalToWorld(
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
const placement = resolveOpeningPlacement({ collides: !target.valid, forcePlace: shiftHeld })
// Ghost world yaw must equal the committed wall-CHILD's world yaw
// (-wallAngle + itemRotation); `cursorRotation` is π off here. See
// `MoveDoorTool.applyPreview`.
const wallAngle = Math.atan2(
target.wallNode.end[1] - target.wallNode.start[1],
target.wallNode.end[0] - target.wallNode.start[0],
)
setGhostPose({
position: wallLocalToWorld(
target.wallNode,
target.clampedX,
target.clampedY,
getLevelYOffset(),
getSlabElevation(target.event),
),
target.cursorRotation,
target.valid,
)
rotationY: target.itemRotation - wallAngle,
tint: placement.tint,
floorY: getLevelYOffset() + getSlabElevation(target.event),
side: target.side,
})
publishOpeningGuidesForWallEvent({
wall: target.wallNode,
@@ -410,6 +492,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
// Hidden during free-follow; the committed window must be visible.
visible: true,
})
useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id
@@ -425,6 +509,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
useScene.temporal.getState().resume()
@@ -436,6 +521,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: target.wallId,
roofSegmentId: undefined,
metadata: {},
visible: true,
})
if (original.parentId && original.parentId !== target.wallId) {
@@ -462,7 +548,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
if (event.node.parentId !== getLevelId()) return
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
if (!target?.valid) return
// Shift force-places: commit even when the window overlaps another opening.
// The preview keeps its red invalid tint as a warning; Shift just lifts the
// commit block. Read shift from THIS event so it's never stale at commit.
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
commitToWall(target)
event.stopPropagation()
}
@@ -480,13 +570,28 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
lastRoofEvent = null
}
// Free-follow: the window rides the cursor over empty floor, parented to
// the level like an item node, kept at a sensible sill height. No wall to
// attach to, so it is not committable here.
const freeFollowAt = (localX: number, localZ: number) => {
// Reveal the real window node + drop the ghost. Used by the roof-face path,
// which previews with the real mesh (the ghost-tint flow is wall-specific).
const revealRealNode = () => {
setGhostPose(null)
const live = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
| WindowNode
| undefined
if (live && live.visible === false) {
useScene.getState().updateNode(movingWindowNode.id, { visible: true })
}
}
// Free-follow: over open floor there's no wall to host the window, so hide
// the real (pale, near-invisible-on-grid) node and float a red translucent
// ghost at the cursor — same treatment the raw `WindowTool` build path uses.
const freeFollowAt = (localX: number, localZ: number, frame: number) => {
freeFollowing = true
lastTarget = null
lastRoofEvent = null
// Click per grid cell as the ghost slides over open floor (X+Z) — the
// same `tickGridStep` the on-wall slide uses, so both feel identical.
tickGridStep(frame, localX, localZ)
hideCursor()
useLiveTransforms.getState().clear(movingWindowNode.id)
const levelId = getLevelId()
@@ -503,6 +608,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: undefined,
roofSegmentId: undefined,
roofFace: undefined,
visible: false,
})
currentHostId = levelId
} else {
@@ -510,8 +616,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
position: [localX, sillCenterY, localZ],
rotation: [0, yaw, 0],
side: sideOverride,
visible: false,
})
}
// Float the red (invalid — no wall) ghost at the cursor, level-Y lifted to
// the sill center (sideOverride carries the R-flip so the ghost matches).
setGhostPose({
position: [localX, getLevelYOffset() + sillCenterY, localZ],
rotationY: yaw,
tint: 'invalid',
floorY: getLevelYOffset(),
side: sideOverride,
})
}
const onGridMove = (event: GridEvent) => {
@@ -523,7 +639,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
// snapping engages only when the cursor ray actually hovers a wall.
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
const [x, , z] = event.localPosition
freeFollowAt(x, z)
lastFloorPoint = [x, z]
freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1)
}
// ── Roof-segment wall faces ─────────────────────────────────────
@@ -564,6 +681,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useLiveTransforms.getState().clear(movingWindowNode.id)
// Opening guides are wall-specific; clear them when over a roof face.
clearOpeningGuides3D()
// On a roof face the real mesh is the preview — drop the ghost + reveal.
revealRealNode()
if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingWindowNode.id, {
position: target.position,
@@ -573,6 +692,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: undefined,
roofSegmentId: target.segment.id,
roofFace: target.face.id,
visible: true,
})
markHostDirty(currentHostId)
currentHostId = target.segment.id
@@ -590,7 +710,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = resolveRoofMoveTarget(event)
if (!target?.valid) return
// Shift force-places over a colliding roof-face target too (see onWallClick).
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
committed = true
const segmentId = target.segment.id
@@ -613,6 +735,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: segmentId,
roofFace: target.face.id,
parentId: segmentId,
visible: true,
})
useScene.getState().createNode(node, segmentId as AnyNodeId)
placedId = node.id
@@ -626,6 +749,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
useScene.temporal.getState().resume()
@@ -638,6 +762,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: segmentId,
roofFace: target.face.id,
metadata: {},
visible: true,
})
if (original.parentId && original.parentId !== segmentId) {
@@ -682,6 +807,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
if (original.parentId) markHostDirty(original.parentId)
}
@@ -693,8 +819,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
// Free-following over open floor can't commit (no wall). A wall hover
// target commits via commitToWall; a roof face via onRoofClick.
if (lastTarget?.valid && !freeFollowing) {
// target commits via commitToWall; a roof face via onRoofClick. Shift
// force-places over a colliding wall target (tint stays red as a warning);
// read shift from this pointerup so it's current at commit.
if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) {
commitToWall(lastTarget)
return
}
@@ -714,18 +842,27 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
) {
return
}
const onWall = lastTarget !== null
if (!(onWall || freeFollowing)) return
// Ignore OS key-repeat so a held R doesn't flip many times per press.
if (e.repeat) return
e.preventDefault()
// ALWAYS toggle the persistent flip intent — never a no-op (the old gate
// dropped R before the first pointermove). Then re-render the current
// preview so the flip shows live and matches commit. See `MoveDoorTool`.
sideOverride = sideOverride === 'front' ? 'back' : 'front'
triggerSFX('sfx:item-rotate')
if (onWall) {
const next = resolveMoveTarget(lastTarget!.event)
if (lastTarget) {
const next = resolveMoveTarget(lastTarget.event)
if (next) {
lastTarget = next
applyPreview(next)
}
} else if (lastFloorPoint) {
// Free-following: re-run at the same spot so the floating ghost rebuilds
// with the flipped side.
freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1)
} else {
// No preview yet (R before the first pointermove): flip the hidden node
// so the first preview/commit already reflects the chosen side.
useScene.getState().updateNode(movingWindowNode.id, {
side: sideOverride,
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0],
@@ -733,6 +870,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
}
// Shift toggles force-place — re-run the on-wall preview so the tint flips
// green↔red live (pointer stationary). Commit gates still read shift fresh.
const onShiftToggle = (e: KeyboardEvent) => {
if (e.key !== 'Shift') return
const held = e.type === 'keydown'
if (held === shiftHeld) return
shiftHeld = held
if (!committed && lastTarget) applyPreview(lastTarget)
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
@@ -745,6 +892,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keydown', onShiftToggle)
window.addEventListener('keyup', onShiftToggle)
return () => {
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
@@ -766,9 +915,15 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
if (original.parentId) markHostDirty(original.parentId)
}
} else if (current && current.visible === false) {
// Safety net: a fresh (isNew) clone isn't marked `isTransient`; if we
// unmount mid-free-follow it would be left hidden. Reveal it so it never
// becomes an invisible orphan (place-preset deletes a true cancel).
useScene.getState().updateNode(movingWindowNode.id, { visible: true })
}
useLiveTransforms.getState().clear(movingWindowNode.id)
useAlignmentGuides.getState().clear()
@@ -786,6 +941,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keydown', onShiftToggle)
window.removeEventListener('keyup', onShiftToggle)
}
}, [movingWindowNode, exitMoveMode])
@@ -802,9 +959,35 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
</group>
<>
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
</group>
{/* Placement ghost shown for the whole move (the real pale node stays
hidden): red off-wall / colliding, green on a valid wall. */}
{ghostPose && (
<group position={ghostPose.position} rotation-y={ghostPose.rotationY}>
<WindowPreview
invalid={ghostPose.tint === 'invalid'}
node={ghostNode}
valid={ghostPose.tint === 'valid'}
/>
</group>
)}
{/* Floor "shadow" projection: footprint + dashed drop-line, so an elevated
window's plan position is legible while placing. World-space, so it's a
sibling of the ghost group, not a child. */}
{ghostPose && (
<WindowFloorProjection
centerX={ghostPose.position[0]}
centerY={ghostPose.position[1]}
centerZ={ghostPose.position[2]}
floorY={ghostPose.floorY}
rotationY={ghostPose.rotationY}
width={movingWindowNode.width}
/>
)}
</>
)
}
+12 -4
View File
@@ -16,7 +16,15 @@ import type { WindowNode } from './schema'
* The root mesh's layer is set to EDITOR_LAYER because the invisible hitbox
* material on SCENE_LAYER would poison the WebGPU MRT pass (project gotcha).
*/
const WindowPreview = ({ node, invalid }: { node: WindowNode; invalid?: boolean }) => {
const WindowPreview = ({
node,
invalid,
valid,
}: {
node: WindowNode
invalid?: boolean
valid?: boolean
}) => {
const mesh = useMemo(() => {
const m = buildWindowPreviewMesh(node)
m.layers.set(EDITOR_LAYER)
@@ -32,9 +40,9 @@ const WindowPreview = ({ node, invalid }: { node: WindowNode; invalid?: boolean
node.sillThickness,
])
// Ghost treatment (clone + tint + raycast-off) re-applies if `invalid`
// flips; its cleanup only disposes the clones it made.
useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid])
// Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips;
// its cleanup only disposes the clones it made.
useEffect(() => applyGhost(mesh, { invalid, valid }), [mesh, invalid, valid])
// Geometry is freshly built per `mesh` and owned here — dispose it only
// when the mesh itself is replaced/unmounted, never on an `invalid` toggle.
+78 -21
View File
@@ -39,8 +39,14 @@ import {
worldToSelectedBuildingLocal,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { WindowFloorProjection } from './floor-projection'
import WindowPreview from './preview'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
import {
clampToWall,
DEFAULT_WINDOW_SILL_M,
hasWallChildOverlap,
wallLocalToWorld,
} from './window-math'
// Shared edge material — reuse across renders, just toggle color
const edgeMaterial = new LineBasicNodeMaterial({
@@ -52,10 +58,12 @@ const edgeMaterial = new LineBasicNodeMaterial({
const FALLBACK_WIDTH = 1.5
const FALLBACK_HEIGHT = 1.5
const FALLBACK_SILL_LIFT = 0.45
// Off-wall ghost lift = the default sill, so the floating preview matches the
// sill the floor-cursor placement commits at.
const FALLBACK_SILL_LIFT = DEFAULT_WINDOW_SILL_M
// Default sill centre for a window snapped from the floor (the floor cursor
// carries no wall-face height). 0.9 m sill + half the 1.5 m default height.
const DEFAULT_SILL_CENTER_Y = 0.9 + FALLBACK_HEIGHT / 2
// carries no wall-face height): the default sill + half the default height.
const DEFAULT_SILL_CENTER_Y = DEFAULT_WINDOW_SILL_M + FALLBACK_HEIGHT / 2
const roofFallbackPoint = new Vector3()
// What currently owns the cursor frame: a wall/roof mesh hover, or null when
@@ -79,14 +87,24 @@ const WindowTool: React.FC = () => {
// Off-host floating ghost: the real window geometry follows the cursor
// over the grid (tinted invalid). Mutually exclusive with the on-host draft.
// `floorY` feeds the floor "shadow" projection; `side` carries the R-flip so
// the floating ghost faces the side that will be committed.
const [fallbackPose, setFallbackPose] = useState<{
position: [number, number, number]
rotationY: number
floorY: number
side: WindowNode['side']
} | null>(null)
// Ghost preview node — zeroed transform + the live facing side (rebuilds on R).
const ghostStub = useMemo(
() => WindowNode.parse({ position: [0, 0, 0], rotation: [0, 0, 0] }),
[],
() =>
WindowNode.parse({
position: [0, 0, 0],
rotation: [0, 0, 0],
side: fallbackPose?.side ?? 'front',
}),
[fallbackPose?.side],
)
useEffect(() => {
@@ -104,6 +122,9 @@ const WindowTool: React.FC = () => {
// to the last wall hover so the flip shows live before commit.
let sideFlip = false
let lastWallEvent: WallEvent | null = null
// Last open-floor cursor point (level-local X/Z) + floor Y, so an R-flip
// while free-following can re-render the floating ghost with the new facing.
let lastFloorPoint: { pos: [number, number, number]; floorY: number } | null = null
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
@@ -157,21 +178,34 @@ const WindowTool: React.FC = () => {
// Off-host fallback: hide the wireframe outline and float the real window
// geometry (tinted invalid) at the cursor so the armed tool is visible.
const showGhostAt = (position: [number, number, number]) => {
const showGhostAt = (position: [number, number, number], floorY: number) => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
setFallbackPose({ position, rotationY: 0 })
lastFloorPoint = { pos: position, floorY }
// `sideFlip` (R) flips the facing — back is a π yaw on the floating ghost.
setFallbackPose({
position,
rotationY: sideFlip ? Math.PI : 0,
floorY,
side: sideFlip ? 'back' : 'front',
})
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
}
const showRoofFallbackCursor = (event: RoofEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z])
showGhostAt(
[x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z],
getLevelYOffset(),
)
}
const showWallFallbackCursor = (event: WallEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z])
showGhostAt(
[x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z],
getLevelYOffset(),
)
}
// Sill alignment (snap + guide): a sibling sill/centre/top wins over the
@@ -211,13 +245,16 @@ const WindowTool: React.FC = () => {
bypassSnap: boolean,
ignoreId?: string,
) => {
// bypassSnap is set by Shift (see callers). Shift = free-place: land at the
// raw cursor but keep the along-wall guides visible. bypass (Alt) still
// hard-disables alignment.
const localX = resolveWallSlideAlignment({
wallNode: wall,
rawLocalX,
width,
candidates: alignmentCandidates,
bypass,
bypassSnap,
bypass: bypass && !bypassSnap,
freePlace: bypassSnap,
})
const localY = resolvePlacementY({
wall,
@@ -454,7 +491,8 @@ const WindowTool: React.FC = () => {
bypassSnap,
draftRef.current.id,
)
if (!valid) return
// Shift force-places over a collision (the draft stays red as a warning).
if (!valid && !bypassSnap) return
commitWindowAtWall(event.node, clampedX, clampedY, side, itemRotation)
event.stopPropagation()
@@ -485,7 +523,7 @@ const WindowTool: React.FC = () => {
lastWallEvent = null
const [x, y, z] = event.localPosition
destroyDraft()
showGhostAt([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z])
showGhostAt([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], y)
}
// ── Roof-segment wall faces ─────────────────────────────────────
@@ -553,7 +591,9 @@ const WindowTool: React.FC = () => {
const onRoofClick = (event: RoofEvent) => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
if (!target?.valid) return
// Shift force-places over a colliding roof-face target (see onWallClick).
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
const { segment, face, position } = target
const draft = draftRef.current
@@ -617,19 +657,24 @@ const WindowTool: React.FC = () => {
hostKind = null
}
// R flips the window's facing side mid-placement (front ↔ back), like the
// committed-selected R flip. Only meaningful while snapped to a wall (the
// off-wall ghost has no orientation), so it acts only then re-applying
// the last wall hover so the snapped preview flips live.
// R flips the window's facing side mid-placement (front ↔ back). ALWAYS
// toggles the persistent flip intent — never a no-op (the old `!lastWallEvent`
// guard dropped R off-wall / before the first hover). Then re-renders the
// current preview so the flip shows live and matches commit.
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'r' && e.key !== 'R') return
if (!lastWallEvent) return
if (e.repeat) return
const t = e.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
e.preventDefault()
sideFlip = !sideFlip
triggerSFX('sfx:item-rotate')
onWallHover(lastWallEvent)
if (lastWallEvent) {
onWallHover(lastWallEvent)
} else if (lastFloorPoint) {
showGhostAt(lastFloorPoint.pos, lastFloorPoint.floorY)
}
// else: no preview yet — `sideFlip` is set, so the first hover/follow uses it.
}
emitter.on('wall:enter', onWallHover)
@@ -690,6 +735,18 @@ const WindowTool: React.FC = () => {
<WindowPreview invalid node={ghostStub} />
</group>
)}
{/* Floor "shadow" projection for the off-host ghost (drop-line + footprint)
so the elevated window's plan position is legible while placing. */}
{fallbackPose && (
<WindowFloorProjection
centerX={fallbackPose.position[0]}
centerY={fallbackPose.position[1]}
centerZ={fallbackPose.position[2]}
floorY={fallbackPose.floorY}
rotationY={fallbackPose.rotationY}
width={FALLBACK_WIDTH}
/>
)}
</>
)
}
+14 -69
View File
@@ -1,12 +1,13 @@
import {
type AnyNodeId,
type DoorNode,
getScaledDimensions,
type ItemNode,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import type { WallNode } from '@pascal-app/core'
/**
* Default sill height (metres from the floor to the BOTTOM of a window) for a
* fresh window that has no wall-face height yet — the off-wall ghost and the
* floor-cursor placement use it so a new window floats slightly above the
* ground rather than sitting on it. The committed Y is the window's CENTRE, so
* callers add `height / 2`. An existing window keeps its own sill.
*/
export const DEFAULT_WINDOW_SILL_M = 0.5
/**
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
@@ -54,64 +55,8 @@ export function clampToWall(
}
/**
* Directly checks the wall's children for bounding-box overlap with a proposed window.
* Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center).
* The spatial grid only tracks `item` nodes, so windows must be checked this way.
* Reads the wall's latest children from the store (not the event node) to avoid stale data.
* Wall-child overlap is shared by door + window placement (one source of
* truth in `shared/wall-attach-target.ts`). Re-exported here so existing
* `./window-math` importers don't change.
*/
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 // Block if wall not found
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, childRight: number, childBottom: number, 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 WindowNode
childLeft = win.position[0] - win.width / 2
childRight = win.position[0] + win.width / 2
childBottom = win.position[1] - win.height / 2 // windows store center Y
childTop = win.position[1] + win.height / 2
} else if (child.type === 'door') {
const door = child as DoorNode
childLeft = door.position[0] - door.width / 2
childRight = door.position[0] + door.width / 2
childBottom = door.position[1] - door.height / 2 // doors store center 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
}
export { hasWallChildOverlap } from '../shared/wall-attach-target'
+1 -1
View File
@@ -12,7 +12,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
| [node-definitions](node-definitions.md) | Three-checkbox composition model for registry-driven kinds (`geometry` / `renderer` / `system`) |
| [materials-and-themes](materials-and-themes.md) | Surface colour: surface roles, colour presets, the textures axis, and scene themes (appearance / ground / clay tints) |
| [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 |
| [tools](tools.md) | Editor tools structure, manipulation constraints, and Shift bypass defaults |
| [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and Shift bypass defaults |
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
| [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` |
+12
View File
@@ -99,6 +99,18 @@ export function MyTool() {
3. Add the tool identifier to the `useEditor` tool union type.
4. If the tool requires new node types, add schema + renderer + system first.
## 2D ↔ 3D behavioral parity (default expectation)
The 2D floor-plan view and the 3D view are two presentations of the **same** edit. Whenever a behavior is applicable to both, it must exist in both — the *mechanism* may differ (a 3D raycast hover vs a plan-space nearest-wall query; a real mesh ghost vs an SVG symbol), but the *felt behavior* should match. When you add or change an interaction in one view, port it to the other in the same change, or write down why it genuinely doesn't apply.
Concretely, door/window placement/move keeps these in lockstep across `{door,window}/move-tool.tsx` (3D) and `{door,window}/floorplan-move.ts` (2D):
- **Snap target**: nearest wall to the true cursor (shared `findClosestWallInPlan` / wall raycast), free-follow off-wall, commit only on a host.
- **Move SFX**: a soft `sfx:grid-snap` click per grid step while sliding (free-follow plan XZ or on-wall along-X, quantized + deduped so it isn't a machine-gun) and a soft `sfx:item-pick` cue on the floor→wall snap. Both tools carry an identical `tickGridStep` / `tickWallSnap` pair — keep them in sync.
- **R-flip** facing mid-placement, **Shift** to free snap/alignment (guides stay visible) and force-place over collisions, faithful ghost/symbol, deterministic single-undo commit.
Tells that you've broken parity: a sound/guide/snap that fires in 3D but is silent in 2D (or vice-versa), or a fix landed in one move file but not its sibling. The two move files are deliberately near-mirrors; diff them when in doubt.
## Move coexistence: 2D `FloorplanRegistryMoveOverlay` + legacy 3D mover
While a kind is mid-migration its move can run through two paths at once: the registry-driven 2D `FloorplanRegistryMoveOverlay` (`def.floorplanMoveTarget`) and the legacy 3D mover (e.g. `MoveItemContent`). Both react to `setMovingNode(node)`, both mount, both want to commit. Two pitfalls surfaced and have stable fixes; replicate the patterns when porting another kind to coexist.