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
@@ -14,7 +14,6 @@ import {
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
consumePlacementDragRelease,
|
||||
EDITOR_LAYER,
|
||||
@@ -27,7 +26,7 @@ import {
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import {
|
||||
@@ -40,8 +39,16 @@ import {
|
||||
type RoofWallOpeningTarget,
|
||||
resolveRoofWallOpeningTarget,
|
||||
} from '../shared/roof-wall-opening-placement'
|
||||
import { resolveOpeningPlacement } from '../shared/wall-attach-target'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
import { WindowFloorProjection } from './floor-projection'
|
||||
import WindowPreview from './preview'
|
||||
import {
|
||||
clampToWall,
|
||||
DEFAULT_WINDOW_SILL_M,
|
||||
hasWallChildOverlap,
|
||||
wallLocalToWorld,
|
||||
} from './window-math'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef_44_44,
|
||||
@@ -65,6 +72,38 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
||||
const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
|
||||
// The window preview ghost. Shown for the WHOLE move so the user always sees
|
||||
// a translucent window tinted by placement state — red off-wall or colliding,
|
||||
// green on a valid wall. The real node stays hidden until commit (the wall
|
||||
// still cuts its hole from the node data). `null` = not previewing. See the
|
||||
// matching `WindowPreview` tint and `MoveDoorTool` for the full rationale.
|
||||
const [ghostPose, setGhostPose] = useState<{
|
||||
position: [number, number, number]
|
||||
rotationY: number
|
||||
tint: 'valid' | 'invalid'
|
||||
// Level floor world-Y, for the floor "shadow" projection (drop-line + footprint).
|
||||
floorY: number
|
||||
// Live facing side — R-flip changes it and the window geometry depends on it,
|
||||
// so the ghost must rebuild with the live side (see `MoveDoorTool`).
|
||||
side: WindowNode['side']
|
||||
} | null>(null)
|
||||
|
||||
// Ghost preview node: the moving window with a zeroed transform + the live
|
||||
// facing side (the ghost is positioned by the `<group position>` wrapper;
|
||||
// `updateWindowMesh` bakes the node's own position/rotation in, so passing the
|
||||
// live node would double-offset). Rebuilds on an R-flip so the preview matches
|
||||
// what commit will place.
|
||||
const ghostSide = ghostPose?.side ?? movingWindowNode.side
|
||||
const ghostNode = useMemo(
|
||||
() => ({
|
||||
...movingWindowNode,
|
||||
side: ghostSide,
|
||||
position: [0, 0, 0] as [number, number, number],
|
||||
rotation: [0, 0, 0] as [number, number, number],
|
||||
}),
|
||||
[movingWindowNode, ghostSide],
|
||||
)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
@@ -91,6 +130,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
roofSegmentId: movingWindowNode.roofSegmentId,
|
||||
roofFace: movingWindowNode.roofFace,
|
||||
metadata: movingWindowNode.metadata,
|
||||
// Free-follow hides the node (visible:false); revert paths restore this.
|
||||
visible: movingWindowNode.visible,
|
||||
}
|
||||
|
||||
// In move mode (existing window) mark it transient so its mesh skips the live wall CSG
|
||||
@@ -113,6 +154,29 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
// mesh event owns the same pointermove — that's the only thing that snaps.
|
||||
let freeFollowing = false
|
||||
let lastMeshEventTime = -1
|
||||
// Last open-floor cursor point (level-local X/Z), so an R-flip while free-
|
||||
// following can re-run the ghost at the same spot with the new facing.
|
||||
let lastFloorPoint: [number, number] | null = null
|
||||
// Live Shift state (force-place) — lets the preview tint re-evaluate when
|
||||
// Shift is pressed/released with the pointer stationary (see `MoveDoorTool`).
|
||||
let shiftHeld = false
|
||||
// Movement SFX: ONE soft `sfx:grid-snap` click per grid step — identical
|
||||
// whether free-following over floor or sliding along a wall (the user's
|
||||
// ask). Always keyed on the RAW cursor (continuous ~0.1m cadence), never the
|
||||
// snapped along-wall value. Guards: `lastStepKey` (cell change) +
|
||||
// `lastTickFrame` (one tick per DOM pointermove). No separate snap cue — a
|
||||
// distinct floor→wall sound was the "double" the user heard. See `MoveDoorTool`.
|
||||
const STEP_M = 0.1
|
||||
let lastStepKey: string | null = null
|
||||
let lastTickFrame = -1
|
||||
const tickGridStep = (frame: number, ...coords: number[]) => {
|
||||
if (frame === lastTickFrame) return
|
||||
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
|
||||
if (key === lastStepKey) return
|
||||
lastStepKey = key
|
||||
lastTickFrame = frame
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
// The window's chosen facing side. R flips it mid-placement (front ↔ back),
|
||||
// matching the committed-selected R flip. Initialised from the moving node.
|
||||
let sideOverride: WindowNode['side'] = movingWindowNode.side
|
||||
@@ -128,7 +192,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
wallId: string
|
||||
side: WindowNode['side']
|
||||
itemRotation: number
|
||||
cursorRotation: number
|
||||
clampedX: number
|
||||
clampedY: number
|
||||
valid: boolean
|
||||
@@ -160,12 +223,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
// Sill-center height used while the window isn't on a wall (free-follow and
|
||||
// proximity). Fresh preset clones are created at position [0,0,0], which
|
||||
// would bury half the window below the floor; default such windows to a
|
||||
// ~0.9m sill so the ghost floats at a realistic height. An existing window
|
||||
// keeps its own sill.
|
||||
const DEFAULT_SILL = 0.9
|
||||
// small sill so the ghost floats slightly above the ground. An existing
|
||||
// window keeps its own sill.
|
||||
const getSillCenterY = () => {
|
||||
const y = movingWindowNode.position[1]
|
||||
return y > 0.1 ? y : DEFAULT_SILL + movingWindowNode.height / 2
|
||||
return y > 0.1 ? y : DEFAULT_WINDOW_SILL_M + movingWindowNode.height / 2
|
||||
}
|
||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||
spatialGridManager.getSlabElevationForWall(
|
||||
@@ -178,6 +240,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
setGhostPose(null)
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every OTHER alignable object (the
|
||||
@@ -214,8 +277,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const side = sideOverride ?? faceSide
|
||||
const rotationOffset = side !== faceSide ? Math.PI : 0
|
||||
const itemRotation = calculateItemRotation(event.normal) + rotationOffset
|
||||
const cursorRotation =
|
||||
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset
|
||||
|
||||
const rawLocalX = event.localPosition[0]
|
||||
const rawLocalY = event.localPosition[1]
|
||||
@@ -256,8 +317,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
rawLocalX: targetLocalX,
|
||||
width: movingWindowNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
// Alt still hard-disables alignment (no guides). Shift = free-place:
|
||||
// land at the raw cursor but keep showing the along-wall guides.
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
freePlace: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
@@ -281,7 +344,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
wallId: event.node.id,
|
||||
side,
|
||||
itemRotation,
|
||||
cursorRotation,
|
||||
clampedX,
|
||||
clampedY,
|
||||
valid,
|
||||
@@ -290,6 +352,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
||||
// Same click as the off-wall ghost: one grid-snap tick per grid step,
|
||||
// keyed on the RAW cursor along-wall position (not the snapped clampedX).
|
||||
// Per-frame guard collapses duplicate wall events on the same pointermove.
|
||||
tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0])
|
||||
// Keep the REAL node hidden and show a tinted ghost in the wall opening —
|
||||
// green when placeable, red when it collides — matching the free-follow
|
||||
// ghost so validity reads at a glance (see MoveDoorTool). The node position
|
||||
// is still written so the wall cuts the hole at the right spot.
|
||||
if (currentHostId !== target.wallId) {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [target.clampedX, target.clampedY, 0],
|
||||
@@ -299,6 +369,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
wallId: target.wallId,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
visible: false,
|
||||
})
|
||||
markHostDirty(currentHostId)
|
||||
currentHostId = target.wallId
|
||||
@@ -316,17 +387,28 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
})
|
||||
markHostDirtyThrottled(target.wallId)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
const placement = resolveOpeningPlacement({ collides: !target.valid, forcePlace: shiftHeld })
|
||||
// Ghost world yaw must equal the committed wall-CHILD's world yaw
|
||||
// (-wallAngle + itemRotation); `cursorRotation` is π off here. See
|
||||
// `MoveDoorTool.applyPreview`.
|
||||
const wallAngle = Math.atan2(
|
||||
target.wallNode.end[1] - target.wallNode.start[1],
|
||||
target.wallNode.end[0] - target.wallNode.start[0],
|
||||
)
|
||||
setGhostPose({
|
||||
position: wallLocalToWorld(
|
||||
target.wallNode,
|
||||
target.clampedX,
|
||||
target.clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(target.event),
|
||||
),
|
||||
target.cursorRotation,
|
||||
target.valid,
|
||||
)
|
||||
rotationY: target.itemRotation - wallAngle,
|
||||
tint: placement.tint,
|
||||
floorY: getLevelYOffset() + getSlabElevation(target.event),
|
||||
side: target.side,
|
||||
})
|
||||
|
||||
publishOpeningGuidesForWallEvent({
|
||||
wall: target.wallNode,
|
||||
@@ -410,6 +492,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
parentId: target.wallId,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
// Hidden during free-follow; the committed window must be visible.
|
||||
visible: true,
|
||||
})
|
||||
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
||||
placedId = node.id
|
||||
@@ -425,6 +509,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
visible: original.visible,
|
||||
})
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
@@ -436,6 +521,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
wallId: target.wallId,
|
||||
roofSegmentId: undefined,
|
||||
metadata: {},
|
||||
visible: true,
|
||||
})
|
||||
|
||||
if (original.parentId && original.parentId !== target.wallId) {
|
||||
@@ -462,7 +548,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
// Shift force-places: commit even when the window overlaps another opening.
|
||||
// The preview keeps its red invalid tint as a warning; Shift just lifts the
|
||||
// commit block. Read shift from THIS event so it's never stale at commit.
|
||||
if (!target) return
|
||||
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
|
||||
commitToWall(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -480,13 +570,28 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
lastRoofEvent = null
|
||||
}
|
||||
|
||||
// Free-follow: the window rides the cursor over empty floor, parented to
|
||||
// the level like an item node, kept at a sensible sill height. No wall to
|
||||
// attach to, so it is not committable here.
|
||||
const freeFollowAt = (localX: number, localZ: number) => {
|
||||
// Reveal the real window node + drop the ghost. Used by the roof-face path,
|
||||
// which previews with the real mesh (the ghost-tint flow is wall-specific).
|
||||
const revealRealNode = () => {
|
||||
setGhostPose(null)
|
||||
const live = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
|
||||
| WindowNode
|
||||
| undefined
|
||||
if (live && live.visible === false) {
|
||||
useScene.getState().updateNode(movingWindowNode.id, { visible: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Free-follow: over open floor there's no wall to host the window, so hide
|
||||
// the real (pale, near-invisible-on-grid) node and float a red translucent
|
||||
// ghost at the cursor — same treatment the raw `WindowTool` build path uses.
|
||||
const freeFollowAt = (localX: number, localZ: number, frame: number) => {
|
||||
freeFollowing = true
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
// Click per grid cell as the ghost slides over open floor (X+Z) — the
|
||||
// same `tickGridStep` the on-wall slide uses, so both feel identical.
|
||||
tickGridStep(frame, localX, localZ)
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
const levelId = getLevelId()
|
||||
@@ -503,6 +608,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
wallId: undefined,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
visible: false,
|
||||
})
|
||||
currentHostId = levelId
|
||||
} else {
|
||||
@@ -510,8 +616,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
position: [localX, sillCenterY, localZ],
|
||||
rotation: [0, yaw, 0],
|
||||
side: sideOverride,
|
||||
visible: false,
|
||||
})
|
||||
}
|
||||
// Float the red (invalid — no wall) ghost at the cursor, level-Y lifted to
|
||||
// the sill center (sideOverride carries the R-flip so the ghost matches).
|
||||
setGhostPose({
|
||||
position: [localX, getLevelYOffset() + sillCenterY, localZ],
|
||||
rotationY: yaw,
|
||||
tint: 'invalid',
|
||||
floorY: getLevelYOffset(),
|
||||
side: sideOverride,
|
||||
})
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
@@ -523,7 +639,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
// snapping engages only when the cursor ray actually hovers a wall.
|
||||
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
|
||||
const [x, , z] = event.localPosition
|
||||
freeFollowAt(x, z)
|
||||
lastFloorPoint = [x, z]
|
||||
freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1)
|
||||
}
|
||||
|
||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||
@@ -564,6 +681,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
// Opening guides are wall-specific; clear them when over a roof face.
|
||||
clearOpeningGuides3D()
|
||||
// On a roof face the real mesh is the preview — drop the ghost + reveal.
|
||||
revealRealNode()
|
||||
if (currentHostId !== target.segment.id) {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: target.position,
|
||||
@@ -573,6 +692,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
wallId: undefined,
|
||||
roofSegmentId: target.segment.id,
|
||||
roofFace: target.face.id,
|
||||
visible: true,
|
||||
})
|
||||
markHostDirty(currentHostId)
|
||||
currentHostId = target.segment.id
|
||||
@@ -590,7 +710,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = resolveRoofMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
// Shift force-places over a colliding roof-face target too (see onWallClick).
|
||||
if (!target) return
|
||||
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
|
||||
committed = true
|
||||
const segmentId = target.segment.id
|
||||
|
||||
@@ -613,6 +735,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
roofSegmentId: segmentId,
|
||||
roofFace: target.face.id,
|
||||
parentId: segmentId,
|
||||
visible: true,
|
||||
})
|
||||
useScene.getState().createNode(node, segmentId as AnyNodeId)
|
||||
placedId = node.id
|
||||
@@ -626,6 +749,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
visible: original.visible,
|
||||
})
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
@@ -638,6 +762,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
roofSegmentId: segmentId,
|
||||
roofFace: target.face.id,
|
||||
metadata: {},
|
||||
visible: true,
|
||||
})
|
||||
|
||||
if (original.parentId && original.parentId !== segmentId) {
|
||||
@@ -682,6 +807,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
visible: original.visible,
|
||||
})
|
||||
if (original.parentId) markHostDirty(original.parentId)
|
||||
}
|
||||
@@ -693,8 +819,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
// Free-following over open floor can't commit (no wall). A wall hover
|
||||
// target commits via commitToWall; a roof face via onRoofClick.
|
||||
if (lastTarget?.valid && !freeFollowing) {
|
||||
// target commits via commitToWall; a roof face via onRoofClick. Shift
|
||||
// force-places over a colliding wall target (tint stays red as a warning);
|
||||
// read shift from this pointerup so it's current at commit.
|
||||
if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) {
|
||||
commitToWall(lastTarget)
|
||||
return
|
||||
}
|
||||
@@ -714,18 +842,27 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
) {
|
||||
return
|
||||
}
|
||||
const onWall = lastTarget !== null
|
||||
if (!(onWall || freeFollowing)) return
|
||||
// Ignore OS key-repeat so a held R doesn't flip many times per press.
|
||||
if (e.repeat) return
|
||||
e.preventDefault()
|
||||
// ALWAYS toggle the persistent flip intent — never a no-op (the old gate
|
||||
// dropped R before the first pointermove). Then re-render the current
|
||||
// preview so the flip shows live and matches commit. See `MoveDoorTool`.
|
||||
sideOverride = sideOverride === 'front' ? 'back' : 'front'
|
||||
triggerSFX('sfx:item-rotate')
|
||||
if (onWall) {
|
||||
const next = resolveMoveTarget(lastTarget!.event)
|
||||
if (lastTarget) {
|
||||
const next = resolveMoveTarget(lastTarget.event)
|
||||
if (next) {
|
||||
lastTarget = next
|
||||
applyPreview(next)
|
||||
}
|
||||
} else if (lastFloorPoint) {
|
||||
// Free-following: re-run at the same spot so the floating ghost rebuilds
|
||||
// with the flipped side.
|
||||
freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1)
|
||||
} else {
|
||||
// No preview yet (R before the first pointermove): flip the hidden node
|
||||
// so the first preview/commit already reflects the chosen side.
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
side: sideOverride,
|
||||
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0],
|
||||
@@ -733,6 +870,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
}
|
||||
|
||||
// Shift toggles force-place — re-run the on-wall preview so the tint flips
|
||||
// green↔red live (pointer stationary). Commit gates still read shift fresh.
|
||||
const onShiftToggle = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Shift') return
|
||||
const held = e.type === 'keydown'
|
||||
if (held === shiftHeld) return
|
||||
shiftHeld = held
|
||||
if (!committed && lastTarget) applyPreview(lastTarget)
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
@@ -745,6 +892,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keydown', onShiftToggle)
|
||||
window.addEventListener('keyup', onShiftToggle)
|
||||
|
||||
return () => {
|
||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
||||
@@ -766,9 +915,15 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
visible: original.visible,
|
||||
})
|
||||
if (original.parentId) markHostDirty(original.parentId)
|
||||
}
|
||||
} else if (current && current.visible === false) {
|
||||
// Safety net: a fresh (isNew) clone isn't marked `isTransient`; if we
|
||||
// unmount mid-free-follow it would be left hidden. Reveal it so it never
|
||||
// becomes an invisible orphan (place-preset deletes a true cancel).
|
||||
useScene.getState().updateNode(movingWindowNode.id, { visible: true })
|
||||
}
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
@@ -786,6 +941,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keydown', onShiftToggle)
|
||||
window.removeEventListener('keyup', onShiftToggle)
|
||||
}
|
||||
}, [movingWindowNode, exitMoveMode])
|
||||
|
||||
@@ -802,9 +959,35 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
||||
</group>
|
||||
<>
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
||||
</group>
|
||||
{/* Placement ghost shown for the whole move (the real pale node stays
|
||||
hidden): red off-wall / colliding, green on a valid wall. */}
|
||||
{ghostPose && (
|
||||
<group position={ghostPose.position} rotation-y={ghostPose.rotationY}>
|
||||
<WindowPreview
|
||||
invalid={ghostPose.tint === 'invalid'}
|
||||
node={ghostNode}
|
||||
valid={ghostPose.tint === 'valid'}
|
||||
/>
|
||||
</group>
|
||||
)}
|
||||
{/* Floor "shadow" projection: footprint + dashed drop-line, so an elevated
|
||||
window's plan position is legible while placing. World-space, so it's a
|
||||
sibling of the ghost group, not a child. */}
|
||||
{ghostPose && (
|
||||
<WindowFloorProjection
|
||||
centerX={ghostPose.position[0]}
|
||||
centerY={ghostPose.position[1]}
|
||||
centerZ={ghostPose.position[2]}
|
||||
floorY={ghostPose.floorY}
|
||||
rotationY={ghostPose.rotationY}
|
||||
width={movingWindowNode.width}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user