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 node type → `node-schemas.md`, `renderers.md`, `systems.md`
- Adding a tool → `tools.md`, `spatial-queries.md`, `events.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` - Adding a system → `systems.md`, `scene-registry.md`
- Anything in `packages/viewer``viewer-isolation.md`, `layers.md` - Anything in `packages/viewer``viewer-isolation.md`, `layers.md`
- Anything touching selection → `selection-managers.md`, `scene-registry.md`, `events.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; // to be consumed. That legacy flow is gone in the registry layer;
// all entries use the action menu now. // all entries use the action menu now.
let hasMovedSinceStart = false 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) => { const onMove = (event: PointerEvent) => {
// Skip 3D-canvas / other-UI cursor moves so the overlay only // 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 // apply so the 2D symbol updates immediately; kinds without a facing
// leave `flipSide` unset and R falls through to the global handler. // 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 // Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D
// the pointer over the 2D pane (`hasMovedSinceStart`). The 3D move tool // move tool ALSO listens for R on `window`. We own R only while the
// also listens for R while a door/window `movingNode` is placed (split // cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the
// / 3D-only view); gating on `hasMovedSinceStart` keeps the two from // 2D mover has actually engaged (`hasMovedSinceStart`); then we
// both flipping (or double-playing the cue) on a single R press. // `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 (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 const t = event.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) { if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
return return
} }
event.preventDefault() event.preventDefault()
event.stopImmediatePropagation()
session.flipSide() session.flipSide()
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
return return
@@ -349,13 +363,18 @@ export function FloorplanRegistryMoveOverlay() {
setMovingNode(null) setMovingNode(null)
} }
window.addEventListener('pointermove', onPointerTrack)
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onPointerUp) 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 () => { return () => {
window.removeEventListener('pointermove', onPointerTrack)
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKey) window.removeEventListener('keydown', onKey, true)
// Unmount cleanup. `historyPaused === true` here means none of // Unmount cleanup. `historyPaused === true` here means none of
// our terminal paths (commit, Esc) ran in this overlay — they // our terminal paths (commit, Esc) ran in this overlay — they
// each call `resumeSceneHistory` and flip the flag. // each call `resumeSceneHistory` and flip the flag.
+5 -69
View File
@@ -1,12 +1,4 @@
import { import type { WallNode } from '@pascal-app/core'
type AnyNodeId,
type DoorNode,
getScaledDimensions,
type ItemNode,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
/** /**
* Keep the door handle at the same relative height when the door is resized: * Keep the door handle at the same relative height when the door is resized:
@@ -64,63 +56,7 @@ export function clampToWall(
return { clampedX, clampedY } return { clampedX, clampedY }
} }
/** // Wall-child overlap is shared by door + window placement (one source of
* Checks if a proposed door position overlaps any existing wall children. // truth in `shared/wall-attach-target.ts`). Re-exported here so existing
* Handles item, window, and door types. // `./door-math` importers don't change.
*/ export { hasWallChildOverlap } from '../shared/wall-attach-target'
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
}
+45 -7
View File
@@ -8,12 +8,13 @@ import {
type WallNode, type WallNode,
WallNode as WallNodeSchema, WallNode as WallNodeSchema,
} from '@pascal-app/core' } 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 { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host' import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
import { import {
findClosestWallInPlan, findClosestWallInPlan,
projectWallLocalPointToPlan, projectWallLocalPointToPlan,
resolveOpeningPlacement,
snapLocalXToNeighbors, snapLocalXToNeighbors,
} from '../shared/wall-attach-target' } from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './door-math' 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 // 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. // needs a wall. Starts true so a click before any move keeps the door put.
let onWall = true 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 // 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 // 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]], end: [planPoint[0] + half, planPoint[1]],
thickness: 0.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 = { const ghost = {
...node, ...node,
side: ghostSide,
parentId: wall.id, parentId: wall.id,
wallId: wall.id, wallId: wall.id,
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined, roofFace: undefined,
position: [half, node.position[1], 0] as [number, number, number], 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, visible: true,
} as DoorNode } as DoorNode
usePlacementPreview.getState().set(ghost, wall) usePlacementPreview.getState().set(ghost, wall)
@@ -125,6 +154,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
}, },
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
lastApply = { planPoint, modifiers } lastApply = { planPoint, modifiers }
forcePlace = modifiers.shiftKey === true
// Drop any stale live transform left by the 3D `MoveDoorTool` (it // Drop any stale live transform left by the 3D `MoveDoorTool` (it
// publishes one on every wall hover). The 2D registry layer renders // publishes one on every wall hover). The 2D registry layer renders
// door/window from `useLiveTransforms` IN PREFERENCE to the scene node, // 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 resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) { 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) freeFollow(resolvedPlanPoint)
return return
} }
@@ -168,6 +200,11 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)) const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height) 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. // Apply the R-flip on top of the wall-derived side.
const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side
const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0) const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0)
@@ -204,9 +241,10 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
if (!onWall) return false if (!onWall) return false
const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined
if (!live || live.type !== 'door') return false if (!live || live.type !== 'door') return false
// Block commit if the door overlaps any other wall child at its // Block commit if the door overlaps another wall child — UNLESS Shift
// current position. The 3D port has the same guard. // force-places (same `placeable` rule as the 3D move + the shared
const overlapping = hasWallChildOverlap( // `resolveOpeningPlacement`).
const collides = hasWallChildOverlap(
live.parentId as string, live.parentId as string,
live.position[0], live.position[0],
live.position[1], live.position[1],
@@ -214,7 +252,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
live.height, live.height,
live.id, live.id,
) )
return !overlapping return resolveOpeningPlacement({ collides, forcePlace }).placeable
}, },
commit() { commit() {
// Own the atomic write so the overlay takes the deterministic // Own the atomic write so the overlay takes the deterministic
+226 -34
View File
@@ -14,7 +14,6 @@ import {
type WallEvent, type WallEvent,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
calculateCursorRotation,
calculateItemRotation, calculateItemRotation,
consumePlacementDragRelease, consumePlacementDragRelease,
EDITOR_LAYER, EDITOR_LAYER,
@@ -26,7 +25,7 @@ import {
useEditor, useEditor,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' 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 { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import { import {
@@ -38,8 +37,10 @@ import {
type RoofWallOpeningTarget, type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget, resolveRoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement' } from '../shared/roof-wall-opening-placement'
import { resolveOpeningPlacement } from '../shared/wall-attach-target'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
import DoorPreview from './preview'
const edgeMaterial = new LineBasicNodeMaterial({ const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef_44_44, color: 0xef_44_44,
@@ -51,6 +52,40 @@ const edgeMaterial = new LineBasicNodeMaterial({
const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
const cursorGroupRef = useRef<Group>(null!) 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(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
}, []) }, [])
@@ -76,6 +111,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: movingDoorNode.roofSegmentId, roofSegmentId: movingDoorNode.roofSegmentId,
roofFace: movingDoorNode.roofFace, roofFace: movingDoorNode.roofFace,
metadata: movingDoorNode.metadata, 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) { 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. // pointermove (shared DOM timeStamp) — that's the only thing that snaps.
let freeFollowing = false let freeFollowing = false
let lastMeshEventTime = -1 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, // 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 // same as the committed-selected R flip) so the user can reorient before
// committing. Initialised from the moving node's side. // committing. Initialised from the moving node's side.
@@ -104,7 +172,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: string wallId: string
side: DoorNode['side'] side: DoorNode['side']
itemRotation: number itemRotation: number
cursorRotation: number
clampedX: number clampedX: number
clampedY: number clampedY: number
valid: boolean valid: boolean
@@ -143,6 +210,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
if (cursorGroupRef.current) cursorGroupRef.current.visible = false if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
clearOpeningGuides3D() clearOpeningGuides3D()
setGhostPose(null)
} }
// Alignment candidates — anchors of every OTHER alignable object (the // Alignment candidates — anchors of every OTHER alignable object (the
@@ -172,8 +240,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
return { return {
side, side,
itemRotation: calculateItemRotation(event.normal) + rotationOffset, 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 if (event.node.parentId !== getLevelId()) return
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) const { side, itemRotation } = getPlacementOrientation(event)
const rawLocalX = event.localPosition[0] const rawLocalX = event.localPosition[0]
if (!dragAnchor || dragAnchor.wallId !== event.node.id) { if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
@@ -201,8 +267,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
rawLocalX: targetLocalX, rawLocalX: targetLocalX,
width: movingDoorNode.width, width: movingDoorNode.width,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, // Alt still hard-disables alignment (no guides). Shift = free-place:
bypassSnap: event.nativeEvent?.shiftKey === true, // 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( const { clampedX, clampedY } = clampToWall(
event.node, event.node,
@@ -225,7 +293,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: event.node.id, wallId: event.node.id,
side, side,
itemRotation, itemRotation,
cursorRotation,
clampedX, clampedX,
clampedY, clampedY,
valid, valid,
@@ -234,6 +301,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
} }
const applyPreview = (target: NonNullable<typeof lastTarget>) => { 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) { if (currentHostId !== target.wallId) {
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: [target.clampedX, target.clampedY, 0], position: [target.clampedX, target.clampedY, 0],
@@ -243,6 +320,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: target.wallId, wallId: target.wallId,
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined, roofFace: undefined,
visible: false,
}) })
markHostDirty(currentHostId) markHostDirty(currentHostId)
currentHostId = target.wallId currentHostId = target.wallId
@@ -260,17 +338,37 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}) })
markHostDirtyThrottled(target.wallId) markHostDirtyThrottled(target.wallId)
updateCursor( // Position the tinted ghost at the wall opening (world frame), facing the
wallLocalToWorld( // 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.wallNode,
target.clampedX, target.clampedX,
target.clampedY, target.clampedY,
getLevelYOffset(), getLevelYOffset(),
getSlabElevation(target.event), getSlabElevation(target.event),
), ),
target.cursorRotation, rotationY: target.itemRotation - wallAngle,
target.valid, tint: placement.tint,
) side: target.side,
})
publishOpeningGuidesForWallEvent({ publishOpeningGuidesForWallEvent({
wall: target.wallNode, wall: target.wallNode,
@@ -351,6 +449,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: target.wallId, parentId: target.wallId,
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: 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) useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id placedId = node.id
@@ -364,6 +465,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace, roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
visible: original.visible,
}) })
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
@@ -375,6 +477,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: target.wallId, wallId: target.wallId,
roofSegmentId: undefined, roofSegmentId: undefined,
metadata: {}, metadata: {},
visible: true,
}) })
if (original.parentId && original.parentId !== target.wallId) { 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 if (event.node.parentId !== getLevelId()) return
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) 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) commitToWall(target)
event.stopPropagation() event.stopPropagation()
} }
@@ -420,13 +527,29 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
lastRoofEvent = null lastRoofEvent = null
} }
// Free-follow: the door rides the cursor over empty floor, parented to the // Reveal the real door node + drop the ghost. Used by the roof-face path,
// level like an item node (lifted so it stands on the floor). No wall to // which previews with the real mesh (the ghost-tint flow is wall-specific).
// attach to, so it is not committable here. const revealRealNode = () => {
const freeFollowAt = (localX: number, localZ: number) => { 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 freeFollowing = true
lastTarget = null lastTarget = null
lastRoofEvent = 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() hideCursor()
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
const levelId = getLevelId() const levelId = getLevelId()
@@ -445,6 +568,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: undefined, wallId: undefined,
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined, roofFace: undefined,
visible: false,
}) })
currentHostId = levelId currentHostId = levelId
} else { } else {
@@ -452,8 +576,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
position: [localX, y, localZ], position: [localX, y, localZ],
rotation: [0, yaw, 0], rotation: [0, yaw, 0],
side: sideOverride, 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) => { 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 // so snapping engages only when the cursor ray actually hovers a wall
// (`onWallMove`). Over open floor the door just follows the cursor. // (`onWallMove`). Over open floor the door just follows the cursor.
const [x, , z] = event.localPosition const [x, , z] = event.localPosition
freeFollowAt(x, z) lastFloorPoint = [x, z]
freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1)
} }
// ── Roof-segment wall faces ───────────────────────────────────── // ── Roof-segment wall faces ─────────────────────────────────────
@@ -505,6 +640,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
// Opening guides are wall-specific; clear them when over a roof face. // Opening guides are wall-specific; clear them when over a roof face.
clearOpeningGuides3D() 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) { if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: target.position, position: target.position,
@@ -514,6 +652,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: undefined, wallId: undefined,
roofSegmentId: target.segment.id, roofSegmentId: target.segment.id,
roofFace: target.face.id, roofFace: target.face.id,
visible: true,
}) })
markHostDirty(currentHostId) markHostDirty(currentHostId)
currentHostId = target.segment.id currentHostId = target.segment.id
@@ -531,7 +670,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (committed) return if (committed) return
const target = resolveRoofMoveTarget(event) 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 committed = true
const segmentId = target.segment.id const segmentId = target.segment.id
@@ -553,6 +694,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: segmentId, roofSegmentId: segmentId,
roofFace: target.face.id, roofFace: target.face.id,
parentId: segmentId, parentId: segmentId,
visible: true,
}) })
useScene.getState().createNode(node, segmentId as AnyNodeId) useScene.getState().createNode(node, segmentId as AnyNodeId)
placedId = node.id placedId = node.id
@@ -566,6 +708,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace, roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
visible: original.visible,
}) })
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
@@ -578,6 +721,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: segmentId, roofSegmentId: segmentId,
roofFace: target.face.id, roofFace: target.face.id,
metadata: {}, metadata: {},
visible: true,
}) })
if (original.parentId && original.parentId !== segmentId) { if (original.parentId && original.parentId !== segmentId) {
@@ -622,6 +766,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace, roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
visible: original.visible,
}) })
if (original.parentId) markHostDirty(original.parentId) if (original.parentId) markHostDirty(original.parentId)
} }
@@ -633,8 +778,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const onPlacementDragPointerUp = (event: PointerEvent) => { const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return if (!consumePlacementDragRelease(event)) return
// Free-following over open floor can't commit (no wall). A wall hover // Free-following over open floor can't commit (no wall). A wall hover
// target commits via commitToWall; a roof face via onRoofClick. // target commits via commitToWall; a roof face via onRoofClick. Shift
if (lastTarget?.valid && !freeFollowing) { // 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) commitToWall(lastTarget)
return return
} }
@@ -655,23 +802,30 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
) { ) {
return return
} }
// Only act where a flip is meaningful — on a wall hover or while // Ignore OS key-repeat so a held R doesn't flip many times per press.
// free-following. On a roof face leave it to the default R (no flip, no if (e.repeat) return
// sfx) so the cue never fires without a visible effect.
const onWall = lastTarget !== null
if (!(onWall || freeFollowing)) return
e.preventDefault() 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' sideOverride = sideOverride === 'front' ? 'back' : 'front'
triggerSFX('sfx:item-rotate') triggerSFX('sfx:item-rotate')
if (onWall) { if (lastTarget) {
// On a wall: re-resolve with the flipped side and re-preview. // On a wall: re-resolve with the flipped side and re-preview.
const next = resolveMoveTarget(lastTarget!.event) const next = resolveMoveTarget(lastTarget.event)
if (next) { if (next) {
lastTarget = next lastTarget = next
applyPreview(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 { } 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, { useScene.getState().updateNode(movingDoorNode.id, {
side: sideOverride, side: sideOverride,
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0], 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:enter', onWallEnter)
emitter.on('wall:move', onWallMove) emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick) emitter.on('wall:click', onWallClick)
@@ -691,6 +857,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp) window.addEventListener('pointerup', onPlacementDragPointerUp)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('keydown', onShiftToggle)
window.addEventListener('keyup', onShiftToggle)
return () => { return () => {
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as 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, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace, roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
visible: original.visible,
}) })
if (original.parentId) markHostDirty(original.parentId) 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) useLiveTransforms.getState().clear(movingDoorNode.id)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
@@ -731,6 +907,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp) window.removeEventListener('pointerup', onPlacementDragPointerUp)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keydown', onShiftToggle)
window.removeEventListener('keyup', onShiftToggle)
} }
}, [movingDoorNode, exitMoveMode]) }, [movingDoorNode, exitMoveMode])
@@ -747,9 +925,23 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return ( return (
<group ref={cursorGroupRef} visible={false}> <>
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} /> <group ref={cursorGroupRef} visible={false}>
</group> <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 * 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). * 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 mesh = useMemo(() => {
const m = buildDoorPreviewMesh(node) const m = buildDoorPreviewMesh(node)
m.layers.set(EDITOR_LAYER) m.layers.set(EDITOR_LAYER)
return m return m
}, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount]) }, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount])
// Ghost treatment (clone + tint + raycast-off) re-applies if `invalid` // Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips;
// flips; its cleanup only disposes the clones it made. // its cleanup only disposes the clones it made.
useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid]) useEffect(() => applyGhost(mesh, { invalid, valid }), [mesh, invalid, valid])
// Geometry is freshly built per `mesh` and owned here — dispose it only // Geometry is freshly built per `mesh` and owned here — dispose it only
// when the mesh itself is replaced/unmounted, never on an `invalid` toggle. // 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 // Off-host floating ghost: the real door geometry follows the cursor over
// the grid (tinted invalid). Mutually exclusive with the on-host draft — // 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<{ const [fallbackPose, setFallbackPose] = useState<{
position: [number, number, number] position: [number, number, number]
rotationY: number rotationY: number
side: DoorNode['side']
} | null>(null) } | 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(() => { useEffect(() => {
useScene.temporal.getState().pause() 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. // re-applied to the last wall hover so the flip shows live before commit.
let sideFlip = false let sideFlip = false
let lastWallEvent: WallEvent | null = null 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 getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => { const getLevelYOffset = () => {
@@ -149,9 +164,15 @@ const DoorTool: React.FC = () => {
// Off-host fallback: hide the wireframe outline and float the real door // Off-host fallback: hide the wireframe outline and float the real door
// geometry (tinted invalid) at the cursor so the armed tool is visible. // 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]) => { const showGhostAt = (position: [number, number, number]) => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false 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() useAlignmentGuides.getState().clear()
clearOpeningGuides3D() clearOpeningGuides3D()
} }
@@ -176,13 +197,16 @@ const DoorTool: React.FC = () => {
bypassSnap: boolean, bypassSnap: boolean,
ignoreId?: string, 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({ const localX = resolveWallSlideAlignment({
wallNode: wall, wallNode: wall,
rawLocalX, rawLocalX,
width, width,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypass, bypass: bypass && !bypassSnap,
bypassSnap, freePlace: bypassSnap,
}) })
const { clampedX, clampedY } = clampToWall(wall, localX, width, height) const { clampedX, clampedY } = clampToWall(wall, localX, width, height)
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
@@ -403,7 +427,8 @@ const DoorTool: React.FC = () => {
bypassSnap, bypassSnap,
draftRef.current.id, 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) commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation)
event.stopPropagation() event.stopPropagation()
@@ -433,8 +458,9 @@ const DoorTool: React.FC = () => {
hostKind = null hostKind = null
lastWallEvent = null lastWallEvent = null
const [x, y, z] = event.localPosition const [x, y, z] = event.localPosition
lastFloorPoint = [x, y + FALLBACK_HEIGHT / 2, z]
destroyDraft() destroyDraft()
showGhostAt([x, y + FALLBACK_HEIGHT / 2, z]) showGhostAt(lastFloorPoint)
} }
// ── Roof-segment wall faces ───────────────────────────────────── // ── Roof-segment wall faces ─────────────────────────────────────
@@ -497,7 +523,9 @@ const DoorTool: React.FC = () => {
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (!draftRef.current?.roofSegmentId) return if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event) 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 { segment, face, position } = target
const draft = draftRef.current 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 // 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 // committed-selected R flip. ALWAYS toggles the persistent flip intent —
// off-wall ghost has no orientation), so it acts only then — re-applying // never a no-op (the old `!lastWallEvent` guard dropped R off-wall and before
// the last wall hover so the snapped preview flips live. // 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) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'r' && e.key !== 'R') return if (e.key !== 'r' && e.key !== 'R') return
if (!lastWallEvent) return if (e.repeat) return
const t = e.target as HTMLElement | null const t = e.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
e.preventDefault() e.preventDefault()
sideFlip = !sideFlip sideFlip = !sideFlip
triggerSFX('sfx:item-rotate') 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) emitter.on('wall:enter', onWallHover)
+18 -4
View File
@@ -3,6 +3,7 @@
import type { Material, Mesh, Object3D, Raycaster } from 'three' import type { Material, Mesh, Object3D, Raycaster } from 'three'
export const INVALID_GHOST_COLOR = 0xef_44_44 export const INVALID_GHOST_COLOR = 0xef_44_44
export const VALID_GHOST_COLOR = 0x22_c5_5e
const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {} 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 * Traverses the object tree, disables raycasting on all descendants (prevents
* cursor-ray starvation), and clones visible mesh materials to set translucency. * 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. * Tint by state:
* Otherwise sets opacity ~0.5 while preserving the original color. * - `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 * Skips: meshes whose material.visible === false (door/window root hitbox) and
* children named 'cutout'. * 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). * Returns cleanup that disposes only the cloned materials (never originals or geometry).
* *
* @param root - The preview mesh tree (typically from buildDoorPreviewMesh / buildWindowPreviewMesh) * @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 * @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 invalid = opts?.invalid ?? false
const valid = !invalid && (opts?.valid ?? false)
const cloned: Material[] = [] const cloned: Material[] = []
root.traverse((obj) => { root.traverse((obj) => {
@@ -51,6 +59,12 @@ export function applyGhost(root: Object3D, opts?: { invalid?: boolean }): () =>
INVALID_GHOST_COLOR, INVALID_GHOST_COLOR,
) )
clone.opacity = 0.4 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 { } else {
clone.opacity = 0.5 clone.opacity = 0.5
} }
@@ -5,6 +5,7 @@ import {
getScaledDimensions, getScaledDimensions,
type ItemNode, type ItemNode,
nearestWallSegment, nearestWallSegment,
useScene,
WALL_SNAP_DISTANCE_M, WALL_SNAP_DISTANCE_M,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -187,3 +188,104 @@ export function snapLocalXToNeighbors(args: {
return bestDelta === null ? null : localX + bestDelta 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 * guide on bypass / no-match. Returns the localX to use (X-clamped to the wall
* given `width`). `bypass` disables alignment; `bypassSnap` also skips the * given `width`). `bypass` disables alignment; `bypassSnap` also skips the
* half-metre fallback. * 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: { export function resolveWallSlideAlignment(args: {
wallNode: WallNode wallNode: WallNode
@@ -31,9 +37,55 @@ export function resolveWallSlideAlignment(args: {
candidates: readonly AlignmentAnchor[] candidates: readonly AlignmentAnchor[]
bypass: boolean bypass: boolean
bypassSnap?: boolean bypassSnap?: boolean
freePlace?: boolean
}): number { }): number {
const { wallNode, rawLocalX, width, candidates, bypass, bypassSnap = false } = args const {
const base = bypassSnap ? rawLocalX : snapToHalf(rawLocalX) 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) { if (bypass || candidates.length === 0) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
return base 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, WallNode as WallNodeSchema,
type WindowNode, type WindowNode,
} from '@pascal-app/core' } 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 { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host' import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
import { import {
findClosestWallInPlan, findClosestWallInPlan,
projectWallLocalPointToPlan, projectWallLocalPointToPlan,
resolveOpeningPlacement,
snapLocalXToNeighbors, snapLocalXToNeighbors,
} from '../shared/wall-attach-target' } 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 * 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 // 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 // below ground); default those to a realistic sill so it floats above
// the floor in 2D too. Same rule as the 3D `MoveWindowTool` (`getSillCenterY`). // the floor in 2D too. Same rule as the 3D `MoveWindowTool` (`getSillCenterY`).
const DEFAULT_SILL = 0.9 const startLocalY =
const startLocalY = node.position[1] > 0.1 ? node.position[1] : DEFAULT_SILL + node.height / 2 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 // Track the last successful placement so `commit()` can write it
// atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`. // 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 // See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor
// as a ghost and isn't committable (it needs a wall). Starts true. // as a ghost and isn't committable (it needs a wall). Starts true.
let onWall = 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]) => { const freeFollow = (planPoint: readonly [number, number]) => {
onWall = false onWall = false
@@ -95,14 +113,22 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
end: [planPoint[0] + half, planPoint[1]], end: [planPoint[0] + half, planPoint[1]],
thickness: 0.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 = { const ghost = {
...node, ...node,
side: ghostSide,
parentId: wall.id, parentId: wall.id,
wallId: wall.id, wallId: wall.id,
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined, roofFace: undefined,
position: [half, startLocalY, 0] as [number, number, number], 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, visible: true,
} as WindowNode } as WindowNode
usePlacementPreview.getState().set(ghost, wall) usePlacementPreview.getState().set(ghost, wall)
@@ -116,6 +142,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
}, },
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
lastApply = { planPoint, modifiers } lastApply = { planPoint, modifiers }
forcePlace = modifiers.shiftKey === true
// Drop any stale live transform left by the 3D `MoveWindowTool` — see // Drop any stale live transform left by the 3D `MoveWindowTool` — see
// `doorFloorplanMoveTarget.apply`. Without this the 2D registry layer // `doorFloorplanMoveTarget.apply`. Without this the 2D registry layer
// keeps rendering the window at the 3D tool's last hover (it prefers // 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 resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) { if (!hit) {
// Off any wall — free-follow. Click per grid cell over open floor.
tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1])
freeFollow(resolvedPlanPoint) freeFollow(resolvedPlanPoint)
return return
} }
@@ -160,6 +189,11 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
node.height, 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 const side: WindowNode['side'] = flipped
? hit.side === 'front' ? hit.side === 'front'
? 'back' ? 'back'
@@ -192,7 +226,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
if (!onWall) return false if (!onWall) return false
const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined
if (!live || live.type !== 'window') return false 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.parentId as string,
live.position[0], live.position[0],
live.position[1], live.position[1],
@@ -200,7 +236,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
live.height, live.height,
live.id, live.id,
) )
return !overlapping return resolveOpeningPlacement({ collides, forcePlace }).placeable
}, },
commit() { commit() {
// Own the atomic write so the overlay takes the deterministic // Own the atomic write so the overlay takes the deterministic
+217 -34
View File
@@ -14,7 +14,6 @@ import {
WindowNode, WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
calculateCursorRotation,
calculateItemRotation, calculateItemRotation,
consumePlacementDragRelease, consumePlacementDragRelease,
EDITOR_LAYER, EDITOR_LAYER,
@@ -27,7 +26,7 @@ import {
useEditor, useEditor,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' 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 { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import { import {
@@ -40,8 +39,16 @@ import {
type RoofWallOpeningTarget, type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget, resolveRoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement' } from '../shared/roof-wall-opening-placement'
import { resolveOpeningPlacement } from '../shared/wall-attach-target'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' 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({ const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef_44_44, color: 0xef_44_44,
@@ -65,6 +72,38 @@ const edgeMaterial = new LineBasicNodeMaterial({
const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => { const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {
const cursorGroupRef = useRef<Group>(null!) 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(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
}, []) }, [])
@@ -91,6 +130,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: movingWindowNode.roofSegmentId, roofSegmentId: movingWindowNode.roofSegmentId,
roofFace: movingWindowNode.roofFace, roofFace: movingWindowNode.roofFace,
metadata: movingWindowNode.metadata, 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 // 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. // mesh event owns the same pointermove — that's the only thing that snaps.
let freeFollowing = false let freeFollowing = false
let lastMeshEventTime = -1 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), // The window's chosen facing side. R flips it mid-placement (front ↔ back),
// matching the committed-selected R flip. Initialised from the moving node. // matching the committed-selected R flip. Initialised from the moving node.
let sideOverride: WindowNode['side'] = movingWindowNode.side let sideOverride: WindowNode['side'] = movingWindowNode.side
@@ -128,7 +192,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: string wallId: string
side: WindowNode['side'] side: WindowNode['side']
itemRotation: number itemRotation: number
cursorRotation: number
clampedX: number clampedX: number
clampedY: number clampedY: number
valid: boolean 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 // 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 // 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 // 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 // small sill so the ghost floats slightly above the ground. An existing
// keeps its own sill. // window keeps its own sill.
const DEFAULT_SILL = 0.9
const getSillCenterY = () => { const getSillCenterY = () => {
const y = movingWindowNode.position[1] 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) => const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall( spatialGridManager.getSlabElevationForWall(
@@ -178,6 +240,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
if (cursorGroupRef.current) cursorGroupRef.current.visible = false if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
clearOpeningGuides3D() clearOpeningGuides3D()
setGhostPose(null)
} }
// Alignment candidates — anchors of every OTHER alignable object (the // 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 side = sideOverride ?? faceSide
const rotationOffset = side !== faceSide ? Math.PI : 0 const rotationOffset = side !== faceSide ? Math.PI : 0
const itemRotation = calculateItemRotation(event.normal) + rotationOffset const itemRotation = calculateItemRotation(event.normal) + rotationOffset
const cursorRotation =
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset
const rawLocalX = event.localPosition[0] const rawLocalX = event.localPosition[0]
const rawLocalY = event.localPosition[1] const rawLocalY = event.localPosition[1]
@@ -256,8 +317,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
rawLocalX: targetLocalX, rawLocalX: targetLocalX,
width: movingWindowNode.width, width: movingWindowNode.width,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, // Alt still hard-disables alignment (no guides). Shift = free-place:
bypassSnap: event.nativeEvent?.shiftKey === true, // 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( const { clampedX, clampedY } = clampToWall(
event.node, event.node,
@@ -281,7 +344,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: event.node.id, wallId: event.node.id,
side, side,
itemRotation, itemRotation,
cursorRotation,
clampedX, clampedX,
clampedY, clampedY,
valid, valid,
@@ -290,6 +352,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
} }
const applyPreview = (target: NonNullable<typeof lastTarget>) => { 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) { if (currentHostId !== target.wallId) {
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: [target.clampedX, target.clampedY, 0], position: [target.clampedX, target.clampedY, 0],
@@ -299,6 +369,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: target.wallId, wallId: target.wallId,
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined, roofFace: undefined,
visible: false,
}) })
markHostDirty(currentHostId) markHostDirty(currentHostId)
currentHostId = target.wallId currentHostId = target.wallId
@@ -316,17 +387,28 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}) })
markHostDirtyThrottled(target.wallId) markHostDirtyThrottled(target.wallId)
updateCursor( if (cursorGroupRef.current) cursorGroupRef.current.visible = false
wallLocalToWorld( 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.wallNode,
target.clampedX, target.clampedX,
target.clampedY, target.clampedY,
getLevelYOffset(), getLevelYOffset(),
getSlabElevation(target.event), getSlabElevation(target.event),
), ),
target.cursorRotation, rotationY: target.itemRotation - wallAngle,
target.valid, tint: placement.tint,
) floorY: getLevelYOffset() + getSlabElevation(target.event),
side: target.side,
})
publishOpeningGuidesForWallEvent({ publishOpeningGuidesForWallEvent({
wall: target.wallNode, wall: target.wallNode,
@@ -410,6 +492,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: target.wallId, parentId: target.wallId,
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined, roofFace: undefined,
// Hidden during free-follow; the committed window must be visible.
visible: true,
}) })
useScene.getState().createNode(node, target.wallId as AnyNodeId) useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id placedId = node.id
@@ -425,6 +509,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace, roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
visible: original.visible,
}) })
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
@@ -436,6 +521,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: target.wallId, wallId: target.wallId,
roofSegmentId: undefined, roofSegmentId: undefined,
metadata: {}, metadata: {},
visible: true,
}) })
if (original.parentId && original.parentId !== target.wallId) { 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 if (event.node.parentId !== getLevelId()) return
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) 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) commitToWall(target)
event.stopPropagation() event.stopPropagation()
} }
@@ -480,13 +570,28 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
lastRoofEvent = null lastRoofEvent = null
} }
// Free-follow: the window rides the cursor over empty floor, parented to // Reveal the real window node + drop the ghost. Used by the roof-face path,
// the level like an item node, kept at a sensible sill height. No wall to // which previews with the real mesh (the ghost-tint flow is wall-specific).
// attach to, so it is not committable here. const revealRealNode = () => {
const freeFollowAt = (localX: number, localZ: number) => { 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 freeFollowing = true
lastTarget = null lastTarget = null
lastRoofEvent = 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() hideCursor()
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
const levelId = getLevelId() const levelId = getLevelId()
@@ -503,6 +608,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: undefined, wallId: undefined,
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined, roofFace: undefined,
visible: false,
}) })
currentHostId = levelId currentHostId = levelId
} else { } else {
@@ -510,8 +616,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
position: [localX, sillCenterY, localZ], position: [localX, sillCenterY, localZ],
rotation: [0, yaw, 0], rotation: [0, yaw, 0],
side: sideOverride, 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) => { 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. // snapping engages only when the cursor ray actually hovers a wall.
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
const [x, , z] = event.localPosition const [x, , z] = event.localPosition
freeFollowAt(x, z) lastFloorPoint = [x, z]
freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1)
} }
// ── Roof-segment wall faces ───────────────────────────────────── // ── Roof-segment wall faces ─────────────────────────────────────
@@ -564,6 +681,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
// Opening guides are wall-specific; clear them when over a roof face. // Opening guides are wall-specific; clear them when over a roof face.
clearOpeningGuides3D() clearOpeningGuides3D()
// On a roof face the real mesh is the preview — drop the ghost + reveal.
revealRealNode()
if (currentHostId !== target.segment.id) { if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: target.position, position: target.position,
@@ -573,6 +692,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: undefined, wallId: undefined,
roofSegmentId: target.segment.id, roofSegmentId: target.segment.id,
roofFace: target.face.id, roofFace: target.face.id,
visible: true,
}) })
markHostDirty(currentHostId) markHostDirty(currentHostId)
currentHostId = target.segment.id currentHostId = target.segment.id
@@ -590,7 +710,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (committed) return if (committed) return
const target = resolveRoofMoveTarget(event) 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 committed = true
const segmentId = target.segment.id const segmentId = target.segment.id
@@ -613,6 +735,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: segmentId, roofSegmentId: segmentId,
roofFace: target.face.id, roofFace: target.face.id,
parentId: segmentId, parentId: segmentId,
visible: true,
}) })
useScene.getState().createNode(node, segmentId as AnyNodeId) useScene.getState().createNode(node, segmentId as AnyNodeId)
placedId = node.id placedId = node.id
@@ -626,6 +749,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace, roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
visible: original.visible,
}) })
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
@@ -638,6 +762,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: segmentId, roofSegmentId: segmentId,
roofFace: target.face.id, roofFace: target.face.id,
metadata: {}, metadata: {},
visible: true,
}) })
if (original.parentId && original.parentId !== segmentId) { if (original.parentId && original.parentId !== segmentId) {
@@ -682,6 +807,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace, roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
visible: original.visible,
}) })
if (original.parentId) markHostDirty(original.parentId) if (original.parentId) markHostDirty(original.parentId)
} }
@@ -693,8 +819,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const onPlacementDragPointerUp = (event: PointerEvent) => { const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return if (!consumePlacementDragRelease(event)) return
// Free-following over open floor can't commit (no wall). A wall hover // Free-following over open floor can't commit (no wall). A wall hover
// target commits via commitToWall; a roof face via onRoofClick. // target commits via commitToWall; a roof face via onRoofClick. Shift
if (lastTarget?.valid && !freeFollowing) { // 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) commitToWall(lastTarget)
return return
} }
@@ -714,18 +842,27 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
) { ) {
return return
} }
const onWall = lastTarget !== null // Ignore OS key-repeat so a held R doesn't flip many times per press.
if (!(onWall || freeFollowing)) return if (e.repeat) return
e.preventDefault() 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' sideOverride = sideOverride === 'front' ? 'back' : 'front'
triggerSFX('sfx:item-rotate') triggerSFX('sfx:item-rotate')
if (onWall) { if (lastTarget) {
const next = resolveMoveTarget(lastTarget!.event) const next = resolveMoveTarget(lastTarget.event)
if (next) { if (next) {
lastTarget = next lastTarget = next
applyPreview(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 { } 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, { useScene.getState().updateNode(movingWindowNode.id, {
side: sideOverride, side: sideOverride,
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0], 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:enter', onWallEnter)
emitter.on('wall:move', onWallMove) emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick) emitter.on('wall:click', onWallClick)
@@ -745,6 +892,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp) window.addEventListener('pointerup', onPlacementDragPointerUp)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('keydown', onShiftToggle)
window.addEventListener('keyup', onShiftToggle)
return () => { return () => {
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move) // 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, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace, roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
visible: original.visible,
}) })
if (original.parentId) markHostDirty(original.parentId) 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) useLiveTransforms.getState().clear(movingWindowNode.id)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
@@ -786,6 +941,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp) window.removeEventListener('pointerup', onPlacementDragPointerUp)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keydown', onShiftToggle)
window.removeEventListener('keyup', onShiftToggle)
} }
}, [movingWindowNode, exitMoveMode]) }, [movingWindowNode, exitMoveMode])
@@ -802,9 +959,35 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return ( return (
<group ref={cursorGroupRef} visible={false}> <>
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} /> <group ref={cursorGroupRef} visible={false}>
</group> <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 * 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). * 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 mesh = useMemo(() => {
const m = buildWindowPreviewMesh(node) const m = buildWindowPreviewMesh(node)
m.layers.set(EDITOR_LAYER) m.layers.set(EDITOR_LAYER)
@@ -32,9 +40,9 @@ const WindowPreview = ({ node, invalid }: { node: WindowNode; invalid?: boolean
node.sillThickness, node.sillThickness,
]) ])
// Ghost treatment (clone + tint + raycast-off) re-applies if `invalid` // Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips;
// flips; its cleanup only disposes the clones it made. // its cleanup only disposes the clones it made.
useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid]) useEffect(() => applyGhost(mesh, { invalid, valid }), [mesh, invalid, valid])
// Geometry is freshly built per `mesh` and owned here — dispose it only // Geometry is freshly built per `mesh` and owned here — dispose it only
// when the mesh itself is replaced/unmounted, never on an `invalid` toggle. // when the mesh itself is replaced/unmounted, never on an `invalid` toggle.
+78 -21
View File
@@ -39,8 +39,14 @@ import {
worldToSelectedBuildingLocal, worldToSelectedBuildingLocal,
} from '../shared/roof-wall-opening-placement' } from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { WindowFloorProjection } from './floor-projection'
import WindowPreview from './preview' 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 // Shared edge material — reuse across renders, just toggle color
const edgeMaterial = new LineBasicNodeMaterial({ const edgeMaterial = new LineBasicNodeMaterial({
@@ -52,10 +58,12 @@ const edgeMaterial = new LineBasicNodeMaterial({
const FALLBACK_WIDTH = 1.5 const FALLBACK_WIDTH = 1.5
const FALLBACK_HEIGHT = 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 // 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. // carries no wall-face height): the default sill + half the default height.
const DEFAULT_SILL_CENTER_Y = 0.9 + FALLBACK_HEIGHT / 2 const DEFAULT_SILL_CENTER_Y = DEFAULT_WINDOW_SILL_M + FALLBACK_HEIGHT / 2
const roofFallbackPoint = new Vector3() const roofFallbackPoint = new Vector3()
// What currently owns the cursor frame: a wall/roof mesh hover, or null when // 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 // Off-host floating ghost: the real window geometry follows the cursor
// over the grid (tinted invalid). Mutually exclusive with the on-host draft. // 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<{ const [fallbackPose, setFallbackPose] = useState<{
position: [number, number, number] position: [number, number, number]
rotationY: number rotationY: number
floorY: number
side: WindowNode['side']
} | null>(null) } | null>(null)
// Ghost preview node — zeroed transform + the live facing side (rebuilds on R).
const ghostStub = useMemo( 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(() => { useEffect(() => {
@@ -104,6 +122,9 @@ const WindowTool: React.FC = () => {
// to the last wall hover so the flip shows live before commit. // to the last wall hover so the flip shows live before commit.
let sideFlip = false let sideFlip = false
let lastWallEvent: WallEvent | null = null 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 getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => { const getLevelYOffset = () => {
@@ -157,21 +178,34 @@ const WindowTool: React.FC = () => {
// Off-host fallback: hide the wireframe outline and float the real window // Off-host fallback: hide the wireframe outline and float the real window
// geometry (tinted invalid) at the cursor so the armed tool is visible. // 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 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() useAlignmentGuides.getState().clear()
clearOpeningGuides3D() clearOpeningGuides3D()
} }
const showRoofFallbackCursor = (event: RoofEvent) => { const showRoofFallbackCursor = (event: RoofEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) 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 showWallFallbackCursor = (event: WallEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) 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 // Sill alignment (snap + guide): a sibling sill/centre/top wins over the
@@ -211,13 +245,16 @@ const WindowTool: React.FC = () => {
bypassSnap: boolean, bypassSnap: boolean,
ignoreId?: string, 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({ const localX = resolveWallSlideAlignment({
wallNode: wall, wallNode: wall,
rawLocalX, rawLocalX,
width, width,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypass, bypass: bypass && !bypassSnap,
bypassSnap, freePlace: bypassSnap,
}) })
const localY = resolvePlacementY({ const localY = resolvePlacementY({
wall, wall,
@@ -454,7 +491,8 @@ const WindowTool: React.FC = () => {
bypassSnap, bypassSnap,
draftRef.current.id, 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) commitWindowAtWall(event.node, clampedX, clampedY, side, itemRotation)
event.stopPropagation() event.stopPropagation()
@@ -485,7 +523,7 @@ const WindowTool: React.FC = () => {
lastWallEvent = null lastWallEvent = null
const [x, y, z] = event.localPosition const [x, y, z] = event.localPosition
destroyDraft() 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 ───────────────────────────────────── // ── Roof-segment wall faces ─────────────────────────────────────
@@ -553,7 +591,9 @@ const WindowTool: React.FC = () => {
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (!draftRef.current?.roofSegmentId) return if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event) 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 { segment, face, position } = target
const draft = draftRef.current const draft = draftRef.current
@@ -617,19 +657,24 @@ const WindowTool: React.FC = () => {
hostKind = null hostKind = null
} }
// R flips the window's facing side mid-placement (front ↔ back), like the // R flips the window's facing side mid-placement (front ↔ back). ALWAYS
// committed-selected R flip. Only meaningful while snapped to a wall (the // toggles the persistent flip intent — never a no-op (the old `!lastWallEvent`
// off-wall ghost has no orientation), so it acts only then re-applying // guard dropped R off-wall / before the first hover). Then re-renders the
// the last wall hover so the snapped preview flips live. // current preview so the flip shows live and matches commit.
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'r' && e.key !== 'R') return if (e.key !== 'r' && e.key !== 'R') return
if (!lastWallEvent) return if (e.repeat) return
const t = e.target as HTMLElement | null const t = e.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
e.preventDefault() e.preventDefault()
sideFlip = !sideFlip sideFlip = !sideFlip
triggerSFX('sfx:item-rotate') 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) emitter.on('wall:enter', onWallHover)
@@ -690,6 +735,18 @@ const WindowTool: React.FC = () => {
<WindowPreview invalid node={ghostStub} /> <WindowPreview invalid node={ghostStub} />
</group> </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 { import type { WallNode } from '@pascal-app/core'
type AnyNodeId,
type DoorNode, /**
getScaledDimensions, * Default sill height (metres from the floor to the BOTTOM of a window) for a
type ItemNode, * fresh window that has no wall-face height yet — the off-wall ghost and the
useScene, * floor-cursor placement use it so a new window floats slightly above the
type WallNode, * ground rather than sitting on it. The committed Y is the window's CENTRE, so
type WindowNode, * callers add `height / 2`. An existing window keeps its own sill.
} from '@pascal-app/core' */
export const DEFAULT_WINDOW_SILL_M = 0.5
/** /**
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ. * 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. * Wall-child overlap is shared by door + window placement (one source of
* Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center). * truth in `shared/wall-attach-target.ts`). Re-exported here so existing
* The spatial grid only tracks `item` nodes, so windows must be checked this way. * `./window-math` importers don't change.
* Reads the wall's latest children from the store (not the event node) to avoid stale data.
*/ */
export function hasWallChildOverlap( export { hasWallChildOverlap } from '../shared/wall-attach-target'
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
}
+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`) | | [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) | | [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 | | [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 | | [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner | | [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
| [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` | | [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. 3. Add the tool identifier to the `useEditor` tool union type.
4. If the tool requires new node types, add schema + renderer + system first. 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 ## 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. 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.