* feat(editor): always-visible translucent placement ghost for openings + roof accessories When a host-surface placement tool is armed, the node's real geometry now follows the cursor everywhere as a translucent ghost: tinted invalid (red) and unconfirmable off-host, snapping onto its host surface (wall/roof) with the existing valid/invalid affordances when near. This replaces the old red wireframe box (door/window) and red DragBoundingBox (roof accessories), so the armed tool is visible before the cursor reaches a placeable surface. - New shared `applyGhost` helper (nodes/src/shared/ghost-materials.ts): clones materials, disables raycast (avoids cursor-ray starvation), tints invalid; cleanup disposes only the clones. - New door/window preview components built from the real geometry via new `buildDoorPreviewMesh`/`buildWindowPreviewMesh` viewer exports; tools float the ghost via a `fallbackPose` that is mutually exclusive with the on-host draft + wireframe outline. - `RoofAttachmentFallbackPreview` gains a `ghost` prop; all 11 roof-accessory tools pass their real preview (invalid-tinted) instead of a box `size`. Snapping behavior is unchanged (no proximity snap yet). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): magnetic proximity wall-snap for door/window placement The door/window ghost now follows the cursor over the floor like a moving item and magnetically snaps onto the nearest wall within range (1.5 m), then releases back to free-follow when the cursor moves away — instead of only attaching on a direct wall-mesh ray hit. A grid-snap sound plays each time it snaps onto a new spot, so it reads as moving a physical object that can only land on walls. - Plan-space proximity via the existing `findClosestWallInPlan` (the same helper the 2D floor-plan move uses): level-scoped, skips curved walls, returns wall + along-wall localX + side + wall-local rotation. - `grid:move` drives the snap and `grid:click` commits when proximity-snapped; a direct wall-mesh hover (wall:enter/move) still owns the precise face side. Both paths share `applyWallTarget` (create the draft once, reparent only on an actual wall change) and a shared commit that refreshes alignment anchors. - Disambiguation without a stuck flag: a per-pointermove `timeStamp` gate (R3F + the grid raycast share the source DOM event) plus a `cameraDragging` guard and stale-`hostKind` reset, so a missed wall:leave during a camera orbit can't strand the draft. - Window keeps its sill height on the floor path (the floor cursor carries no wall-face Y) — defaults to a ~0.9 m sill, mirroring the 2D move. - Shift bypasses the along-wall grid/alignment snap but still attaches to the nearest wall, matching the 3D-hover and 2D-move conventions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): door/window preset placement follows the cursor over open floor The community preset/catalog flow places doors and windows through the isNew move path (MoveDoorTool / MoveWindowTool), which had no free-follow: the fresh clone was parented to the level at the origin and only became visible once the cursor reached a wall, so over empty floor nothing tracked the cursor. Now the move tools mirror the def.tool placement behaviour: - Off-wall, the real node rides the cursor like an item (reparented to the level, positioned at the building-local cursor) so it's obvious what's being placed before it attaches. - Within range of a wall it magnetically snaps on via findClosestWallInPlan (the same plan-space helper the 2D move uses), releasing back to free-follow when the cursor moves away, and plays the grid-snap sound on each new snap. - grid:click commits only when snapped (open floor is a no-op — a door/window needs a wall); the wall/roof mesh-hover paths are unchanged and still own their own click. A per-pointermove timeStamp gate + cameraDragging guard keep the floor handler from fighting a wall/roof hover. - Windows default to a ~0.9m sill while off-wall (fresh preset clones carry position [0,0,0], which buried half the window below the floor). The wall/roof commit body is extracted into a shared commitToWall so the mesh-click and proximity-click paths stay identical. Existing-node moves are fully restored on cancel/unmount (the node stays isTransient through free-follow). Standalone-editor def.tool placement already had this in a prior commit; this brings the community move path to parity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): 2D floorplan ghost follows the cursor for door/window placement Mirrors the 3D free-follow in the top-down floor plan: while placing a door or window, a loose footprint rectangle now follows the cursor over open floor so it's obvious what's being placed before it snaps to a wall. The instant the cursor nears a wall, the existing synthesized wall:enter/move path takes over and the real on-wall door/window symbol (swing arc, etc.) replaces the ghost. - The opening-placement pointer-move handler in floorplan-panel sets a new `openingGhostPoint` on the off-wall (findClosestWallPoint miss) branch and clears it on a wall hit; a loose width × 0.1m rectangle renders at that point inside the floor-plan scene group (same world→SVG transform as every glyph). - Width comes from the moving node or the kind default (door 0.9 / window 1.5). - The ghost clears when opening placement ends (tool/mode change, cancel, commit) and on level change, so no stale rectangle lingers. Deliberately a plain rectangle, not the full swing-arc symbol: off-wall there's no host to orient the swing to. The shared door/window def.floorplan builders are untouched — overloading them with a wall-less fallback would make roof-hosted doors (parent is a roof segment, builder returns null today) draw stray rectangles in plan. Keeping the preview in the editor layer avoids that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): faithful 2D door/window ghost, 2D wall-slide, R-flip during placement; 3D snap only on hover Three placement fixes plus a snapping revision, for both the standalone def.tool path and the community isNew move path (door + window): - 2D faithful ghost: the off-wall placement ghost now renders the real blueprint symbol (door swing arc / window panes) following the cursor, not a bare rectangle. Done by publishing a transient opening on a synthetic wall to usePlacementPreview (extended with a `parentNode` fed as the builder's ctx.parent) so the real def.floorplan builder draws it. Cleared on wall-hit, on commit, on placement-inactive, and on level change. - 2D slide-along-wall: the floor-plan registry layer ignored useLiveTransforms for door/window (only floor-placed + slab/ceiling/zone), so a same-wall slide updated the 3D mesh but left the 2D symbol frozen. It now merges the wall-local live position/rotation onto the node (keeping parentId) so the 2D symbol slides with the cursor. - R-flip during placement: pressing R now flips a door/window's facing (front ↔ back, rotation += π) before commit — the placement tools own R while placing (the global selection-based R/T handler stands down via isPlacingOpening so it can't double-fire). No-op on roof faces (front-only). - 3D snapping zero-padding: removed the 1.5 m proximity magnet; in 3D the opening free-follows the cursor over open floor and snaps only when the cursor ray actually hovers a wall/roof mesh (big raycast targets). 2D keeps its 0.5 m findClosestWallPoint padding since plan walls are thin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): make FloorplanRegistryMoveOverlay the sole 2D owner of door/window placement Community door/window placement (movingNode + metadata.isNew) had TWO 2D paths running at once: the floorplan-panel synthesized wall:*/grid:* events (driving the 3D MoveDoorTool) AND FloorplanRegistryMoveOverlay via def.floorplanMoveTarget. They fought — R didn't flip the 2D symbol and clicks didn't commit in 2D, while 3D worked. The overlay + floorplanMoveTarget is the purpose-built 2D owner (faithful def.floorplan symbol, plan-space CTM coords, Figma snap, single-undo commit), so it now owns 2D opening placement when movingNode is set: - floorplan-panel: the opening pointer-move branch + the registry grid catch-all + the background-click catch-all all now exclude the door/window MOVE case (`!isOpeningMoveActive`), so the synthesized events no longer fire for it (they still drive pure raw-build placement, which has no movingNode). Without the catch-all exclusions the move case fell through to grid:move/grid:click, which re-drove the 3D tool's free-follow and consumed the commit click. - R-flip in 2D: `FloorplanMoveTargetSession` gains optional `flipSide()`; door/window floorplan-move implement it (XOR the wall-derived side + π rotation, re-running the last apply). The overlay's keydown calls `session.flipSide()` on R — gated on `hasMovedSinceStart` so it only fires when the 2D pane is the active mover (the 3D MoveDoorTool owns R in 3D/split; this prevents a double flip / double cue on one R press). - Commit in 2D now flows solely through the overlay's pointerup (no competing synthesized wall:click), so click-to-place commits. The global use-keyboard R/T already stands down during opening placement (isPlacingOpening), so a selected node can't also flip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): 2D door/window move free-follows the cursor off-wall and commits only on a wall Moving an existing door/window in the 2D floor plan: the move target's `apply` early-returned off-wall (`if (!hit) return`), so the opening stayed frozen on its old wall instead of following the cursor between walls (3D free-follows), and an off-wall confirm click committed the stale last-wall position — looking like the placement failed. Now `doorFloorplanMoveTarget`/`windowFloorplanMoveTarget` mirror the 3D move: - Off-wall, `apply` free-follows the cursor — hides the real node and floats the faithful door/window symbol at the cursor via a synthetic wall published to `usePlacementPreview` (the same preview layer fresh placement uses). The real node is `visible:false` so the registry layer skips it (no double symbol). - Back on a wall, it clears the ghost, reveals the real node, and snaps as before. - `canCommit` returns false while off-wall, so an open-floor click reverts to the pre-move snapshot (door returns to its wall) instead of committing in mid-air — matching the 3D move, where clicking open floor commits nothing. On a wall the commit lands normally. The overlay's snapshot revert (cancel / invalid commit) and the on-wall `apply`'s `visible:true` restore both guarantee the node is never left hidden after a move. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): 2D opening snap picks the true nearest wall, with a tighter radius The 2D door/window snap felt too aggressive and could grab a wall further away than the one the cursor was actually nearest. Root cause in `findClosestWallInPlan`: it compared a candidate's true segment distance against the previous best's `perpDistance` (signed offset to the wall's infinite line, not the clamped segment distance). Near a wall end those diverge, so a closer wall could be rejected / a farther one kept. - Track the best segment distance and keep the strict minimum — the wall chosen is now always the single closest segment to the cursor (true nearest), which resolves correctly when many walls sit close together. - Tighten the snap radius from 1.5 m to 0.4 m: plan walls are thin, so the old radius snapped from far away. The opening now free-follows the cursor until it's genuinely near a wall. Only the 2D move/placement targets use this helper (3D snaps on raycast hover); wall-attached items share the same improved nearest-wall behaviour. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): true-nearest 2D opening snap with dev hit-area overlay Round 7 of the placement-ghosts work. Make the 2D door/window wall snap always pick the wall nearest the cursor, fix fresh-placement snapping/slide in 2D, and add a dev-only overlay that visualises each wall's snap region. - Extract the plan-space nearest-wall-segment math to core (`lib/wall-distance.ts`: collectLevelWallSegments / closestOnSegment / nearestWallSegment / WALL_SNAP_DISTANCE_M). `findClosestWallInPlan` delegates to it, so the snap and the debug overlay share one source of truth. WallHit contract unchanged. - door/window 2D move now resolves the host level via the shared `getOpeningHostLevelId` (wall-hosted, roof-hosted, AND fresh-placement parented straight to the level — the last case previously resolved to the building, so a new opening never snapped in 2D). - Cursor resolver switched to absolute mode: query the snap with the true cursor, not the original-wall position + grab delta, so it picks the cursor-nearest wall (matching the 3D move) instead of a far wall across a thin gap. - 2D move clears any stale `useLiveTransforms` entry for the node each apply: the registry layer renders door/window from the live transform in preference to the scene node, so a leftover entry from the 3D tool froze the 2D slide for fresh / re-armed openings. - Fresh window defaults to a 0.9 m sill in 2D (was sitting half-below floor at y=0), matching the 3D MoveWindowTool. - New dev-only FloorplanVoronoiLayer + `show2dVoronoi` editor flag: draws each wall's snap hit area as an analytic capsule (no grid sampling), gated on a developer-menu toggle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
735 lines
25 KiB
TypeScript
735 lines
25 KiB
TypeScript
import {
|
|
type AnyNodeId,
|
|
collectAlignmentAnchors,
|
|
DoorNode,
|
|
emitter,
|
|
type GridEvent,
|
|
isCurvedWall,
|
|
type RoofEvent,
|
|
type RoofNode,
|
|
sceneRegistry,
|
|
spatialGridManager,
|
|
useLiveTransforms,
|
|
useScene,
|
|
type WallEvent,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
calculateCursorRotation,
|
|
calculateItemRotation,
|
|
consumePlacementDragRelease,
|
|
EDITOR_LAYER,
|
|
getSideFromNormal,
|
|
isValidWallSideFace,
|
|
stripPlacementMetadataFlags,
|
|
triggerSFX,
|
|
useAlignmentGuides,
|
|
useEditor,
|
|
} from '@pascal-app/editor'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
|
import {
|
|
getRoofWallOpeningCursorPose,
|
|
type RoofWallOpeningTarget,
|
|
resolveRoofWallOpeningTarget,
|
|
} from '../shared/roof-wall-opening-placement'
|
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
|
|
|
const edgeMaterial = new LineBasicNodeMaterial({
|
|
color: 0xef_44_44,
|
|
linewidth: 3,
|
|
depthTest: false,
|
|
depthWrite: false,
|
|
})
|
|
|
|
const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
|
|
const cursorGroupRef = useRef<Group>(null!)
|
|
|
|
const exitMoveMode = useCallback(() => {
|
|
useEditor.getState().setMovingNode(null)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
useScene.temporal.getState().pause()
|
|
|
|
const meta =
|
|
typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null
|
|
? (movingDoorNode.metadata as Record<string, unknown>)
|
|
: {}
|
|
const isNew = !!meta.isNew
|
|
|
|
const original = {
|
|
position: [...movingDoorNode.position] as [number, number, number],
|
|
rotation: [...movingDoorNode.rotation] as [number, number, number],
|
|
side: movingDoorNode.side,
|
|
parentId: movingDoorNode.parentId,
|
|
wallId: movingDoorNode.wallId,
|
|
// Doors can be hosted on a roof-segment wall face. Moving onto a
|
|
// wall re-anchors as wall-hosted (roofSegmentId cleared); reverts
|
|
// must restore the roof host.
|
|
roofSegmentId: movingDoorNode.roofSegmentId,
|
|
roofFace: movingDoorNode.roofFace,
|
|
metadata: movingDoorNode.metadata,
|
|
}
|
|
|
|
if (!isNew) {
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
metadata: { ...meta, isTransient: true },
|
|
})
|
|
}
|
|
|
|
let currentHostId: string | null = movingDoorNode.parentId
|
|
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
|
let committed = false
|
|
// Off-wall free-follow: when the cursor is over empty floor (no wall under
|
|
// the ray) the door is parented to the level and tracks the cursor like an
|
|
// item node. `freeFollowing` distinguishes that state so the placement
|
|
// commit no-ops in open space (a door needs a wall). `lastMeshEventTime`
|
|
// defers the floor handler whenever a wall/roof mesh event owns the same
|
|
// pointermove (shared DOM timeStamp) — that's the only thing that snaps.
|
|
let freeFollowing = false
|
|
let lastMeshEventTime = -1
|
|
// The door's chosen facing side. R flips it mid-placement (front ↔ back,
|
|
// same as the committed-selected R flip) so the user can reorient before
|
|
// committing. Initialised from the moving node's side.
|
|
let sideOverride: DoorNode['side'] = movingDoorNode.side
|
|
let lastTarget: {
|
|
wallNode: WallEvent['node']
|
|
wallId: string
|
|
side: DoorNode['side']
|
|
itemRotation: number
|
|
cursorRotation: number
|
|
clampedX: number
|
|
clampedY: number
|
|
valid: boolean
|
|
event: WallEvent
|
|
} | null = null
|
|
let lastRoofEvent: RoofEvent | null = null
|
|
|
|
const markHostDirty = (hostId: string | null) => {
|
|
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
|
}
|
|
const lastHostDirtyAt = new Map<string, number>()
|
|
const markHostDirtyThrottled = (hostId: string | null) => {
|
|
if (!hostId) return
|
|
const now = globalThis.performance?.now?.() ?? Date.now()
|
|
const last = lastHostDirtyAt.get(hostId) ?? 0
|
|
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
|
|
if (now - last > 120) {
|
|
lastHostDirtyAt.set(hostId, now)
|
|
markHostDirty(hostId)
|
|
}
|
|
}
|
|
|
|
const getLevelId = () => useViewer.getState().selection.levelId
|
|
const getLevelYOffset = () => {
|
|
const id = getLevelId()
|
|
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
|
}
|
|
const getSlabElevation = (wallEvent: WallEvent) =>
|
|
spatialGridManager.getSlabElevationForWall(
|
|
wallEvent.node.parentId ?? '',
|
|
wallEvent.node.start,
|
|
wallEvent.node.end,
|
|
)
|
|
|
|
const hideCursor = () => {
|
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
|
useAlignmentGuides.getState().clear()
|
|
}
|
|
|
|
// Alignment candidates — anchors of every OTHER alignable object (the
|
|
// moving door is excluded so it never aligns to itself).
|
|
const alignmentCandidates = collectAlignmentAnchors(
|
|
useScene.getState().nodes,
|
|
movingDoorNode.id,
|
|
)
|
|
|
|
const updateCursor = (
|
|
worldPosition: [number, number, number],
|
|
cursorRotationY: number,
|
|
valid: boolean,
|
|
) => {
|
|
const group = cursorGroupRef.current
|
|
if (!group) return
|
|
group.visible = true
|
|
group.position.set(...worldPosition)
|
|
group.rotation.y = cursorRotationY
|
|
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
|
}
|
|
|
|
const getPlacementOrientation = (event: WallEvent) => {
|
|
const faceSide = getSideFromNormal(event.normal)
|
|
const side = sideOverride ?? faceSide
|
|
const rotationOffset = side !== faceSide ? Math.PI : 0
|
|
return {
|
|
side,
|
|
itemRotation: calculateItemRotation(event.normal) + rotationOffset,
|
|
cursorRotation:
|
|
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset,
|
|
}
|
|
}
|
|
|
|
const resolveMoveTarget = (event: WallEvent) => {
|
|
if (!isValidWallSideFace(event.normal)) return
|
|
if (isCurvedWall(event.node)) {
|
|
hideCursor()
|
|
return
|
|
}
|
|
if (event.node.parentId !== getLevelId()) return
|
|
|
|
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
|
|
|
const rawLocalX = event.localPosition[0]
|
|
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
|
|
dragAnchor = {
|
|
wallId: event.node.id,
|
|
rawX: rawLocalX,
|
|
startX: event.node.id === original.parentId ? original.position[0] : rawLocalX,
|
|
}
|
|
}
|
|
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
|
|
const localX = resolveWallSlideAlignment({
|
|
wallNode: event.node,
|
|
rawLocalX: targetLocalX,
|
|
width: movingDoorNode.width,
|
|
candidates: alignmentCandidates,
|
|
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
|
bypassSnap: event.nativeEvent?.shiftKey === true,
|
|
})
|
|
const { clampedX, clampedY } = clampToWall(
|
|
event.node,
|
|
localX,
|
|
movingDoorNode.width,
|
|
movingDoorNode.height,
|
|
)
|
|
|
|
const valid = !hasWallChildOverlap(
|
|
event.node.id,
|
|
clampedX,
|
|
clampedY,
|
|
movingDoorNode.width,
|
|
movingDoorNode.height,
|
|
movingDoorNode.id,
|
|
)
|
|
|
|
return {
|
|
wallNode: event.node,
|
|
wallId: event.node.id,
|
|
side,
|
|
itemRotation,
|
|
cursorRotation,
|
|
clampedX,
|
|
clampedY,
|
|
valid,
|
|
event,
|
|
}
|
|
}
|
|
|
|
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
|
if (currentHostId !== target.wallId) {
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
position: [target.clampedX, target.clampedY, 0],
|
|
rotation: [0, target.itemRotation, 0],
|
|
side: target.side,
|
|
parentId: target.wallId,
|
|
wallId: target.wallId,
|
|
roofSegmentId: undefined,
|
|
roofFace: undefined,
|
|
})
|
|
markHostDirty(currentHostId)
|
|
currentHostId = target.wallId
|
|
} else {
|
|
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
|
|
if (doorMesh) {
|
|
doorMesh.position.set(target.clampedX, target.clampedY, 0)
|
|
doorMesh.rotation.set(0, target.itemRotation, 0)
|
|
doorMesh.updateMatrixWorld(true)
|
|
}
|
|
}
|
|
useLiveTransforms.getState().set(movingDoorNode.id, {
|
|
position: [target.clampedX, target.clampedY, 0],
|
|
rotation: target.itemRotation,
|
|
})
|
|
markHostDirtyThrottled(target.wallId)
|
|
|
|
updateCursor(
|
|
wallLocalToWorld(
|
|
target.wallNode,
|
|
target.clampedX,
|
|
target.clampedY,
|
|
getLevelYOffset(),
|
|
getSlabElevation(target.event),
|
|
),
|
|
target.cursorRotation,
|
|
target.valid,
|
|
)
|
|
}
|
|
|
|
const onWallEnter = (event: WallEvent) => {
|
|
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
|
const target = resolveMoveTarget(event)
|
|
if (!target) {
|
|
onWallLeave()
|
|
return
|
|
}
|
|
freeFollowing = false
|
|
lastTarget = target
|
|
lastRoofEvent = null
|
|
applyPreview(target)
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const onWallMove = (event: WallEvent) => {
|
|
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
|
if (!isValidWallSideFace(event.normal)) {
|
|
onWallLeave()
|
|
return
|
|
}
|
|
if (isCurvedWall(event.node)) {
|
|
onWallLeave()
|
|
return
|
|
}
|
|
if (event.node.parentId !== getLevelId()) {
|
|
onWallLeave()
|
|
return
|
|
}
|
|
|
|
const target = resolveMoveTarget(event)
|
|
if (!target) {
|
|
onWallLeave()
|
|
return
|
|
}
|
|
freeFollowing = false
|
|
lastTarget = target
|
|
lastRoofEvent = null
|
|
applyPreview(target)
|
|
event.stopPropagation()
|
|
}
|
|
|
|
// Promote the moving door into its committed wall placement. Shared by the
|
|
// direct wall-mesh click and the floor proximity click.
|
|
const commitToWall = (target: NonNullable<typeof lastTarget>) => {
|
|
if (committed) return
|
|
committed = true
|
|
|
|
let placedId: string
|
|
|
|
if (isNew) {
|
|
useScene.getState().deleteNode(movingDoorNode.id)
|
|
useScene.temporal.getState().resume()
|
|
|
|
const cloned = structuredClone(movingDoorNode) as any
|
|
delete cloned.id
|
|
cloned.metadata = stripPlacementMetadataFlags(cloned.metadata)
|
|
const node = DoorNode.parse({
|
|
...cloned,
|
|
position: [target.clampedX, target.clampedY, 0],
|
|
rotation: [0, target.itemRotation, 0],
|
|
side: target.side,
|
|
wallId: target.wallId,
|
|
parentId: target.wallId,
|
|
roofSegmentId: undefined,
|
|
roofFace: undefined,
|
|
})
|
|
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
|
placedId = node.id
|
|
} else {
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
position: original.position,
|
|
rotation: original.rotation,
|
|
side: original.side,
|
|
parentId: original.parentId,
|
|
wallId: original.wallId,
|
|
roofSegmentId: original.roofSegmentId,
|
|
roofFace: original.roofFace,
|
|
metadata: original.metadata,
|
|
})
|
|
useScene.temporal.getState().resume()
|
|
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
position: [target.clampedX, target.clampedY, 0],
|
|
rotation: [0, target.itemRotation, 0],
|
|
side: target.side,
|
|
parentId: target.wallId,
|
|
wallId: target.wallId,
|
|
roofSegmentId: undefined,
|
|
metadata: {},
|
|
})
|
|
|
|
if (original.parentId && original.parentId !== target.wallId) {
|
|
markHostDirty(original.parentId)
|
|
}
|
|
placedId = movingDoorNode.id
|
|
}
|
|
|
|
markHostDirty(target.wallId)
|
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
useScene.temporal.getState().pause()
|
|
|
|
triggerSFX('sfx:structure-build')
|
|
hideCursor()
|
|
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
|
exitMoveMode()
|
|
}
|
|
|
|
const onWallClick = (event: WallEvent) => {
|
|
if (committed) return
|
|
if (!isValidWallSideFace(event.normal)) return
|
|
if (isCurvedWall(event.node)) return
|
|
if (event.node.parentId !== getLevelId()) return
|
|
|
|
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
|
if (!target?.valid) return
|
|
commitToWall(target)
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const onWallLeave = () => {
|
|
// The cursor left the wall mesh. Don't snap back to the origin/original
|
|
// here — the floor proximity handler (onGridMove) takes over on the same
|
|
// pointermove: it either snaps to a nearby wall or free-follows the
|
|
// cursor. The wireframe outline + live transform are cleared so the
|
|
// free-follow path can re-establish them. Reverting the node is left to
|
|
// onGridMove's free-follow / cancel / commit, so the door never blinks
|
|
// back to the building origin between a wall and open floor.
|
|
hideCursor()
|
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
dragAnchor = null
|
|
lastTarget = null
|
|
lastRoofEvent = null
|
|
}
|
|
|
|
// Free-follow: the door rides the cursor over empty floor, parented to the
|
|
// level like an item node (lifted so it stands on the floor). No wall to
|
|
// attach to, so it is not committable here.
|
|
const freeFollowAt = (localX: number, localZ: number) => {
|
|
freeFollowing = true
|
|
lastTarget = null
|
|
lastRoofEvent = null
|
|
hideCursor()
|
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
const levelId = getLevelId()
|
|
const y = movingDoorNode.height / 2
|
|
// Keep the R-flip visible while free-following: face the chosen side
|
|
// (back = rotated π) instead of forcing 0, so an R press isn't undone on
|
|
// the next mousemove.
|
|
const yaw = sideOverride === 'back' ? Math.PI : 0
|
|
if (currentHostId !== levelId) {
|
|
if (currentHostId && currentHostId !== levelId) markHostDirty(currentHostId)
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
position: [localX, y, localZ],
|
|
rotation: [0, yaw, 0],
|
|
side: sideOverride,
|
|
parentId: levelId ?? undefined,
|
|
wallId: undefined,
|
|
roofSegmentId: undefined,
|
|
roofFace: undefined,
|
|
})
|
|
currentHostId = levelId
|
|
} else {
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
position: [localX, y, localZ],
|
|
rotation: [0, yaw, 0],
|
|
side: sideOverride,
|
|
})
|
|
}
|
|
}
|
|
|
|
const onGridMove = (event: GridEvent) => {
|
|
if (committed) return
|
|
if (useViewer.getState().cameraDragging) return
|
|
// A wall/roof mesh handler owns this exact pointermove (shared DOM
|
|
// timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise
|
|
// the cursor is over open floor — free-follow it.
|
|
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
|
|
|
|
// No proximity magnet: in 3D the wall side faces are big raycast targets,
|
|
// so snapping engages only when the cursor ray actually hovers a wall
|
|
// (`onWallMove`). Over open floor the door just follows the cursor.
|
|
const [x, , z] = event.localPosition
|
|
freeFollowAt(x, z)
|
|
}
|
|
|
|
// ── Roof-segment wall faces ─────────────────────────────────────
|
|
// Mirrors the wall flow for the segments' vertical wall faces (base
|
|
// walls under the roof + coplanar gable ends). This is also the
|
|
// placement path preset tiles take (`metadata.isNew` clones).
|
|
|
|
const resolveRoofMoveTarget = (event: RoofEvent) =>
|
|
resolveRoofWallOpeningTarget({
|
|
event,
|
|
width: movingDoorNode.width,
|
|
height: movingDoorNode.height,
|
|
ignoreId: movingDoorNode.id,
|
|
vertical: { kind: 'bottom-locked' },
|
|
})
|
|
|
|
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
|
|
const pose = getRoofWallOpeningCursorPose(target, roof)
|
|
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
|
|
}
|
|
|
|
const onRoofHover = (event: RoofEvent) => {
|
|
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
|
const target = resolveRoofMoveTarget(event)
|
|
if (!target) {
|
|
onRoofLeave()
|
|
return
|
|
}
|
|
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
|
freeFollowing = false
|
|
dragAnchor = null
|
|
lastTarget = null
|
|
lastRoofEvent = event
|
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
if (currentHostId !== target.segment.id) {
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
position: target.position,
|
|
rotation: [0, 0, 0],
|
|
side: 'front',
|
|
parentId: target.segment.id,
|
|
wallId: undefined,
|
|
roofSegmentId: target.segment.id,
|
|
roofFace: target.face.id,
|
|
})
|
|
markHostDirty(currentHostId)
|
|
currentHostId = target.segment.id
|
|
} else {
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
position: target.position,
|
|
rotation: [0, 0, 0],
|
|
roofFace: target.face.id,
|
|
})
|
|
}
|
|
updateRoofCursor(target, event.node as RoofNode)
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const onRoofClick = (event: RoofEvent) => {
|
|
if (committed) return
|
|
const target = resolveRoofMoveTarget(event)
|
|
if (!target?.valid) return
|
|
committed = true
|
|
const segmentId = target.segment.id
|
|
|
|
let placedId: string
|
|
|
|
if (isNew) {
|
|
useScene.getState().deleteNode(movingDoorNode.id)
|
|
useScene.temporal.getState().resume()
|
|
|
|
const cloned = structuredClone(movingDoorNode) as any
|
|
delete cloned.id
|
|
cloned.metadata = stripPlacementMetadataFlags(cloned.metadata)
|
|
const node = DoorNode.parse({
|
|
...cloned,
|
|
position: target.position,
|
|
rotation: [0, 0, 0],
|
|
side: 'front',
|
|
wallId: undefined,
|
|
roofSegmentId: segmentId,
|
|
roofFace: target.face.id,
|
|
parentId: segmentId,
|
|
})
|
|
useScene.getState().createNode(node, segmentId as AnyNodeId)
|
|
placedId = node.id
|
|
} else {
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
position: original.position,
|
|
rotation: original.rotation,
|
|
side: original.side,
|
|
parentId: original.parentId,
|
|
wallId: original.wallId,
|
|
roofSegmentId: original.roofSegmentId,
|
|
roofFace: original.roofFace,
|
|
metadata: original.metadata,
|
|
})
|
|
useScene.temporal.getState().resume()
|
|
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
position: target.position,
|
|
rotation: [0, 0, 0],
|
|
side: 'front',
|
|
parentId: segmentId,
|
|
wallId: undefined,
|
|
roofSegmentId: segmentId,
|
|
roofFace: target.face.id,
|
|
metadata: {},
|
|
})
|
|
|
|
if (original.parentId && original.parentId !== segmentId) {
|
|
markHostDirty(original.parentId)
|
|
}
|
|
placedId = movingDoorNode.id
|
|
}
|
|
|
|
markHostDirty(segmentId)
|
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
useScene.temporal.getState().pause()
|
|
|
|
triggerSFX('sfx:structure-build')
|
|
hideCursor()
|
|
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
|
exitMoveMode()
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const onRoofLeave = () => {
|
|
// Mirror onWallLeave: don't revert to origin here — onGridMove takes
|
|
// over on the same pointermove (snap to a nearby wall or free-follow).
|
|
hideCursor()
|
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
dragAnchor = null
|
|
lastTarget = null
|
|
lastRoofEvent = null
|
|
}
|
|
|
|
const onCancel = () => {
|
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
if (isNew) {
|
|
useScene.getState().deleteNode(movingDoorNode.id)
|
|
if (currentHostId) markHostDirty(currentHostId)
|
|
} else {
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
position: original.position,
|
|
rotation: original.rotation,
|
|
side: original.side,
|
|
parentId: original.parentId,
|
|
wallId: original.wallId,
|
|
roofSegmentId: original.roofSegmentId,
|
|
roofFace: original.roofFace,
|
|
metadata: original.metadata,
|
|
})
|
|
if (original.parentId) markHostDirty(original.parentId)
|
|
}
|
|
useScene.temporal.getState().resume()
|
|
hideCursor()
|
|
exitMoveMode()
|
|
}
|
|
|
|
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) {
|
|
commitToWall(lastTarget)
|
|
return
|
|
}
|
|
if (lastRoofEvent) onRoofClick(lastRoofEvent)
|
|
}
|
|
|
|
// R flips the door's facing side mid-placement (front ↔ back), like the
|
|
// committed-selected R flip — usable before commit, whether snapped to a
|
|
// wall or free-following. Re-applies the preview so the flip shows live.
|
|
// No-op on a roof-segment face (those host front-only; nothing to flip).
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (committed) return
|
|
if (e.key !== 'r' && e.key !== 'R') return
|
|
const target = e.target as HTMLElement | null
|
|
if (
|
|
target &&
|
|
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
|
) {
|
|
return
|
|
}
|
|
// Only act where a flip is meaningful — on a wall hover or while
|
|
// free-following. On a roof face leave it to the default R (no flip, no
|
|
// sfx) so the cue never fires without a visible effect.
|
|
const onWall = lastTarget !== null
|
|
if (!(onWall || freeFollowing)) return
|
|
e.preventDefault()
|
|
sideOverride = sideOverride === 'front' ? 'back' : 'front'
|
|
triggerSFX('sfx:item-rotate')
|
|
if (onWall) {
|
|
// On a wall: re-resolve with the flipped side and re-preview.
|
|
const next = resolveMoveTarget(lastTarget!.event)
|
|
if (next) {
|
|
lastTarget = next
|
|
applyPreview(next)
|
|
}
|
|
} else {
|
|
// Free-following on the level: flip the draft's facing in place.
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
side: sideOverride,
|
|
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0],
|
|
})
|
|
}
|
|
}
|
|
|
|
emitter.on('wall:enter', onWallEnter)
|
|
emitter.on('wall:move', onWallMove)
|
|
emitter.on('wall:click', onWallClick)
|
|
emitter.on('wall:leave', onWallLeave)
|
|
emitter.on('roof:enter', onRoofHover)
|
|
emitter.on('roof:move', onRoofHover)
|
|
emitter.on('roof:click', onRoofClick)
|
|
emitter.on('roof:leave', onRoofLeave)
|
|
emitter.on('grid:move', onGridMove)
|
|
emitter.on('tool:cancel', onCancel)
|
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
|
window.addEventListener('keydown', onKeyDown)
|
|
|
|
return () => {
|
|
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
|
| DoorNode
|
|
| undefined
|
|
const currentMeta = current?.metadata as Record<string, unknown> | undefined
|
|
if (currentMeta?.isTransient) {
|
|
if (isNew) {
|
|
useScene.getState().deleteNode(movingDoorNode.id)
|
|
if (currentHostId) markHostDirty(currentHostId)
|
|
} else {
|
|
useScene.getState().updateNode(movingDoorNode.id, {
|
|
position: original.position,
|
|
rotation: original.rotation,
|
|
side: original.side,
|
|
parentId: original.parentId,
|
|
wallId: original.wallId,
|
|
roofSegmentId: original.roofSegmentId,
|
|
roofFace: original.roofFace,
|
|
metadata: original.metadata,
|
|
})
|
|
if (original.parentId) markHostDirty(original.parentId)
|
|
}
|
|
}
|
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
useAlignmentGuides.getState().clear()
|
|
useScene.temporal.getState().resume()
|
|
emitter.off('wall:enter', onWallEnter)
|
|
emitter.off('wall:move', onWallMove)
|
|
emitter.off('wall:click', onWallClick)
|
|
emitter.off('wall:leave', onWallLeave)
|
|
emitter.off('roof:enter', onRoofHover)
|
|
emitter.off('roof:move', onRoofHover)
|
|
emitter.off('roof:click', onRoofClick)
|
|
emitter.off('roof:leave', onRoofLeave)
|
|
emitter.off('grid:move', onGridMove)
|
|
emitter.off('tool:cancel', onCancel)
|
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
|
window.removeEventListener('keydown', onKeyDown)
|
|
}
|
|
}, [movingDoorNode, exitMoveMode])
|
|
|
|
const edgesGeo = useMemo(() => {
|
|
const boxGeo = new BoxGeometry(
|
|
movingDoorNode.width,
|
|
movingDoorNode.height,
|
|
movingDoorNode.frameDepth ?? 0.07,
|
|
)
|
|
const geo = new EdgesGeometry(boxGeo)
|
|
boxGeo.dispose()
|
|
return geo
|
|
}, [movingDoorNode])
|
|
|
|
return (
|
|
<group ref={cursorGroupRef} visible={false}>
|
|
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
|
</group>
|
|
)
|
|
}
|
|
|
|
export default MoveDoorTool
|