From e16dbc92a2daea1f05e8b21ebe1c3731790ff54c Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 16 Jun 2026 10:13:44 -0400 Subject: [PATCH] feat(editor): door/window placement-feel polish + placement-state refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- AGENTS.md | 1 + .../floorplan-registry-move-overlay.tsx | 35 ++- packages/nodes/src/door/door-math.ts | 74 +---- packages/nodes/src/door/floorplan-move.ts | 52 +++- packages/nodes/src/door/move-tool.tsx | 260 +++++++++++++++--- packages/nodes/src/door/preview.tsx | 16 +- packages/nodes/src/door/tool.tsx | 62 ++++- packages/nodes/src/shared/ghost-materials.ts | 22 +- .../nodes/src/shared/wall-attach-target.ts | 102 +++++++ .../src/shared/wall-opening-alignment.ts | 56 +++- .../nodes/src/window/floor-projection.tsx | 111 ++++++++ packages/nodes/src/window/floorplan-move.ts | 50 +++- packages/nodes/src/window/move-tool.tsx | 251 ++++++++++++++--- packages/nodes/src/window/preview.tsx | 16 +- packages/nodes/src/window/tool.tsx | 99 +++++-- packages/nodes/src/window/window-math.ts | 83 +----- wiki/architecture/README.md | 2 +- wiki/architecture/tools.md | 12 + 18 files changed, 1027 insertions(+), 277 deletions(-) create mode 100644 packages/nodes/src/window/floor-projection.tsx diff --git a/AGENTS.md b/AGENTS.md index d55de2981..9ddb52fb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ Read the relevant page in `wiki/architecture/` **before** writing code. The page - Adding a node type → `node-schemas.md`, `renderers.md`, `systems.md` - Adding a tool → `tools.md`, `spatial-queries.md`, `events.md` +- Adding / changing a placement or move interaction → `tools.md` ("2D ↔ 3D behavioral parity": applicable behaviors must exist in both views; port the change to the sibling 2D/3D file in the same PR) - Adding a system → `systems.md`, `scene-registry.md` - Anything in `packages/viewer` → `viewer-isolation.md`, `layers.md` - Anything touching selection → `selection-managers.md`, `scene-registry.md`, `events.md` diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 3bdcb5eff..b7eca84ed 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -124,6 +124,14 @@ export function FloorplanRegistryMoveOverlay() { // to be consumed. That legacy flow is gone in the registry layer; // all entries use the action menu now. let hasMovedSinceStart = false + // Live cursor location — updated on EVERY pointermove (even over the 3D + // canvas) so R-key ownership can follow the pointer's CURRENT pane rather + // than the sticky `hasMovedSinceStart`. Without this, once the user touched + // the 2D pane the overlay claimed R forever and the 3D flip went dead. + let pointerOverFloorplan = false + const onPointerTrack = (event: PointerEvent) => { + pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY) + } const onMove = (event: PointerEvent) => { // Skip 3D-canvas / other-UI cursor moves so the overlay only @@ -288,18 +296,24 @@ export function FloorplanRegistryMoveOverlay() { // apply so the 2D symbol updates immediately; kinds without a facing // leave `flipSide` unset and R falls through to the global handler. // - // Only when THIS overlay is the active mover — i.e. the user has moved - // the pointer over the 2D pane (`hasMovedSinceStart`). The 3D move tool - // also listens for R while a door/window `movingNode` is placed (split - // / 3D-only view); gating on `hasMovedSinceStart` keeps the two from - // both flipping (or double-playing the cue) on a single R press. + // Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D + // move tool ALSO listens for R on `window`. We own R only while the + // cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the + // 2D mover has actually engaged (`hasMovedSinceStart`); then we + // `stopImmediatePropagation` (this handler is CAPTURE-phase, so it runs + // first) so the 3D tool can't also flip. When the cursor is over the 3D + // pane we yield — the 3D tool owns R there. (The old sticky + // `hasMovedSinceStart`-only gate made the overlay claim R forever after + // the first 2D move, killing the 3D flip.) if (event.key === 'r' || event.key === 'R') { - if (!(session.flipSide && hasMovedSinceStart)) return + if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return + if (event.repeat) return const t = event.target as HTMLElement | null if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) { return } event.preventDefault() + event.stopImmediatePropagation() session.flipSide() sfxEmitter.emit('sfx:item-rotate') return @@ -349,13 +363,18 @@ export function FloorplanRegistryMoveOverlay() { setMovingNode(null) } + window.addEventListener('pointermove', onPointerTrack) window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onPointerUp) - window.addEventListener('keydown', onKey) + // Capture phase so this runs BEFORE the 3D move tool's bubble-phase R + // listener — when the cursor is over the 2D pane, `stopImmediatePropagation` + // then pre-empts it so only one handler flips. + window.addEventListener('keydown', onKey, true) return () => { + window.removeEventListener('pointermove', onPointerTrack) window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onPointerUp) - window.removeEventListener('keydown', onKey) + window.removeEventListener('keydown', onKey, true) // Unmount cleanup. `historyPaused === true` here means none of // our terminal paths (commit, Esc) ran in this overlay — they // each call `resumeSceneHistory` and flip the flag. diff --git a/packages/nodes/src/door/door-math.ts b/packages/nodes/src/door/door-math.ts index 15a1b2ddb..b5517c9dc 100644 --- a/packages/nodes/src/door/door-math.ts +++ b/packages/nodes/src/door/door-math.ts @@ -1,12 +1,4 @@ -import { - type AnyNodeId, - type DoorNode, - getScaledDimensions, - type ItemNode, - useScene, - type WallNode, - type WindowNode, -} from '@pascal-app/core' +import type { WallNode } from '@pascal-app/core' /** * Keep the door handle at the same relative height when the door is resized: @@ -64,63 +56,7 @@ export function clampToWall( return { clampedX, clampedY } } -/** - * Checks if a proposed door position overlaps any existing wall children. - * Handles item, window, and door types. - */ -export function hasWallChildOverlap( - wallId: string, - clampedX: number, - clampedY: number, - width: number, - height: number, - ignoreId?: string, -): boolean { - const nodes = useScene.getState().nodes - const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined - if (!wallNode) return true - const halfW = width / 2 - const halfH = height / 2 - const newBottom = clampedY - halfH - const newTop = clampedY + halfH - const newLeft = clampedX - halfW - const newRight = clampedX + halfW - - for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) { - if (childId === ignoreId) continue - const child = nodes[childId as AnyNodeId] - if (!child) continue - - let childLeft: number, childRight: number, childBottom: number, childTop: number - - if (child.type === 'item') { - const item = child as ItemNode - if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue - const [w, h] = getScaledDimensions(item) - childLeft = item.position[0] - w / 2 - childRight = item.position[0] + w / 2 - childBottom = item.position[1] - childTop = item.position[1] + h - } else if (child.type === 'window') { - const win = child as WindowNode - childLeft = win.position[0] - win.width / 2 - childRight = win.position[0] + win.width / 2 - childBottom = win.position[1] - win.height / 2 - childTop = win.position[1] + win.height / 2 - } else if (child.type === 'door') { - const door = child as DoorNode - childLeft = door.position[0] - door.width / 2 - childRight = door.position[0] + door.width / 2 - childBottom = door.position[1] - door.height / 2 - childTop = door.position[1] + door.height / 2 - } else { - continue - } - - const xOverlap = newLeft < childRight && newRight > childLeft - const yOverlap = newBottom < childTop && newTop > childBottom - if (xOverlap && yOverlap) return true - } - - return false -} +// Wall-child overlap is shared by door + window placement (one source of +// truth in `shared/wall-attach-target.ts`). Re-exported here so existing +// `./door-math` importers don't change. +export { hasWallChildOverlap } from '../shared/wall-attach-target' diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index 06cc1dc72..f61bcc762 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -8,12 +8,13 @@ import { type WallNode, WallNode as WallNodeSchema, } from '@pascal-app/core' -import { snapToHalf, usePlacementPreview } from '@pascal-app/editor' +import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host' import { findClosestWallInPlan, projectWallLocalPointToPlan, + resolveOpeningPlacement, snapLocalXToNeighbors, } from '../shared/wall-attach-target' import { clampToWall, hasWallChildOverlap } from './door-math' @@ -87,6 +88,25 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) // the cursor as a ghost (like the 3D move) and is NOT committable — a door // needs a wall. Starts true so a click before any move keeps the door put. let onWall = true + // Shift force-place (last apply's modifier) — lets `canCommit` allow an + // overlapping placement, matching the 3D move. Read in `canCommit` so a Shift- + // held commit over a collision lands instead of reverting. + let forcePlace = false + + // Move SFX — parity with the 3D `MoveDoorTool`: ONE soft `sfx:grid-snap` click + // per grid step, identical free-following or sliding on a wall, keyed on the + // RAW cursor (not the snapped along-wall value). No separate floor→wall cue — + // that distinct sound was the "double" the user heard. 2D `apply` runs once per + // pointermove, so the step-key dedup is sufficient (no per-frame guard needed). + const STEP_M = 0.1 + let lastStepKey: string | null = null + const tickGridStep = (...coords: number[]) => { + const key = coords.map((c) => Math.round(c / STEP_M)).join(',') + if (key !== lastStepKey) { + lastStepKey = key + triggerSFX('sfx:grid-snap') + } + } // Off-wall: float the faithful door symbol at the cursor (via a synthetic // wall fed to the placement-preview layer) and hide the real node, so the @@ -104,14 +124,23 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) end: [planPoint[0] + half, planPoint[1]], thickness: 0.1, }) + // Reflect the R-flip on the floating ghost so its swing-arc faces the side + // that will be committed (the synthetic wall is plan-X aligned, so a back + // facing is a π yaw; the symbol builder also reads `side`). + const ghostSide: DoorNode['side'] = flipped + ? node.side === 'front' + ? 'back' + : 'front' + : node.side const ghost = { ...node, + side: ghostSide, parentId: wall.id, wallId: wall.id, roofSegmentId: undefined, roofFace: undefined, position: [half, node.position[1], 0] as [number, number, number], - rotation: [0, 0, 0] as [number, number, number], + rotation: [0, flipped ? Math.PI : 0, 0] as [number, number, number], visible: true, } as DoorNode usePlacementPreview.getState().set(ghost, wall) @@ -125,6 +154,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) }, apply({ planPoint, modifiers }) { lastApply = { planPoint, modifiers } + forcePlace = modifiers.shiftKey === true // Drop any stale live transform left by the 3D `MoveDoorTool` (it // publishes one on every wall hover). The 2D registry layer renders // door/window from `useLiveTransforms` IN PREFERENCE to the scene node, @@ -140,7 +170,9 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) const resolvedPlanPoint = resolveCursor(planPoint) const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) if (!hit) { - // Off any wall — free-follow the cursor (not committable). + // Off any wall — free-follow the cursor (not committable). Click per grid + // cell as the ghost slides over open floor. + tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1]) freeFollow(resolvedPlanPoint) return } @@ -168,6 +200,11 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)) const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height) + // One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`, + // not the snapped value) so the wall slide ticks at the same cadence as the + // off-wall ghost — the same SFX, no separate snap cue. + tickGridStep(hit.localX) + // Apply the R-flip on top of the wall-derived side. const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0) @@ -204,9 +241,10 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) if (!onWall) return false const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined if (!live || live.type !== 'door') return false - // Block commit if the door overlaps any other wall child at its - // current position. The 3D port has the same guard. - const overlapping = hasWallChildOverlap( + // Block commit if the door overlaps another wall child — UNLESS Shift + // force-places (same `placeable` rule as the 3D move + the shared + // `resolveOpeningPlacement`). + const collides = hasWallChildOverlap( live.parentId as string, live.position[0], live.position[1], @@ -214,7 +252,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) live.height, live.id, ) - return !overlapping + return resolveOpeningPlacement({ collides, forcePlace }).placeable }, commit() { // Own the atomic write so the overlay takes the deterministic diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 9aa33288b..69152bf4c 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -14,7 +14,6 @@ import { type WallEvent, } from '@pascal-app/core' import { - calculateCursorRotation, calculateItemRotation, consumePlacementDragRelease, EDITOR_LAYER, @@ -26,7 +25,7 @@ import { useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { @@ -38,8 +37,10 @@ import { type RoofWallOpeningTarget, resolveRoofWallOpeningTarget, } from '../shared/roof-wall-opening-placement' +import { resolveOpeningPlacement } from '../shared/wall-attach-target' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' +import DoorPreview from './preview' const edgeMaterial = new LineBasicNodeMaterial({ color: 0xef_44_44, @@ -51,6 +52,40 @@ const edgeMaterial = new LineBasicNodeMaterial({ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { const cursorGroupRef = useRef(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 + // `` wrapper already places it, so we zero those to avoid a double + // offset) and reads `side` for the swing/hinge direction — so the ghost + // matches exactly what commit will place, including an R-flip. Falls back to + // the moving node's own side when no pose is active. + const ghostSide = ghostPose?.side ?? movingDoorNode.side + const ghostNode = useMemo( + () => ({ + ...movingDoorNode, + side: ghostSide, + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }), + [movingDoorNode, ghostSide], + ) + const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) }, []) @@ -76,6 +111,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: movingDoorNode.roofSegmentId, roofFace: movingDoorNode.roofFace, metadata: movingDoorNode.metadata, + // Free-follow hides the node (visible:false); every revert path must + // restore the original visibility or an existing door cancelled over open + // floor would stay invisible. + visible: movingDoorNode.visible, } if (!isNew) { @@ -95,6 +134,35 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // pointermove (shared DOM timeStamp) — that's the only thing that snaps. let freeFollowing = false let lastMeshEventTime = -1 + // Last open-floor cursor point (level-local X/Z), so an R-flip or Shift change + // while free-following can re-run the ghost at the same spot with the new + // facing/tint — no pointer move required. + let lastFloorPoint: [number, number] | null = null + // Live Shift state (force-place). Tracked here so the preview tint can be + // re-evaluated when Shift is pressed/released with the pointer stationary — + // the stored WallEvent carries a STALE shiftKey from the last move. + let shiftHeld = false + // Movement SFX: ONE soft `sfx:grid-snap` click each time the door crosses a + // grid step — identical whether free-following over open floor or sliding + // along a wall, so the two feel the same (the user's ask). Always keyed on + // the RAW cursor position (continuous ~0.1m cadence), never the snapped + // along-wall value, so the wall slide ticks at the same rate as the ghost. + // Two guards prevent a doubled/flammed cue: `lastStepKey` (emit only when + // the quantized cell changes) AND `lastTickFrame` (at most one tick per DOM + // pointermove — a wall mesh can emit `wall:move` more than once per move, and + // the grid + wall paths can both run). No separate snap cue: a distinct + // floor→wall sound was the "double" the user heard. + const STEP_M = 0.1 + let lastStepKey: string | null = null + let lastTickFrame = -1 + const tickGridStep = (frame: number, ...coords: number[]) => { + if (frame === lastTickFrame) return + const key = coords.map((c) => Math.round(c / STEP_M)).join(',') + if (key === lastStepKey) return + lastStepKey = key + lastTickFrame = frame + triggerSFX('sfx:grid-snap') + } // The door's chosen facing side. R flips it mid-placement (front ↔ back, // same as the committed-selected R flip) so the user can reorient before // committing. Initialised from the moving node's side. @@ -104,7 +172,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: string side: DoorNode['side'] itemRotation: number - cursorRotation: number clampedX: number clampedY: number valid: boolean @@ -143,6 +210,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() clearOpeningGuides3D() + setGhostPose(null) } // Alignment candidates — anchors of every OTHER alignable object (the @@ -172,8 +240,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => return { side, itemRotation: calculateItemRotation(event.normal) + rotationOffset, - cursorRotation: - calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset, } } @@ -185,7 +251,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } if (event.node.parentId !== getLevelId()) return - const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) + const { side, itemRotation } = getPlacementOrientation(event) const rawLocalX = event.localPosition[0] if (!dragAnchor || dragAnchor.wallId !== event.node.id) { @@ -201,8 +267,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => rawLocalX: targetLocalX, width: movingDoorNode.width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, + // Alt still hard-disables alignment (no guides). Shift = free-place: + // land at the raw cursor but keep showing the alignment guides. + bypass: event.nativeEvent?.altKey === true, + freePlace: event.nativeEvent?.shiftKey === true, }) const { clampedX, clampedY } = clampToWall( event.node, @@ -225,7 +293,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: event.node.id, side, itemRotation, - cursorRotation, clampedX, clampedY, valid, @@ -234,6 +301,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const applyPreview = (target: NonNullable) => { + // Same click as the off-wall ghost: one grid-snap tick per grid step, + // keyed on the RAW cursor along-wall position (not the snapped clampedX, + // whose ~0.5m jumps would tick at a different cadence). Per-frame guard + // collapses any duplicate wall events on the same pointermove. + tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0]) + // Keep the REAL node hidden and show a tinted ghost in the wall opening — + // green when placeable, red when it collides — the same translucent ghost + // the free-follow uses, so validity reads at a glance. The node position is + // still written (so the wall cuts the hole at the right spot) but + // `visible:false` keeps the pale solid mesh from competing with the ghost. if (currentHostId !== target.wallId) { useScene.getState().updateNode(movingDoorNode.id, { position: [target.clampedX, target.clampedY, 0], @@ -243,6 +320,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: target.wallId, roofSegmentId: undefined, roofFace: undefined, + visible: false, }) markHostDirty(currentHostId) currentHostId = target.wallId @@ -260,17 +338,37 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => }) markHostDirtyThrottled(target.wallId) - updateCursor( - wallLocalToWorld( + // Position the tinted ghost at the wall opening (world frame), facing the + // wall normal + the live side (so an R-flip shows correctly). The + // wireframe cursor is no longer used on a wall. Tint comes from the SHARED + // placement decision — green when placeable (incl. Shift force-place over a + // collision), red otherwise — the SAME `placeable` the commit gate uses. + if (cursorGroupRef.current) cursorGroupRef.current.visible = false + const placement = resolveOpeningPlacement({ + collides: !target.valid, + forcePlace: shiftHeld, + }) + // The committed door is a CHILD of the wall mesh (group yaw = -wallAngle) + // with wall-local `itemRotation` (0 front / π back). The ghost is a + // scene-root world-space group, so its world yaw must be + // `-wallAngle + itemRotation` to face the same way as commit. + // `cursorRotation` (the old symmetric-wireframe yaw) is π off here. + const wallAngle = Math.atan2( + target.wallNode.end[1] - target.wallNode.start[1], + target.wallNode.end[0] - target.wallNode.start[0], + ) + setGhostPose({ + position: wallLocalToWorld( target.wallNode, target.clampedX, target.clampedY, getLevelYOffset(), getSlabElevation(target.event), ), - target.cursorRotation, - target.valid, - ) + rotationY: target.itemRotation - wallAngle, + tint: placement.tint, + side: target.side, + }) publishOpeningGuidesForWallEvent({ wall: target.wallNode, @@ -351,6 +449,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: target.wallId, roofSegmentId: undefined, roofFace: undefined, + // The moving node is hidden during free-follow; the committed door + // must be visible regardless of the pre-commit free-follow state. + visible: true, }) useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id @@ -364,6 +465,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) useScene.temporal.getState().resume() @@ -375,6 +477,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: target.wallId, roofSegmentId: undefined, metadata: {}, + visible: true, }) if (original.parentId && original.parentId !== target.wallId) { @@ -400,7 +503,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => if (event.node.parentId !== getLevelId()) return const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) - if (!target?.valid) return + // Shift force-places: commit even when the door overlaps another opening. + // The preview keeps its red invalid tint as a warning; Shift just lifts the + // commit block. Read shift from THIS event so it's never stale at commit. + if (!target) return + if (!target.valid && event.nativeEvent?.shiftKey !== true) return commitToWall(target) event.stopPropagation() } @@ -420,13 +527,29 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => lastRoofEvent = null } - // Free-follow: the door rides the cursor over empty floor, parented to the - // level like an item node (lifted so it stands on the floor). No wall to - // attach to, so it is not committable here. - const freeFollowAt = (localX: number, localZ: number) => { + // Reveal the real door node + drop the ghost. Used by the roof-face path, + // which previews with the real mesh (the ghost-tint flow is wall-specific). + const revealRealNode = () => { + setGhostPose(null) + const live = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined + if (live && live.visible === false) { + useScene.getState().updateNode(movingDoorNode.id, { visible: true }) + } + } + + // Free-follow: over open floor there's no wall to host the door, so instead + // of dragging the real (pale, near-invisible-on-grid) node around we hide it + // and float a red translucent ghost at the cursor — same treatment the raw + // `DoorTool` build path uses. The node still re-parents to the level so a + // later wall-snap / commit has a clean base, but stays `visible:false` until + // a wall is hovered. + const freeFollowAt = (localX: number, localZ: number, frame: number) => { freeFollowing = true lastTarget = null lastRoofEvent = null + // Click per grid cell as the ghost slides over open floor (X+Z) — the + // same `tickGridStep` the on-wall slide uses, so both feel identical. + tickGridStep(frame, localX, localZ) hideCursor() useLiveTransforms.getState().clear(movingDoorNode.id) const levelId = getLevelId() @@ -445,6 +568,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: undefined, roofSegmentId: undefined, roofFace: undefined, + visible: false, }) currentHostId = levelId } else { @@ -452,8 +576,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => position: [localX, y, localZ], rotation: [0, yaw, 0], side: sideOverride, + visible: false, }) } + // Float the red (invalid — no wall) ghost at the cursor, level-Y lifted so + // it stands on the floor, matching the door's chosen facing (sideOverride + // carries the R-flip so the ghost swing direction matches commit). + setGhostPose({ + position: [localX, getLevelYOffset() + y, localZ], + rotationY: yaw, + tint: 'invalid', + side: sideOverride, + }) } const onGridMove = (event: GridEvent) => { @@ -468,7 +602,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // so snapping engages only when the cursor ray actually hovers a wall // (`onWallMove`). Over open floor the door just follows the cursor. const [x, , z] = event.localPosition - freeFollowAt(x, z) + lastFloorPoint = [x, z] + freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -505,6 +640,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => useLiveTransforms.getState().clear(movingDoorNode.id) // Opening guides are wall-specific; clear them when over a roof face. clearOpeningGuides3D() + // On a roof face the real mesh is the preview — drop the free-follow ghost + // and reveal the node. + revealRealNode() if (currentHostId !== target.segment.id) { useScene.getState().updateNode(movingDoorNode.id, { position: target.position, @@ -514,6 +652,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: undefined, roofSegmentId: target.segment.id, roofFace: target.face.id, + visible: true, }) markHostDirty(currentHostId) currentHostId = target.segment.id @@ -531,7 +670,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const onRoofClick = (event: RoofEvent) => { if (committed) return const target = resolveRoofMoveTarget(event) - if (!target?.valid) return + // Shift force-places over a colliding roof-face target too (see onWallClick). + if (!target) return + if (!target.valid && event.nativeEvent?.shiftKey !== true) return committed = true const segmentId = target.segment.id @@ -553,6 +694,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: segmentId, roofFace: target.face.id, parentId: segmentId, + visible: true, }) useScene.getState().createNode(node, segmentId as AnyNodeId) placedId = node.id @@ -566,6 +708,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) useScene.temporal.getState().resume() @@ -578,6 +721,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: segmentId, roofFace: target.face.id, metadata: {}, + visible: true, }) if (original.parentId && original.parentId !== segmentId) { @@ -622,6 +766,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) if (original.parentId) markHostDirty(original.parentId) } @@ -633,8 +778,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const onPlacementDragPointerUp = (event: PointerEvent) => { if (!consumePlacementDragRelease(event)) return // Free-following over open floor can't commit (no wall). A wall hover - // target commits via commitToWall; a roof face via onRoofClick. - if (lastTarget?.valid && !freeFollowing) { + // target commits via commitToWall; a roof face via onRoofClick. Shift + // force-places over a colliding wall target (the tint stays red as a + // warning); read shift from this pointerup so it's current at commit. + if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) { commitToWall(lastTarget) return } @@ -655,23 +802,30 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => ) { return } - // Only act where a flip is meaningful — on a wall hover or while - // free-following. On a roof face leave it to the default R (no flip, no - // sfx) so the cue never fires without a visible effect. - const onWall = lastTarget !== null - if (!(onWall || freeFollowing)) return + // Ignore OS key-repeat so a held R doesn't flip many times per press. + if (e.repeat) return e.preventDefault() + // ALWAYS toggle the persistent flip intent — never a no-op. (The old gate + // dropped R before the first pointermove, so initial-placement R needed a + // second press.) Then re-render whatever preview is current so the flip + // shows live and matches what commit will write. sideOverride = sideOverride === 'front' ? 'back' : 'front' triggerSFX('sfx:item-rotate') - if (onWall) { + if (lastTarget) { // On a wall: re-resolve with the flipped side and re-preview. - const next = resolveMoveTarget(lastTarget!.event) + const next = resolveMoveTarget(lastTarget.event) if (next) { lastTarget = next applyPreview(next) } + } else if (lastFloorPoint) { + // Free-following: re-run at the same spot so the floating ghost rebuilds + // with the flipped side (its swing/hinge geometry depends on `side`). + freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1) } else { - // Free-following on the level: flip the draft's facing in place. + // No preview yet (R pressed before the first pointermove at initial + // placement): flip the hidden node so the FIRST preview/commit already + // reflects the chosen side. useScene.getState().updateNode(movingDoorNode.id, { side: sideOverride, rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0], @@ -679,6 +833,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } } + // Shift toggles force-place. Track it live and re-run the on-wall preview so + // the tint flips green↔red the instant Shift is pressed/released, even with + // the pointer stationary — the ghost and the commit gate read the same + // `placeable`. (Commit gates still read shift fresh from their own event.) + const onShiftToggle = (e: KeyboardEvent) => { + if (e.key !== 'Shift') return + const held = e.type === 'keydown' + if (held === shiftHeld) return + shiftHeld = held + if (!committed && lastTarget) applyPreview(lastTarget) + } + emitter.on('wall:enter', onWallEnter) emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) @@ -691,6 +857,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) window.addEventListener('keydown', onKeyDown) + window.addEventListener('keydown', onShiftToggle) + window.addEventListener('keyup', onShiftToggle) return () => { const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as @@ -711,9 +879,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) if (original.parentId) markHostDirty(original.parentId) } + } else if (current && current.visible === false) { + // Safety net: a fresh (isNew) clone isn't marked `isTransient`, so the + // branch above skips it. If we unmount mid-free-follow it would be left + // hidden — reveal it so it never becomes an invisible orphan. (The + // `place-preset` movingNode subscription deletes a truly-cancelled + // clone separately.) + useScene.getState().updateNode(movingDoorNode.id, { visible: true }) } useLiveTransforms.getState().clear(movingDoorNode.id) useAlignmentGuides.getState().clear() @@ -731,6 +907,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keydown', onShiftToggle) + window.removeEventListener('keyup', onShiftToggle) } }, [movingDoorNode, exitMoveMode]) @@ -747,9 +925,23 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( - - - + <> + + + + {/* 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 && ( + + + + )} + ) } diff --git a/packages/nodes/src/door/preview.tsx b/packages/nodes/src/door/preview.tsx index 0bc0892d7..8cd9b6375 100644 --- a/packages/nodes/src/door/preview.tsx +++ b/packages/nodes/src/door/preview.tsx @@ -16,16 +16,24 @@ import type { DoorNode } from './schema' * The root mesh's layer is set to EDITOR_LAYER because the invisible hitbox * material on SCENE_LAYER would poison the WebGPU MRT pass (project gotcha). */ -const DoorPreview = ({ node, invalid }: { node: DoorNode; invalid?: boolean }) => { +const DoorPreview = ({ + node, + invalid, + valid, +}: { + node: DoorNode + invalid?: boolean + valid?: boolean +}) => { const mesh = useMemo(() => { const m = buildDoorPreviewMesh(node) m.layers.set(EDITOR_LAYER) return m }, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount]) - // Ghost treatment (clone + tint + raycast-off) re-applies if `invalid` - // flips; its cleanup only disposes the clones it made. - useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid]) + // Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips; + // its cleanup only disposes the clones it made. + useEffect(() => applyGhost(mesh, { invalid, valid }), [mesh, invalid, valid]) // Geometry is freshly built per `mesh` and owned here — dispose it only // when the mesh itself is replaced/unmounted, never on an `invalid` toggle. diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 72ffc49aa..3e5fc2c77 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -74,13 +74,25 @@ const DoorTool: React.FC = () => { // Off-host floating ghost: the real door geometry follows the cursor over // the grid (tinted invalid). Mutually exclusive with the on-host draft — - // when a draft + wireframe is shown this is null and vice-versa. + // when a draft + wireframe is shown this is null and vice-versa. `side` + // carries the R-flip so the floating ghost faces the side that will be + // committed (its swing/hinge geometry depends on `side`). const [fallbackPose, setFallbackPose] = useState<{ position: [number, number, number] rotationY: number + side: DoorNode['side'] } | null>(null) - const ghostStub = useMemo(() => DoorNode.parse({ position: [0, 0, 0], rotation: [0, 0, 0] }), []) + // Ghost preview node — zeroed transform + the live facing side (rebuilds on R). + const ghostStub = useMemo( + () => + DoorNode.parse({ + position: [0, 0, 0], + rotation: [0, 0, 0], + side: fallbackPose?.side ?? 'front', + }), + [fallbackPose?.side], + ) useEffect(() => { useScene.temporal.getState().pause() @@ -98,6 +110,9 @@ const DoorTool: React.FC = () => { // re-applied to the last wall hover so the flip shows live before commit. let sideFlip = false let lastWallEvent: WallEvent | null = null + // Last open-floor cursor point (level-local X/Z) so an R-flip while + // free-following can re-render the floating ghost with the new facing. + let lastFloorPoint: [number, number, number] | null = null const getLevelId = () => useViewer.getState().selection.levelId const getLevelYOffset = () => { @@ -149,9 +164,15 @@ const DoorTool: React.FC = () => { // Off-host fallback: hide the wireframe outline and float the real door // geometry (tinted invalid) at the cursor so the armed tool is visible. + // `sideFlip` (R) flips the facing: a back facing is a π yaw on the floating + // ghost, and the door swing/hinge geometry reads `side`. const showGhostAt = (position: [number, number, number]) => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false - setFallbackPose({ position, rotationY: 0 }) + setFallbackPose({ + position, + rotationY: sideFlip ? Math.PI : 0, + side: sideFlip ? 'back' : 'front', + }) useAlignmentGuides.getState().clear() clearOpeningGuides3D() } @@ -176,13 +197,16 @@ const DoorTool: React.FC = () => { bypassSnap: boolean, ignoreId?: string, ) => { + // bypassSnap is set by Shift (see callers). Shift = free-place: land at the + // raw cursor but keep the along-wall guides visible. bypass (Alt) still + // hard-disables alignment. const localX = resolveWallSlideAlignment({ wallNode: wall, rawLocalX, width, candidates: alignmentCandidates, - bypass, - bypassSnap, + bypass: bypass && !bypassSnap, + freePlace: bypassSnap, }) const { clampedX, clampedY } = clampToWall(wall, localX, width, height) const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) @@ -403,7 +427,8 @@ const DoorTool: React.FC = () => { bypassSnap, draftRef.current.id, ) - if (!valid) return + // Shift force-places over a collision (the draft stays red as a warning). + if (!valid && !bypassSnap) return commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation) event.stopPropagation() @@ -433,8 +458,9 @@ const DoorTool: React.FC = () => { hostKind = null lastWallEvent = null const [x, y, z] = event.localPosition + lastFloorPoint = [x, y + FALLBACK_HEIGHT / 2, z] destroyDraft() - showGhostAt([x, y + FALLBACK_HEIGHT / 2, z]) + showGhostAt(lastFloorPoint) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -497,7 +523,9 @@ const DoorTool: React.FC = () => { const onRoofClick = (event: RoofEvent) => { if (!draftRef.current?.roofSegmentId) return const target = resolveRoofTarget(event) - if (!target?.valid) return + // Shift force-places over a colliding roof-face target (see onWallClick). + if (!target) return + if (!target.valid && event.nativeEvent?.shiftKey !== true) return const { segment, face, position } = target const draft = draftRef.current @@ -568,18 +596,26 @@ const DoorTool: React.FC = () => { } // R flips the door's facing side mid-placement (front ↔ back), like the - // committed-selected R flip. Only meaningful while snapped to a wall (the - // off-wall ghost has no orientation), so it acts only then — re-applying - // the last wall hover so the snapped preview flips live. + // committed-selected R flip. ALWAYS toggles the persistent flip intent — + // never a no-op (the old `!lastWallEvent` guard dropped R off-wall and before + // the first wall hover, so it "needed two presses"). Then re-renders whatever + // preview is current so the flip shows live and matches commit. const onKeyDown = (e: KeyboardEvent) => { if (e.key !== 'r' && e.key !== 'R') return - if (!lastWallEvent) return + if (e.repeat) return const t = e.target as HTMLElement | null if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return e.preventDefault() sideFlip = !sideFlip triggerSFX('sfx:item-rotate') - onWallHover(lastWallEvent) + if (lastWallEvent) { + // On a wall: re-resolve + re-preview with the flipped side. + onWallHover(lastWallEvent) + } else if (lastFloorPoint) { + // Off-wall: re-render the floating ghost (showGhostAt reads `sideFlip`). + showGhostAt(lastFloorPoint) + } + // else: no preview yet — `sideFlip` is set, so the first hover/follow uses it. } emitter.on('wall:enter', onWallHover) diff --git a/packages/nodes/src/shared/ghost-materials.ts b/packages/nodes/src/shared/ghost-materials.ts index 221027b7f..d2dc4c305 100644 --- a/packages/nodes/src/shared/ghost-materials.ts +++ b/packages/nodes/src/shared/ghost-materials.ts @@ -3,6 +3,7 @@ import type { Material, Mesh, Object3D, Raycaster } from 'three' export const INVALID_GHOST_COLOR = 0xef_44_44 +export const VALID_GHOST_COLOR = 0x22_c5_5e const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {} @@ -12,8 +13,11 @@ const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {} * Traverses the object tree, disables raycasting on all descendants (prevents * cursor-ray starvation), and clones visible mesh materials to set translucency. * - * When `invalid` is true, sets color/emissive to INVALID_GHOST_COLOR and opacity ~0.4. - * Otherwise sets opacity ~0.5 while preserving the original color. + * Tint by state: + * - `invalid` (red, opacity ~0.4): off-host or colliding — can't place here. + * - `valid` (green, opacity ~0.45): on a host and placeable — the "go" cue. + * - neither (opacity ~0.5, original color): a plain translucent preview. + * `invalid` wins if both are passed. * * Skips: meshes whose material.visible === false (door/window root hitbox) and * children named 'cutout'. @@ -21,11 +25,15 @@ const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {} * Returns cleanup that disposes only the cloned materials (never originals or geometry). * * @param root - The preview mesh tree (typically from buildDoorPreviewMesh / buildWindowPreviewMesh) - * @param opts - { invalid?: boolean } whether to tint red for invalid placement + * @param opts - { invalid?, valid? } placement-state tint * @returns Cleanup function that disposes the cloned materials */ -export function applyGhost(root: Object3D, opts?: { invalid?: boolean }): () => void { +export function applyGhost( + root: Object3D, + opts?: { invalid?: boolean; valid?: boolean }, +): () => void { const invalid = opts?.invalid ?? false + const valid = !invalid && (opts?.valid ?? false) const cloned: Material[] = [] root.traverse((obj) => { @@ -51,6 +59,12 @@ export function applyGhost(root: Object3D, opts?: { invalid?: boolean }): () => INVALID_GHOST_COLOR, ) clone.opacity = 0.4 + } else if (valid) { + ;(clone as { color?: { setHex: (c: number) => void } }).color?.setHex(VALID_GHOST_COLOR) + ;(clone as { emissive?: { setHex: (c: number) => void } }).emissive?.setHex( + VALID_GHOST_COLOR, + ) + clone.opacity = 0.45 } else { clone.opacity = 0.5 } diff --git a/packages/nodes/src/shared/wall-attach-target.ts b/packages/nodes/src/shared/wall-attach-target.ts index d54c5a89b..0c6dc53e6 100644 --- a/packages/nodes/src/shared/wall-attach-target.ts +++ b/packages/nodes/src/shared/wall-attach-target.ts @@ -5,6 +5,7 @@ import { getScaledDimensions, type ItemNode, nearestWallSegment, + useScene, WALL_SNAP_DISTANCE_M, type WallNode, } from '@pascal-app/core' @@ -187,3 +188,104 @@ export function snapLocalXToNeighbors(args: { return bestDelta === null ? null : localX + bestDelta } + +/** + * Does a wall-hosted opening of `width × height` centred at `(clampedX, + * clampedY)` (wall-local) overlap any OTHER child of `wallId` (door / window / + * wall-mounted item)? AABB test in the wall's local face plane. `ignoreId` + * excludes the moving node itself. Returns `true` (blocked) if the wall is + * gone. + * + * Single source of truth for door + window placement collision — door-math and + * window-math had byte-identical copies of this. Y conventions differ per kind + * (items store bottom Y; doors/windows store centre Y), handled inline. + */ +export function hasWallChildOverlap( + wallId: string, + clampedX: number, + clampedY: number, + width: number, + height: number, + ignoreId?: string, +): boolean { + const nodes = useScene.getState().nodes + const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined + if (!wallNode) return true + const halfW = width / 2 + const halfH = height / 2 + const newBottom = clampedY - halfH + const newTop = clampedY + halfH + const newLeft = clampedX - halfW + const newRight = clampedX + halfW + + for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) { + if (childId === ignoreId) continue + const child = nodes[childId as AnyNodeId] + if (!child) continue + + let childLeft: number + let childRight: number + let childBottom: number + let childTop: number + + if (child.type === 'item') { + const item = child as ItemNode + if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue + const [w, h] = getScaledDimensions(item) + childLeft = item.position[0] - w / 2 + childRight = item.position[0] + w / 2 + childBottom = item.position[1] // items store bottom Y + childTop = item.position[1] + h + } else if (child.type === 'window') { + const win = child as { position: [number, number, number]; width: number; height: number } + childLeft = win.position[0] - win.width / 2 + childRight = win.position[0] + win.width / 2 + childBottom = win.position[1] - win.height / 2 // windows store centre Y + childTop = win.position[1] + win.height / 2 + } else if (child.type === 'door') { + const door = child as { position: [number, number, number]; width: number; height: number } + childLeft = door.position[0] - door.width / 2 + childRight = door.position[0] + door.width / 2 + childBottom = door.position[1] - door.height / 2 // doors store centre Y + childTop = door.position[1] + door.height / 2 + } else { + continue + } + + const xOverlap = newLeft < childRight && newRight > childLeft + const yOverlap = newBottom < childTop && newTop > childBottom + if (xOverlap && yOverlap) return true + } + + return false +} + +/** Placement state for a wall-hosted opening — the SINGLE decision the preview + * tint and the commit gate both consume so they can never disagree. */ +export type OpeningPlacement = { + /** Geometric overlap with another wall child (independent of modifiers). */ + collides: boolean + /** May the opening be committed here? `true` unless it collides and the user + * isn't force-placing. */ + placeable: boolean + /** Ghost tint: green when placeable, red when not. */ + tint: 'valid' | 'invalid' +} + +/** + * Resolve the placement state from the raw collision result and whether the + * user is force-placing (Shift). Force-place lifts the collision block, so the + * opening becomes placeable AND the tint goes green — the preview and the + * commit gate stay in lockstep because both read this one result. + */ +export function resolveOpeningPlacement(args: { + collides: boolean + forcePlace: boolean +}): OpeningPlacement { + const placeable = !args.collides || args.forcePlace + return { + collides: args.collides, + placeable, + tint: placeable ? 'valid' : 'invalid', + } +} diff --git a/packages/nodes/src/shared/wall-opening-alignment.ts b/packages/nodes/src/shared/wall-opening-alignment.ts index 48f8cc308..0a184f2f6 100644 --- a/packages/nodes/src/shared/wall-opening-alignment.ts +++ b/packages/nodes/src/shared/wall-opening-alignment.ts @@ -23,6 +23,12 @@ const MIN_AXIS_COMPONENT = 0.5 * guide on bypass / no-match. Returns the localX to use (X-clamped to the wall * given `width`). `bypass` disables alignment; `bypassSnap` also skips the * half-metre fallback. + * + * `freePlace` (Shift) is the "place anywhere, but still show me where I'd + * align" mode: the opening lands at the EXACT raw cursor (no grid snap, no + * jump-to-guide), yet the alignment guides are still computed and shown so the + * user keeps the visual reference while overriding the magnetic pull. It + * supersedes `bypass`/`bypassSnap` when set. */ export function resolveWallSlideAlignment(args: { wallNode: WallNode @@ -31,9 +37,55 @@ export function resolveWallSlideAlignment(args: { candidates: readonly AlignmentAnchor[] bypass: boolean bypassSnap?: boolean + freePlace?: boolean }): number { - const { wallNode, rawLocalX, width, candidates, bypass, bypassSnap = false } = args - const base = bypassSnap ? rawLocalX : snapToHalf(rawLocalX) + const { + wallNode, + rawLocalX, + width, + candidates, + bypass, + bypassSnap = false, + freePlace = false, + } = args + const base = bypassSnap || freePlace ? rawLocalX : snapToHalf(rawLocalX) + + const dxAxis = wallNode.end[0] - wallNode.start[0] + const dzAxis = wallNode.end[1] - wallNode.start[1] + const axisLength = Math.sqrt(dxAxis * dxAxis + dzAxis * dzAxis) + + // Shift / free-place: land at the raw cursor but still publish the guides so + // the user sees alignment relationships without being snapped to them. The + // guides are re-resolved at the freely-placed point so they connect to the + // opening, not the snap target. + if (freePlace) { + if (candidates.length === 0 || axisLength < 1e-6) { + useAlignmentGuides.getState().clear() + return base + } + const c = dxAxis / axisLength + const s = dzAxis / axisLength + const placedX = Math.max(width / 2, Math.min(axisLength - width / 2, base)) + const shown = resolveAlignment({ + moving: [ + { + nodeId: '__wall-opening-draft__', + kind: 'corner', + x: wallNode.start[0] + placedX * c, + z: wallNode.start[1] + placedX * s, + }, + ], + candidates, + threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M, + }) + const axisGuides = shown.guides.filter( + (g) => Math.abs(g.axis === 'x' ? c : s) >= MIN_AXIS_COMPONENT, + ) + if (axisGuides.length === 0) useAlignmentGuides.getState().clear() + else useAlignmentGuides.getState().set(axisGuides) + return placedX + } + if (bypass || candidates.length === 0) { useAlignmentGuides.getState().clear() return base diff --git a/packages/nodes/src/window/floor-projection.tsx b/packages/nodes/src/window/floor-projection.tsx new file mode 100644 index 000000000..071156300 --- /dev/null +++ b/packages/nodes/src/window/floor-projection.tsx @@ -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 ( + <> + + + + ) +} diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index 761154066..48d6e1df2 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -8,15 +8,16 @@ import { WallNode as WallNodeSchema, type WindowNode, } from '@pascal-app/core' -import { snapToHalf, usePlacementPreview } from '@pascal-app/editor' +import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host' import { findClosestWallInPlan, projectWallLocalPointToPlan, + resolveOpeningPlacement, snapLocalXToNeighbors, } from '../shared/wall-attach-target' -import { clampToWall, hasWallChildOverlap } from './window-math' +import { clampToWall, DEFAULT_WINDOW_SILL_M, hasWallChildOverlap } from './window-math' /** * 2D floor-plan move handler for window. Same shape as door (see @@ -56,8 +57,8 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod // created at y=0, which would sit the window's centre on the floor (half // below ground); default those to a realistic sill so it floats above // the floor in 2D too. Same rule as the 3D `MoveWindowTool` (`getSillCenterY`). - const DEFAULT_SILL = 0.9 - const startLocalY = node.position[1] > 0.1 ? node.position[1] : DEFAULT_SILL + node.height / 2 + const startLocalY = + node.position[1] > 0.1 ? node.position[1] : DEFAULT_WINDOW_SILL_M + node.height / 2 // Track the last successful placement so `commit()` can write it // atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`. @@ -82,6 +83,23 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod // See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor // as a ghost and isn't committable (it needs a wall). Starts true. let onWall = true + // Shift force-place (last apply's modifier) — lets `canCommit` allow an + // overlapping placement, matching the 3D move. + let forcePlace = false + + // Move SFX — parity with the 3D `MoveWindowTool` (see `doorFloorplanMoveTarget`): + // ONE soft `sfx:grid-snap` click per grid step, identical free-following or on a + // wall, keyed on the RAW cursor. No separate floor→wall cue (that was the + // "double"). 2D `apply` runs once per pointermove, so the step-key dedup suffices. + const STEP_M = 0.1 + let lastStepKey: string | null = null + const tickGridStep = (...coords: number[]) => { + const key = coords.map((c) => Math.round(c / STEP_M)).join(',') + if (key !== lastStepKey) { + lastStepKey = key + triggerSFX('sfx:grid-snap') + } + } const freeFollow = (planPoint: readonly [number, number]) => { onWall = false @@ -95,14 +113,22 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod end: [planPoint[0] + half, planPoint[1]], thickness: 0.1, }) + // Reflect the R-flip on the floating ghost so it faces the side that will + // be committed (see `doorFloorplanMoveTarget.freeFollow`). + const ghostSide: WindowNode['side'] = flipped + ? node.side === 'front' + ? 'back' + : 'front' + : node.side const ghost = { ...node, + side: ghostSide, parentId: wall.id, wallId: wall.id, roofSegmentId: undefined, roofFace: undefined, position: [half, startLocalY, 0] as [number, number, number], - rotation: [0, 0, 0] as [number, number, number], + rotation: [0, flipped ? Math.PI : 0, 0] as [number, number, number], visible: true, } as WindowNode usePlacementPreview.getState().set(ghost, wall) @@ -116,6 +142,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod }, apply({ planPoint, modifiers }) { lastApply = { planPoint, modifiers } + forcePlace = modifiers.shiftKey === true // Drop any stale live transform left by the 3D `MoveWindowTool` — see // `doorFloorplanMoveTarget.apply`. Without this the 2D registry layer // keeps rendering the window at the 3D tool's last hover (it prefers @@ -129,6 +156,8 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod const resolvedPlanPoint = resolveCursor(planPoint) const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) if (!hit) { + // Off any wall — free-follow. Click per grid cell over open floor. + tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1]) freeFollow(resolvedPlanPoint) return } @@ -160,6 +189,11 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod node.height, ) + // One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`) + // so the wall slide ticks at the same cadence as the off-wall ghost — same + // SFX, no separate snap cue. + tickGridStep(hit.localX) + const side: WindowNode['side'] = flipped ? hit.side === 'front' ? 'back' @@ -192,7 +226,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod if (!onWall) return false const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined if (!live || live.type !== 'window') return false - const overlapping = hasWallChildOverlap( + // Block on overlap UNLESS Shift force-places — same `placeable` rule as + // the 3D move + the shared `resolveOpeningPlacement`. + const collides = hasWallChildOverlap( live.parentId as string, live.position[0], live.position[1], @@ -200,7 +236,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod live.height, live.id, ) - return !overlapping + return resolveOpeningPlacement({ collides, forcePlace }).placeable }, commit() { // Own the atomic write so the overlay takes the deterministic diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 2f31401ba..37ac65678 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -14,7 +14,6 @@ import { WindowNode, } from '@pascal-app/core' import { - calculateCursorRotation, calculateItemRotation, consumePlacementDragRelease, EDITOR_LAYER, @@ -27,7 +26,7 @@ import { useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { @@ -40,8 +39,16 @@ import { type RoofWallOpeningTarget, resolveRoofWallOpeningTarget, } from '../shared/roof-wall-opening-placement' +import { resolveOpeningPlacement } from '../shared/wall-attach-target' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' -import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' +import { WindowFloorProjection } from './floor-projection' +import WindowPreview from './preview' +import { + clampToWall, + DEFAULT_WINDOW_SILL_M, + hasWallChildOverlap, + wallLocalToWorld, +} from './window-math' const edgeMaterial = new LineBasicNodeMaterial({ color: 0xef_44_44, @@ -65,6 +72,38 @@ const edgeMaterial = new LineBasicNodeMaterial({ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => { const cursorGroupRef = useRef(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 `` wrapper; + // `updateWindowMesh` bakes the node's own position/rotation in, so passing the + // live node would double-offset). Rebuilds on an R-flip so the preview matches + // what commit will place. + const ghostSide = ghostPose?.side ?? movingWindowNode.side + const ghostNode = useMemo( + () => ({ + ...movingWindowNode, + side: ghostSide, + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }), + [movingWindowNode, ghostSide], + ) + const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) }, []) @@ -91,6 +130,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: movingWindowNode.roofSegmentId, roofFace: movingWindowNode.roofFace, metadata: movingWindowNode.metadata, + // Free-follow hides the node (visible:false); revert paths restore this. + visible: movingWindowNode.visible, } // In move mode (existing window) mark it transient so its mesh skips the live wall CSG @@ -113,6 +154,29 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // mesh event owns the same pointermove — that's the only thing that snaps. let freeFollowing = false let lastMeshEventTime = -1 + // Last open-floor cursor point (level-local X/Z), so an R-flip while free- + // following can re-run the ghost at the same spot with the new facing. + let lastFloorPoint: [number, number] | null = null + // Live Shift state (force-place) — lets the preview tint re-evaluate when + // Shift is pressed/released with the pointer stationary (see `MoveDoorTool`). + let shiftHeld = false + // Movement SFX: ONE soft `sfx:grid-snap` click per grid step — identical + // whether free-following over floor or sliding along a wall (the user's + // ask). Always keyed on the RAW cursor (continuous ~0.1m cadence), never the + // snapped along-wall value. Guards: `lastStepKey` (cell change) + + // `lastTickFrame` (one tick per DOM pointermove). No separate snap cue — a + // distinct floor→wall sound was the "double" the user heard. See `MoveDoorTool`. + const STEP_M = 0.1 + let lastStepKey: string | null = null + let lastTickFrame = -1 + const tickGridStep = (frame: number, ...coords: number[]) => { + if (frame === lastTickFrame) return + const key = coords.map((c) => Math.round(c / STEP_M)).join(',') + if (key === lastStepKey) return + lastStepKey = key + lastTickFrame = frame + triggerSFX('sfx:grid-snap') + } // The window's chosen facing side. R flips it mid-placement (front ↔ back), // matching the committed-selected R flip. Initialised from the moving node. let sideOverride: WindowNode['side'] = movingWindowNode.side @@ -128,7 +192,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: string side: WindowNode['side'] itemRotation: number - cursorRotation: number clampedX: number clampedY: number valid: boolean @@ -160,12 +223,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // Sill-center height used while the window isn't on a wall (free-follow and // proximity). Fresh preset clones are created at position [0,0,0], which // would bury half the window below the floor; default such windows to a - // ~0.9m sill so the ghost floats at a realistic height. An existing window - // keeps its own sill. - const DEFAULT_SILL = 0.9 + // small sill so the ghost floats slightly above the ground. An existing + // window keeps its own sill. const getSillCenterY = () => { const y = movingWindowNode.position[1] - return y > 0.1 ? y : DEFAULT_SILL + movingWindowNode.height / 2 + return y > 0.1 ? y : DEFAULT_WINDOW_SILL_M + movingWindowNode.height / 2 } const getSlabElevation = (wallEvent: WallEvent) => spatialGridManager.getSlabElevationForWall( @@ -178,6 +240,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() clearOpeningGuides3D() + setGhostPose(null) } // Alignment candidates — anchors of every OTHER alignable object (the @@ -214,8 +277,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const side = sideOverride ?? faceSide const rotationOffset = side !== faceSide ? Math.PI : 0 const itemRotation = calculateItemRotation(event.normal) + rotationOffset - const cursorRotation = - calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset const rawLocalX = event.localPosition[0] const rawLocalY = event.localPosition[1] @@ -256,8 +317,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode rawLocalX: targetLocalX, width: movingWindowNode.width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, + // Alt still hard-disables alignment (no guides). Shift = free-place: + // land at the raw cursor but keep showing the along-wall guides. + bypass: event.nativeEvent?.altKey === true, + freePlace: event.nativeEvent?.shiftKey === true, }) const { clampedX, clampedY } = clampToWall( event.node, @@ -281,7 +344,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: event.node.id, side, itemRotation, - cursorRotation, clampedX, clampedY, valid, @@ -290,6 +352,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const applyPreview = (target: NonNullable) => { + // Same click as the off-wall ghost: one grid-snap tick per grid step, + // keyed on the RAW cursor along-wall position (not the snapped clampedX). + // Per-frame guard collapses duplicate wall events on the same pointermove. + tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0]) + // Keep the REAL node hidden and show a tinted ghost in the wall opening — + // green when placeable, red when it collides — matching the free-follow + // ghost so validity reads at a glance (see MoveDoorTool). The node position + // is still written so the wall cuts the hole at the right spot. if (currentHostId !== target.wallId) { useScene.getState().updateNode(movingWindowNode.id, { position: [target.clampedX, target.clampedY, 0], @@ -299,6 +369,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: target.wallId, roofSegmentId: undefined, roofFace: undefined, + visible: false, }) markHostDirty(currentHostId) currentHostId = target.wallId @@ -316,17 +387,28 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) markHostDirtyThrottled(target.wallId) - updateCursor( - wallLocalToWorld( + if (cursorGroupRef.current) cursorGroupRef.current.visible = false + const placement = resolveOpeningPlacement({ collides: !target.valid, forcePlace: shiftHeld }) + // Ghost world yaw must equal the committed wall-CHILD's world yaw + // (-wallAngle + itemRotation); `cursorRotation` is π off here. See + // `MoveDoorTool.applyPreview`. + const wallAngle = Math.atan2( + target.wallNode.end[1] - target.wallNode.start[1], + target.wallNode.end[0] - target.wallNode.start[0], + ) + setGhostPose({ + position: wallLocalToWorld( target.wallNode, target.clampedX, target.clampedY, getLevelYOffset(), getSlabElevation(target.event), ), - target.cursorRotation, - target.valid, - ) + rotationY: target.itemRotation - wallAngle, + tint: placement.tint, + floorY: getLevelYOffset() + getSlabElevation(target.event), + side: target.side, + }) publishOpeningGuidesForWallEvent({ wall: target.wallNode, @@ -410,6 +492,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: target.wallId, roofSegmentId: undefined, roofFace: undefined, + // Hidden during free-follow; the committed window must be visible. + visible: true, }) useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id @@ -425,6 +509,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) useScene.temporal.getState().resume() @@ -436,6 +521,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: target.wallId, roofSegmentId: undefined, metadata: {}, + visible: true, }) if (original.parentId && original.parentId !== target.wallId) { @@ -462,7 +548,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode if (event.node.parentId !== getLevelId()) return const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) - if (!target?.valid) return + // Shift force-places: commit even when the window overlaps another opening. + // The preview keeps its red invalid tint as a warning; Shift just lifts the + // commit block. Read shift from THIS event so it's never stale at commit. + if (!target) return + if (!target.valid && event.nativeEvent?.shiftKey !== true) return commitToWall(target) event.stopPropagation() } @@ -480,13 +570,28 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode lastRoofEvent = null } - // Free-follow: the window rides the cursor over empty floor, parented to - // the level like an item node, kept at a sensible sill height. No wall to - // attach to, so it is not committable here. - const freeFollowAt = (localX: number, localZ: number) => { + // Reveal the real window node + drop the ghost. Used by the roof-face path, + // which previews with the real mesh (the ghost-tint flow is wall-specific). + const revealRealNode = () => { + setGhostPose(null) + const live = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as + | WindowNode + | undefined + if (live && live.visible === false) { + useScene.getState().updateNode(movingWindowNode.id, { visible: true }) + } + } + + // Free-follow: over open floor there's no wall to host the window, so hide + // the real (pale, near-invisible-on-grid) node and float a red translucent + // ghost at the cursor — same treatment the raw `WindowTool` build path uses. + const freeFollowAt = (localX: number, localZ: number, frame: number) => { freeFollowing = true lastTarget = null lastRoofEvent = null + // Click per grid cell as the ghost slides over open floor (X+Z) — the + // same `tickGridStep` the on-wall slide uses, so both feel identical. + tickGridStep(frame, localX, localZ) hideCursor() useLiveTransforms.getState().clear(movingWindowNode.id) const levelId = getLevelId() @@ -503,6 +608,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: undefined, roofSegmentId: undefined, roofFace: undefined, + visible: false, }) currentHostId = levelId } else { @@ -510,8 +616,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode position: [localX, sillCenterY, localZ], rotation: [0, yaw, 0], side: sideOverride, + visible: false, }) } + // Float the red (invalid — no wall) ghost at the cursor, level-Y lifted to + // the sill center (sideOverride carries the R-flip so the ghost matches). + setGhostPose({ + position: [localX, getLevelYOffset() + sillCenterY, localZ], + rotationY: yaw, + tint: 'invalid', + floorY: getLevelYOffset(), + side: sideOverride, + }) } const onGridMove = (event: GridEvent) => { @@ -523,7 +639,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // snapping engages only when the cursor ray actually hovers a wall. if (event.nativeEvent?.timeStamp === lastMeshEventTime) return const [x, , z] = event.localPosition - freeFollowAt(x, z) + lastFloorPoint = [x, z] + freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -564,6 +681,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useLiveTransforms.getState().clear(movingWindowNode.id) // Opening guides are wall-specific; clear them when over a roof face. clearOpeningGuides3D() + // On a roof face the real mesh is the preview — drop the ghost + reveal. + revealRealNode() if (currentHostId !== target.segment.id) { useScene.getState().updateNode(movingWindowNode.id, { position: target.position, @@ -573,6 +692,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: undefined, roofSegmentId: target.segment.id, roofFace: target.face.id, + visible: true, }) markHostDirty(currentHostId) currentHostId = target.segment.id @@ -590,7 +710,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onRoofClick = (event: RoofEvent) => { if (committed) return const target = resolveRoofMoveTarget(event) - if (!target?.valid) return + // Shift force-places over a colliding roof-face target too (see onWallClick). + if (!target) return + if (!target.valid && event.nativeEvent?.shiftKey !== true) return committed = true const segmentId = target.segment.id @@ -613,6 +735,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: segmentId, roofFace: target.face.id, parentId: segmentId, + visible: true, }) useScene.getState().createNode(node, segmentId as AnyNodeId) placedId = node.id @@ -626,6 +749,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) useScene.temporal.getState().resume() @@ -638,6 +762,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: segmentId, roofFace: target.face.id, metadata: {}, + visible: true, }) if (original.parentId && original.parentId !== segmentId) { @@ -682,6 +807,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) if (original.parentId) markHostDirty(original.parentId) } @@ -693,8 +819,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onPlacementDragPointerUp = (event: PointerEvent) => { if (!consumePlacementDragRelease(event)) return // Free-following over open floor can't commit (no wall). A wall hover - // target commits via commitToWall; a roof face via onRoofClick. - if (lastTarget?.valid && !freeFollowing) { + // target commits via commitToWall; a roof face via onRoofClick. Shift + // force-places over a colliding wall target (tint stays red as a warning); + // read shift from this pointerup so it's current at commit. + if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) { commitToWall(lastTarget) return } @@ -714,18 +842,27 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode ) { return } - const onWall = lastTarget !== null - if (!(onWall || freeFollowing)) return + // Ignore OS key-repeat so a held R doesn't flip many times per press. + if (e.repeat) return e.preventDefault() + // ALWAYS toggle the persistent flip intent — never a no-op (the old gate + // dropped R before the first pointermove). Then re-render the current + // preview so the flip shows live and matches commit. See `MoveDoorTool`. sideOverride = sideOverride === 'front' ? 'back' : 'front' triggerSFX('sfx:item-rotate') - if (onWall) { - const next = resolveMoveTarget(lastTarget!.event) + if (lastTarget) { + const next = resolveMoveTarget(lastTarget.event) if (next) { lastTarget = next applyPreview(next) } + } else if (lastFloorPoint) { + // Free-following: re-run at the same spot so the floating ghost rebuilds + // with the flipped side. + freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1) } else { + // No preview yet (R before the first pointermove): flip the hidden node + // so the first preview/commit already reflects the chosen side. useScene.getState().updateNode(movingWindowNode.id, { side: sideOverride, rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0], @@ -733,6 +870,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } } + // Shift toggles force-place — re-run the on-wall preview so the tint flips + // green↔red live (pointer stationary). Commit gates still read shift fresh. + const onShiftToggle = (e: KeyboardEvent) => { + if (e.key !== 'Shift') return + const held = e.type === 'keydown' + if (held === shiftHeld) return + shiftHeld = held + if (!committed && lastTarget) applyPreview(lastTarget) + } + emitter.on('wall:enter', onWallEnter) emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) @@ -745,6 +892,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) window.addEventListener('keydown', onKeyDown) + window.addEventListener('keydown', onShiftToggle) + window.addEventListener('keyup', onShiftToggle) return () => { // Safety cleanup: if still transient on unmount (e.g. phase switch mid-move) @@ -766,9 +915,15 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) if (original.parentId) markHostDirty(original.parentId) } + } else if (current && current.visible === false) { + // Safety net: a fresh (isNew) clone isn't marked `isTransient`; if we + // unmount mid-free-follow it would be left hidden. Reveal it so it never + // becomes an invisible orphan (place-preset deletes a true cancel). + useScene.getState().updateNode(movingWindowNode.id, { visible: true }) } useLiveTransforms.getState().clear(movingWindowNode.id) useAlignmentGuides.getState().clear() @@ -786,6 +941,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keydown', onShiftToggle) + window.removeEventListener('keyup', onShiftToggle) } }, [movingWindowNode, exitMoveMode]) @@ -802,9 +959,35 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( - - - + <> + + + + {/* Placement ghost shown for the whole move (the real pale node stays + hidden): red off-wall / colliding, green on a valid wall. */} + {ghostPose && ( + + + + )} + {/* 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 && ( + + )} + ) } diff --git a/packages/nodes/src/window/preview.tsx b/packages/nodes/src/window/preview.tsx index e9291d447..f7d140969 100644 --- a/packages/nodes/src/window/preview.tsx +++ b/packages/nodes/src/window/preview.tsx @@ -16,7 +16,15 @@ import type { WindowNode } from './schema' * The root mesh's layer is set to EDITOR_LAYER because the invisible hitbox * material on SCENE_LAYER would poison the WebGPU MRT pass (project gotcha). */ -const WindowPreview = ({ node, invalid }: { node: WindowNode; invalid?: boolean }) => { +const WindowPreview = ({ + node, + invalid, + valid, +}: { + node: WindowNode + invalid?: boolean + valid?: boolean +}) => { const mesh = useMemo(() => { const m = buildWindowPreviewMesh(node) m.layers.set(EDITOR_LAYER) @@ -32,9 +40,9 @@ const WindowPreview = ({ node, invalid }: { node: WindowNode; invalid?: boolean node.sillThickness, ]) - // Ghost treatment (clone + tint + raycast-off) re-applies if `invalid` - // flips; its cleanup only disposes the clones it made. - useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid]) + // Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips; + // its cleanup only disposes the clones it made. + useEffect(() => applyGhost(mesh, { invalid, valid }), [mesh, invalid, valid]) // Geometry is freshly built per `mesh` and owned here — dispose it only // when the mesh itself is replaced/unmounted, never on an `invalid` toggle. diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 6716ad178..c0b481135 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -39,8 +39,14 @@ import { worldToSelectedBuildingLocal, } from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' +import { WindowFloorProjection } from './floor-projection' import WindowPreview from './preview' -import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' +import { + clampToWall, + DEFAULT_WINDOW_SILL_M, + hasWallChildOverlap, + wallLocalToWorld, +} from './window-math' // Shared edge material — reuse across renders, just toggle color const edgeMaterial = new LineBasicNodeMaterial({ @@ -52,10 +58,12 @@ const edgeMaterial = new LineBasicNodeMaterial({ const FALLBACK_WIDTH = 1.5 const FALLBACK_HEIGHT = 1.5 -const FALLBACK_SILL_LIFT = 0.45 +// Off-wall ghost lift = the default sill, so the floating preview matches the +// sill the floor-cursor placement commits at. +const FALLBACK_SILL_LIFT = DEFAULT_WINDOW_SILL_M // Default sill centre for a window snapped from the floor (the floor cursor -// carries no wall-face height). 0.9 m sill + half the 1.5 m default height. -const DEFAULT_SILL_CENTER_Y = 0.9 + FALLBACK_HEIGHT / 2 +// carries no wall-face height): the default sill + half the default height. +const DEFAULT_SILL_CENTER_Y = DEFAULT_WINDOW_SILL_M + FALLBACK_HEIGHT / 2 const roofFallbackPoint = new Vector3() // What currently owns the cursor frame: a wall/roof mesh hover, or null when @@ -79,14 +87,24 @@ const WindowTool: React.FC = () => { // Off-host floating ghost: the real window geometry follows the cursor // over the grid (tinted invalid). Mutually exclusive with the on-host draft. + // `floorY` feeds the floor "shadow" projection; `side` carries the R-flip so + // the floating ghost faces the side that will be committed. const [fallbackPose, setFallbackPose] = useState<{ position: [number, number, number] rotationY: number + floorY: number + side: WindowNode['side'] } | null>(null) + // Ghost preview node — zeroed transform + the live facing side (rebuilds on R). const ghostStub = useMemo( - () => WindowNode.parse({ position: [0, 0, 0], rotation: [0, 0, 0] }), - [], + () => + WindowNode.parse({ + position: [0, 0, 0], + rotation: [0, 0, 0], + side: fallbackPose?.side ?? 'front', + }), + [fallbackPose?.side], ) useEffect(() => { @@ -104,6 +122,9 @@ const WindowTool: React.FC = () => { // to the last wall hover so the flip shows live before commit. let sideFlip = false let lastWallEvent: WallEvent | null = null + // Last open-floor cursor point (level-local X/Z) + floor Y, so an R-flip + // while free-following can re-render the floating ghost with the new facing. + let lastFloorPoint: { pos: [number, number, number]; floorY: number } | null = null const getLevelId = () => useViewer.getState().selection.levelId const getLevelYOffset = () => { @@ -157,21 +178,34 @@ const WindowTool: React.FC = () => { // Off-host fallback: hide the wireframe outline and float the real window // geometry (tinted invalid) at the cursor so the armed tool is visible. - const showGhostAt = (position: [number, number, number]) => { + const showGhostAt = (position: [number, number, number], floorY: number) => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false - setFallbackPose({ position, rotationY: 0 }) + lastFloorPoint = { pos: position, floorY } + // `sideFlip` (R) flips the facing — back is a π yaw on the floating ghost. + setFallbackPose({ + position, + rotationY: sideFlip ? Math.PI : 0, + floorY, + side: sideFlip ? 'back' : 'front', + }) useAlignmentGuides.getState().clear() clearOpeningGuides3D() } const showRoofFallbackCursor = (event: RoofEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) - showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z]) + showGhostAt( + [x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], + getLevelYOffset(), + ) } const showWallFallbackCursor = (event: WallEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) - showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z]) + showGhostAt( + [x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], + getLevelYOffset(), + ) } // Sill alignment (snap + guide): a sibling sill/centre/top wins over the @@ -211,13 +245,16 @@ const WindowTool: React.FC = () => { bypassSnap: boolean, ignoreId?: string, ) => { + // bypassSnap is set by Shift (see callers). Shift = free-place: land at the + // raw cursor but keep the along-wall guides visible. bypass (Alt) still + // hard-disables alignment. const localX = resolveWallSlideAlignment({ wallNode: wall, rawLocalX, width, candidates: alignmentCandidates, - bypass, - bypassSnap, + bypass: bypass && !bypassSnap, + freePlace: bypassSnap, }) const localY = resolvePlacementY({ wall, @@ -454,7 +491,8 @@ const WindowTool: React.FC = () => { bypassSnap, draftRef.current.id, ) - if (!valid) return + // Shift force-places over a collision (the draft stays red as a warning). + if (!valid && !bypassSnap) return commitWindowAtWall(event.node, clampedX, clampedY, side, itemRotation) event.stopPropagation() @@ -485,7 +523,7 @@ const WindowTool: React.FC = () => { lastWallEvent = null const [x, y, z] = event.localPosition destroyDraft() - showGhostAt([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z]) + showGhostAt([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], y) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -553,7 +591,9 @@ const WindowTool: React.FC = () => { const onRoofClick = (event: RoofEvent) => { if (!draftRef.current?.roofSegmentId) return const target = resolveRoofTarget(event) - if (!target?.valid) return + // Shift force-places over a colliding roof-face target (see onWallClick). + if (!target) return + if (!target.valid && event.nativeEvent?.shiftKey !== true) return const { segment, face, position } = target const draft = draftRef.current @@ -617,19 +657,24 @@ const WindowTool: React.FC = () => { hostKind = null } - // R flips the window's facing side mid-placement (front ↔ back), like the - // committed-selected R flip. Only meaningful while snapped to a wall (the - // off-wall ghost has no orientation), so it acts only then — re-applying - // the last wall hover so the snapped preview flips live. + // R flips the window's facing side mid-placement (front ↔ back). ALWAYS + // toggles the persistent flip intent — never a no-op (the old `!lastWallEvent` + // guard dropped R off-wall / before the first hover). Then re-renders the + // current preview so the flip shows live and matches commit. const onKeyDown = (e: KeyboardEvent) => { if (e.key !== 'r' && e.key !== 'R') return - if (!lastWallEvent) return + if (e.repeat) return const t = e.target as HTMLElement | null if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return e.preventDefault() sideFlip = !sideFlip triggerSFX('sfx:item-rotate') - onWallHover(lastWallEvent) + if (lastWallEvent) { + onWallHover(lastWallEvent) + } else if (lastFloorPoint) { + showGhostAt(lastFloorPoint.pos, lastFloorPoint.floorY) + } + // else: no preview yet — `sideFlip` is set, so the first hover/follow uses it. } emitter.on('wall:enter', onWallHover) @@ -690,6 +735,18 @@ const WindowTool: React.FC = () => { )} + {/* Floor "shadow" projection for the off-host ghost (drop-line + footprint) + so the elevated window's plan position is legible while placing. */} + {fallbackPose && ( + + )} ) } diff --git a/packages/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index 182611a2f..eb3547df6 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -1,12 +1,13 @@ -import { - type AnyNodeId, - type DoorNode, - getScaledDimensions, - type ItemNode, - useScene, - type WallNode, - type WindowNode, -} from '@pascal-app/core' +import type { WallNode } from '@pascal-app/core' + +/** + * Default sill height (metres from the floor to the BOTTOM of a window) for a + * fresh window that has no wall-face height yet — the off-wall ghost and the + * floor-cursor placement use it so a new window floats slightly above the + * ground rather than sitting on it. The committed Y is the window's CENTRE, so + * callers add `height / 2`. An existing window keeps its own sill. + */ +export const DEFAULT_WINDOW_SILL_M = 0.5 /** * Converts wall-local (X along wall, Y = height above wall base) to world XYZ. @@ -54,64 +55,8 @@ export function clampToWall( } /** - * Directly checks the wall's children for bounding-box overlap with a proposed window. - * Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center). - * The spatial grid only tracks `item` nodes, so windows must be checked this way. - * Reads the wall's latest children from the store (not the event node) to avoid stale data. + * Wall-child overlap is shared by door + window placement (one source of + * truth in `shared/wall-attach-target.ts`). Re-exported here so existing + * `./window-math` importers don't change. */ -export function hasWallChildOverlap( - wallId: string, - clampedX: number, - clampedY: number, - width: number, - height: number, - ignoreId?: string, -): boolean { - const nodes = useScene.getState().nodes - const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined - if (!wallNode) return true // Block if wall not found - const halfW = width / 2 - const halfH = height / 2 - const newBottom = clampedY - halfH - const newTop = clampedY + halfH - const newLeft = clampedX - halfW - const newRight = clampedX + halfW - - for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) { - if (childId === ignoreId) continue - const child = nodes[childId as AnyNodeId] - if (!child) continue - - let childLeft: number, childRight: number, childBottom: number, childTop: number - - if (child.type === 'item') { - const item = child as ItemNode - if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue - const [w, h] = getScaledDimensions(item) - childLeft = item.position[0] - w / 2 - childRight = item.position[0] + w / 2 - childBottom = item.position[1] // items store bottom Y - childTop = item.position[1] + h - } else if (child.type === 'window') { - const win = child as WindowNode - childLeft = win.position[0] - win.width / 2 - childRight = win.position[0] + win.width / 2 - childBottom = win.position[1] - win.height / 2 // windows store center Y - childTop = win.position[1] + win.height / 2 - } else if (child.type === 'door') { - const door = child as DoorNode - childLeft = door.position[0] - door.width / 2 - childRight = door.position[0] + door.width / 2 - childBottom = door.position[1] - door.height / 2 // doors store center Y - childTop = door.position[1] + door.height / 2 - } else { - continue - } - - const xOverlap = newLeft < childRight && newRight > childLeft - const yOverlap = newBottom < childTop && newTop > childBottom - if (xOverlap && yOverlap) return true - } - - return false -} +export { hasWallChildOverlap } from '../shared/wall-attach-target' diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index efc42c72c..8ad9e1ce7 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -12,7 +12,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa | [node-definitions](node-definitions.md) | Three-checkbox composition model for registry-driven kinds (`geometry` / `renderer` / `system`) | | [materials-and-themes](materials-and-themes.md) | Surface colour: surface roles, colour presets, the textures axis, and scene themes (appearance / ground / clay tints) | | [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 | -| [tools](tools.md) | Editor tools structure, manipulation constraints, and Shift bypass defaults | +| [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and Shift bypass defaults | | [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic | | [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner | | [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` | diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index d8c80f8ba..8ef982ab3 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -99,6 +99,18 @@ export function MyTool() { 3. Add the tool identifier to the `useEditor` tool union type. 4. If the tool requires new node types, add schema + renderer + system first. +## 2D ↔ 3D behavioral parity (default expectation) + +The 2D floor-plan view and the 3D view are two presentations of the **same** edit. Whenever a behavior is applicable to both, it must exist in both — the *mechanism* may differ (a 3D raycast hover vs a plan-space nearest-wall query; a real mesh ghost vs an SVG symbol), but the *felt behavior* should match. When you add or change an interaction in one view, port it to the other in the same change, or write down why it genuinely doesn't apply. + +Concretely, door/window placement/move keeps these in lockstep across `{door,window}/move-tool.tsx` (3D) and `{door,window}/floorplan-move.ts` (2D): + +- **Snap target**: nearest wall to the true cursor (shared `findClosestWallInPlan` / wall raycast), free-follow off-wall, commit only on a host. +- **Move SFX**: a soft `sfx:grid-snap` click per grid step while sliding (free-follow plan XZ or on-wall along-X, quantized + deduped so it isn't a machine-gun) and a soft `sfx:item-pick` cue on the floor→wall snap. Both tools carry an identical `tickGridStep` / `tickWallSnap` pair — keep them in sync. +- **R-flip** facing mid-placement, **Shift** to free snap/alignment (guides stay visible) and force-place over collisions, faithful ghost/symbol, deterministic single-undo commit. + +Tells that you've broken parity: a sound/guide/snap that fires in 3D but is silent in 2D (or vice-versa), or a fix landed in one move file but not its sibling. The two move files are deliberately near-mirrors; diff them when in doubt. + ## Move coexistence: 2D `FloorplanRegistryMoveOverlay` + legacy 3D mover While a kind is mid-migration its move can run through two paths at once: the registry-driven 2D `FloorplanRegistryMoveOverlay` (`def.floorplanMoveTarget`) and the legacy 3D mover (e.g. `MoveItemContent`). Both react to `setMovingNode(node)`, both mount, both want to commit. Two pitfalls surfaced and have stable fixes; replicate the patterns when porting another kind to coexist.