Files
editor/packages/nodes/src/door/tool.tsx
T

764 lines
27 KiB
TypeScript

import {
type AnyNode,
type AnyNodeId,
DoorNode,
emitter,
type GridEvent,
isCurvedWall,
type RoofEvent,
type RoofNode,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
type WallNode,
WallNode as WallNodeSchema,
} from '@pascal-app/core'
import {
calculateCursorRotation,
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
isMagneticSnapActive,
isValidWallSideFace,
triggerSFX,
useAlignmentGuides,
useEditor,
useFacingPose,
usePlacementPreview,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
clearOpeningGuides3D,
publishOpeningGuidesForWallEvent,
} from '../shared/opening-guides-runtime'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget,
worldToSelectedBuildingLocal,
} from '../shared/roof-wall-opening-placement'
import {
collectWallOpeningAlignmentCandidates,
resolveWallSlideAlignment,
} from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
import DoorPreview from './preview'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef_44_44,
linewidth: 3,
depthTest: false,
depthWrite: false,
})
const FALLBACK_WIDTH = 0.9
const FALLBACK_HEIGHT = 2.1
const roofFallbackPoint = new Vector3()
// What currently owns the cursor frame: a wall/roof mesh hover, or null when
// the cursor is over open floor (the grid handler then free-follows).
type HostKind = 'wall' | 'roof' | null
/**
* Door tool — places DoorNodes on walls and on roof-segment wall faces
* (the generated base walls under a roof, including coplanar gable ends).
* Doors always sit at floor level (clampedY = height/2 — segment base for
* roof-hosted doors).
*
* The ghost follows the cursor everywhere (like moving an item): over open
* floor it floats as an invalid (unplaceable) ghost; the moment the cursor
* ray hovers a wall (or roof-segment face) the real draft snaps onto it.
* Snapping engages only on an actual mesh hover — no proximity magnet — since
* the wall side faces are big raycast targets.
*/
const DoorTool: React.FC = () => {
const draftRef = useRef<DoorNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
// Off-host floating ghost: the real door geometry follows the cursor over
// the grid (tinted invalid). Mutually exclusive with the on-host draft —
// when a draft + wireframe is shown this is null and vice-versa. `side`
// carries the R-flip so the floating ghost faces the side that will be
// committed (its swing/hinge geometry depends on `side`).
const [fallbackPose, setFallbackPose] = useState<{
position: [number, number, number]
rotationY: number
side: DoorNode['side']
} | null>(null)
// 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],
)
// The frame depth is a fixed parse default (the `side` flip doesn't change
// it); a ref lets the facing-pose publish inside the setup effect read it
// without re-subscribing every event listener.
const frameDepthRef = useRef(ghostStub.frameDepth)
useEffect(() => {
useScene.temporal.getState().pause()
const ownedPreviewIds = new Set<string>()
const fallbackPreview = DoorNode.parse({
position: [0, 0, 0],
rotation: [0, 0, 0],
side: 'front',
})
const fallbackWallId = WallNodeSchema.parse({
end: [1, 0],
start: [0, 0],
thickness: 0.1,
}).id
const publishPlacementPreview = (node: AnyNode, parentNode: AnyNode | null) => {
ownedPreviewIds.add(node.id)
usePlacementPreview.getState().set(node, parentNode)
}
const clearPlacementPreview = () => {
const current = usePlacementPreview.getState().node
if (current && ownedPreviewIds.has(current.id)) usePlacementPreview.getState().clear()
}
const publishDraftPreview = (parentNode: AnyNode) => {
const draft = draftRef.current
if (!draft) return
const live = useScene.getState().nodes[draft.id as AnyNodeId]
if (live?.type !== 'door') return
draftRef.current = live
publishPlacementPreview(live, parentNode)
}
let hostKind: HostKind = null
// timeStamp of the most recent wall/roof mesh event. A wall/roof hover and
// the grid raycast from the SAME pointermove share the source DOM event's
// timeStamp, so the grid handler can detect "a mesh handler already owns
// this frame" without depending on event order or on a leave firing (node
// events are suppressed during a camera drag, so a sticky boolean would
// strand the draft after an orbit; a per-frame timestamp self-heals).
let lastMeshEventTime = -1
// R flips the door's facing side mid-placement (front ↔ back). On a wall
// the chosen side is `getSideFromNormal(normal)` flipped by `sideFlip`;
// re-applied to the last wall hover so the flip shows live before commit.
let sideFlip = false
let lastWallEvent: WallEvent | null = null
// Last open-floor cursor point (level-local X/Z) so an R-flip while
// free-following can re-render the floating ghost with the new facing.
let lastFloorPoint: [number, number, number] | null = null
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
const getSlabElevationForWall = (wall: WallNode) =>
spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end)
const markHostDirty = (hostId: string) => {
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
}
const destroyDraft = () => {
const draft = draftRef.current
if (!draft) {
clearPlacementPreview()
return
}
const wallId = draft.parentId
useScene.getState().deleteNode(draft.id)
draftRef.current = null
clearPlacementPreview()
if (wallId) markHostDirty(wallId)
}
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
setFallbackPose(null)
useFacingPose.getState().clear()
clearPlacementPreview()
}
// Alignment candidates — anchors of every alignable object; refreshed
// after each placement. A door aligns by the plan position of its centre.
let alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '')
// On-host cursor: the green/red wireframe outline tracks a live draft.
// Showing it always clears the off-host floating ghost (they never
// coexist — a draft means the cursor is on a valid host).
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
indicatorYOffset: number,
) => {
setFallbackPose(null)
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)
// Forward-facing triangle (editor-side overlay). The cursor group is
// already yawed so +Z faces out of the wall, so the door's front is +Z.
// The indicator rides at the sill (`indicatorYOffset`, the door's base).
useFacingPose.getState().set({
position: [worldPosition[0], worldPosition[1] + indicatorYOffset, worldPosition[2]],
rotationY: cursorRotationY,
depth: frameDepthRef.current,
})
}
// Off-host fallback: hide the wireframe outline and float the real door
// geometry (tinted invalid) at the cursor so the armed tool is visible.
// `sideFlip` (R) flips the facing: a back facing is a π yaw on the floating
// ghost, and the door swing/hinge geometry reads `side`.
const showGhostAt = (position: [number, number, number]) => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
setFallbackPose({
position,
rotationY: sideFlip ? Math.PI : 0,
side: sideFlip ? 'back' : 'front',
})
const halfWidth = fallbackPreview.width / 2 + 0.5
const wall = WallNodeSchema.parse({
end: [position[0] + halfWidth, position[2]],
id: fallbackWallId,
start: [position[0] - halfWidth, position[2]],
thickness: 0.1,
})
const ghost = DoorNode.parse({
...fallbackPreview,
metadata: { isTransient: true },
parentId: wall.id,
position: [halfWidth, fallbackPreview.height / 2, 0],
rotation: [0, sideFlip ? Math.PI : 0, 0],
side: sideFlip ? 'back' : 'front',
wallId: wall.id,
})
publishPlacementPreview(ghost, wall)
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
// Off-host (invalid) floating ghost — no direction triangle.
useFacingPose.getState().clear()
}
const showRoofFallbackCursor = (event: RoofEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z])
}
const showWallFallbackCursor = (event: WallEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z])
}
// Settle a wall target: alignment snap → clamp → overlap check. Pure read.
const resolveWallPlacement = (
wall: WallNode,
rawLocalX: number,
width: number,
height: number,
applySnap: boolean,
ignoreId?: string,
) => {
// Along-wall alignment guides are DISPLAYED in every snapping mode; the
// magnetic pull onto them is applied only when `applySnap` (magnetic
// "lines" mode). The grid component lives in `snapToHalf`, which is itself
// mode-aware (raw cursor when grid is off).
const localX = resolveWallSlideAlignment({
wallNode: wall,
rawLocalX,
width,
candidates: alignmentCandidates,
applySnap,
})
const { clampedX, clampedY } = clampToWall(wall, localX, width, height)
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
return { clampedX, clampedY, valid }
}
// Shared create/update path for the wall draft — used by the direct
// wall-mesh hover and the floor proximity snap. Reuses the existing draft
// (reparenting only on an actual wall change to avoid churning the host's
// children array, which flashes 0-vertex wall geometry in WebGPU).
const applyWallTarget = (args: {
wall: WallNode
rawLocalX: number
side: 'front' | 'back'
itemRotation: number
cursorRotationY: number
applySnap: boolean
}) => {
const { wall, rawLocalX, side, itemRotation, cursorRotationY, applySnap } = args
const width = draftRef.current?.width ?? 0.9
const height = draftRef.current?.height ?? 2.1
if (!draftRef.current) {
const node = DoorNode.parse({
position: [0, height / 2, 0],
rotation: [0, itemRotation, 0],
side,
wallId: wall.id,
parentId: wall.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, wall.id as AnyNodeId)
draftRef.current = node
}
const { clampedX, clampedY, valid } = resolveWallPlacement(
wall,
rawLocalX,
width,
height,
applySnap,
draftRef.current.id,
)
if (wall.id === draftRef.current.parentId) {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
})
markHostDirty(wall.id)
} else {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: wall.id,
wallId: wall.id,
// The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined,
roofFace: undefined,
})
}
publishDraftPreview(wall)
updateCursor(
wallLocalToWorld(
wall,
clampedX,
clampedY,
getLevelYOffset(),
getSlabElevationForWall(wall),
),
cursorRotationY,
valid,
-clampedY,
)
if (draftRef.current) {
publishOpeningGuidesForWallEvent({
wall,
movingId: draftRef.current.id,
centerS: clampedX,
centerY: clampedY,
width,
height,
includeVertical: false,
levelYOffset: getLevelYOffset(),
slabElevation: getSlabElevationForWall(wall),
})
}
return { clampedX, clampedY, valid }
}
// Promote the draft into a permanent door. Shared by the wall-mesh click
// and the floor proximity click.
const commitDoorAtWall = (
wall: WallNode,
clampedX: number,
clampedY: number,
side: 'front' | 'back',
itemRotation: number,
) => {
const draft = draftRef.current
if (!draft) return
clearPlacementPreview()
draftRef.current = null
hostKind = null
useScene.getState().deleteNode(draft.id)
useScene.temporal.getState().resume()
const levelId = getLevelId()
const state = useScene.getState()
const doorCount = Object.values(state.nodes).filter((n) => {
if (n.type !== 'door') return false
const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
return w?.parentId === levelId
}).length
const node = DoorNode.parse({
name: `Door ${doorCount + 1}`,
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: wall.id,
parentId: wall.id,
width: draft.width,
height: draft.height,
doorCategory: draft.doorCategory,
doorType: draft.doorType,
leafCount: draft.leafCount,
operationState: draft.operationState,
slideDirection: draft.slideDirection,
trackStyle: draft.trackStyle,
garagePanelCount: draft.garagePanelCount,
frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth,
threshold: draft.threshold,
thresholdHeight: draft.thresholdHeight,
hingesSide: draft.hingesSide,
swingDirection: draft.swingDirection,
segments: draft.segments,
handle: draft.handle,
handleHeight: draft.handleHeight,
handleSide: draft.handleSide,
doorCloser: draft.doorCloser,
panicBar: draft.panicBar,
panicBarHeight: draft.panicBarHeight,
})
useScene.getState().createNode(node, wall.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
triggerSFX('sfx:structure-build')
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
if (useEditor.getState().getContinuation('point') === 'repeat') {
useScene.temporal.getState().pause()
alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '')
} else {
hideCursor()
useEditor.getState().setTool(null)
}
}
// ── Direct wall-mesh hover ──────────────────────────────────────
const onWallHover = (event: WallEvent) => {
hostKind = 'wall'
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
if (
!isValidWallSideFace(event.normal) ||
isCurvedWall(event.node) ||
event.node.parentId !== getLevelId()
) {
destroyDraft()
showWallFallbackCursor(event)
return
}
lastWallEvent = event
const faceSide = getSideFromNormal(event.normal)
const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide
const flipOffset = sideFlip ? Math.PI : 0
const itemRotation = calculateItemRotation(event.normal) + flipOffset
const cursorRotation =
calculateCursorRotation(event.normal, event.node.start, event.node.end) + flipOffset
applyWallTarget({
wall: event.node,
rawLocalX: event.localPosition[0],
side,
itemRotation,
cursorRotationY: cursorRotation,
applySnap: isMagneticSnapActive(),
})
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
if (
!isValidWallSideFace(event.normal) ||
isCurvedWall(event.node) ||
event.node.parentId !== getLevelId()
) {
return
}
const faceSide = getSideFromNormal(event.normal)
const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide
const itemRotation = calculateItemRotation(event.normal) + (sideFlip ? Math.PI : 0)
const { clampedX, clampedY, valid } = resolveWallPlacement(
event.node,
event.localPosition[0],
draftRef.current.width,
draftRef.current.height,
isMagneticSnapActive(),
draftRef.current.id,
)
// Alt force-places over a collision (the draft stays red as a warning).
if (!valid && event.nativeEvent?.altKey !== true) return
commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation)
event.stopPropagation()
}
const onWallLeave = () => {
if (hostKind !== 'wall') return
lastWallEvent = null
destroyDraft()
hideCursor()
hostKind = null
}
// ── Floor free-follow ───────────────────────────────────────────
// Over open floor the ghost follows the cursor like a moving item. It does
// NOT snap from proximity — snapping engages only when the cursor ray
// actually hovers a wall (onWallHover) or roof face (onRoofHover).
const onGridFreeFollow = (event: GridEvent) => {
if (useViewer.getState().cameraDragging) return
// A wall/roof mesh handler processed this pointermove (shared DOM
// timeStamp) — it owns the frame and has snapped the draft, so skip the
// floor follow this tick.
const ts = event.nativeEvent?.timeStamp ?? -1
if (ts === lastMeshEventTime) return
// Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft
// and free-follow the cursor with the invalid (unplaceable) ghost.
hostKind = null
lastWallEvent = null
const [x, y, z] = event.localPosition
lastFloorPoint = [x, y + FALLBACK_HEIGHT / 2, z]
destroyDraft()
showGhostAt(lastFloorPoint)
}
// ── Roof-segment wall faces ─────────────────────────────────────
// The merged roof mesh emits `roof:*`; hits are resolved against the
// segments' vertical wall faces (base walls + coplanar gable ends).
const resolveRoofTarget = (event: RoofEvent) =>
resolveRoofWallOpeningTarget({
event,
width: draftRef.current?.width ?? 0.9,
height: draftRef.current?.height ?? 2.1,
ignoreId: draftRef.current?.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, -target.position[1])
}
const onRoofHover = (event: RoofEvent) => {
hostKind = 'roof'
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
const target = resolveRoofTarget(event)
if (!target) {
// On the roof but not over a placeable wall face (slope, soffit,
// or a face the door cannot fit on).
destroyDraft()
showRoofFallbackCursor(event)
return
}
const { segment, face, position } = target
if (draftRef.current && draftRef.current.parentId !== segment.id) destroyDraft()
if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, {
position,
rotation: [0, 0, 0],
roofFace: face.id,
})
} else {
const node = DoorNode.parse({
position,
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: segment.id,
roofFace: face.id,
parentId: segment.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, segment.id as AnyNodeId)
draftRef.current = node
}
publishDraftPreview(segment)
// Opening guides are wall-specific; clear them while over a roof face.
clearOpeningGuides3D()
updateRoofCursor(target, event.node as RoofNode)
event.stopPropagation()
}
const onRoofClick = (event: RoofEvent) => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
// Alt force-places over a colliding roof-face target (see onWallClick).
if (!target) return
if (!target.valid && event.nativeEvent?.altKey !== true) return
const { segment, face, position } = target
const draft = draftRef.current
clearPlacementPreview()
draftRef.current = null
hostKind = null
useScene.getState().deleteNode(draft.id)
useScene.temporal.getState().resume()
const state = useScene.getState()
const doorCount = Object.values(state.nodes).filter(
(n) => n.type === 'door' && (n as DoorNode).roofSegmentId !== undefined,
).length
const node = DoorNode.parse({
name: `Door ${doorCount + 1}`,
position,
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: segment.id,
roofFace: face.id,
parentId: segment.id,
width: draft.width,
height: draft.height,
doorCategory: draft.doorCategory,
doorType: draft.doorType,
leafCount: draft.leafCount,
operationState: draft.operationState,
slideDirection: draft.slideDirection,
trackStyle: draft.trackStyle,
garagePanelCount: draft.garagePanelCount,
frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth,
threshold: draft.threshold,
thresholdHeight: draft.thresholdHeight,
hingesSide: draft.hingesSide,
swingDirection: draft.swingDirection,
segments: draft.segments,
handle: draft.handle,
handleHeight: draft.handleHeight,
handleSide: draft.handleSide,
doorCloser: draft.doorCloser,
panicBar: draft.panicBar,
panicBarHeight: draft.panicBarHeight,
})
useScene.getState().createNode(node, segment.id as AnyNodeId)
// Rebuild the segment (and the merged roof) so the wall brush
// picks up the new opening cut.
useScene.getState().dirtyNodes.add(segment.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
triggerSFX('sfx:structure-build')
if (useEditor.getState().getContinuation('point') === 'repeat') {
useScene.temporal.getState().pause()
} else {
hideCursor()
useEditor.getState().setTool(null)
}
event.stopPropagation()
}
const onRoofLeave = () => {
if (hostKind !== 'roof') return
destroyDraft()
hideCursor()
hostKind = null
}
const onCancel = () => {
destroyDraft()
hideCursor()
hostKind = null
}
// R flips the door's facing side mid-placement (front ↔ back), like the
// committed-selected R flip. ALWAYS toggles the persistent flip intent —
// never a no-op (the old `!lastWallEvent` guard dropped R off-wall and before
// the first wall hover, so it "needed two presses"). Then re-renders whatever
// preview is current so the flip shows live and matches commit.
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'r' && e.key !== 'R') return
if (e.repeat) return
const t = e.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
e.preventDefault()
sideFlip = !sideFlip
triggerSFX('sfx:item-rotate')
if (lastWallEvent) {
// On a wall: re-resolve + re-preview with the flipped side.
onWallHover(lastWallEvent)
} else if (lastFloorPoint) {
// Off-wall: re-render the floating ghost (showGhostAt reads `sideFlip`).
showGhostAt(lastFloorPoint)
}
// else: no preview yet — `sideFlip` is set, so the first hover/follow uses it.
}
emitter.on('wall:enter', onWallHover)
emitter.on('wall:move', onWallHover)
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', onGridFreeFollow)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
return () => {
destroyDraft()
hideCursor()
clearPlacementPreview()
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallHover)
emitter.off('wall:move', onWallHover)
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', onGridFreeFollow)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
}
}, [])
// Cursor geometry: door outline. Static dims, so build it once and dispose on
// unmount rather than reallocating (and orphaning) an EdgesGeometry on every
// re-render during placement.
const edgesGeo = useMemo(() => {
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
const geo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return geo
}, [])
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
<>
<group ref={cursorGroupRef} visible={false}>
<lineSegments
geometry={edgesGeo}
layers={EDITOR_LAYER}
material={edgeMaterial}
ref={edgesRef}
/>
</group>
{fallbackPose && (
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}>
<DoorPreview invalid node={ghostStub} />
</group>
)}
</>
)
}
export default DoorTool