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