Extend the opening proximity guides to two more interactions:
- PLACEMENT: the door/window placement tools publish the same 3D guides
(sill/head, edge proximity, sill alignment, equal-spacing) while a NEW
opening is being dropped; window placement also snaps its sill to a
neighbour's sill/centre/top (Shift bypass) — so "two windows aligned" reads
during placement, not just move.
- RESIZE: a new `onDrag` hook on the linear-resize handle descriptor lets the
door/window width/height arrows publish live guides for the edge being
resized — proximity to neighbours as the width grows, and the live sill/head
as a window's height changes. The generic LinearArrow stays kind-agnostic;
only door/window declare the hook.
Refactor (Codex review follow-up): one `publishOpeningGuidesForWallEvent`
wrapper now backs all four wall-event publish sites (door/window move +
placement) over a shared `makeWallToWorld`; window placement's repeated
sill-snap is a single `resolvePlacementY` helper. Opening guides clear on
commit / leave / cancel / roof-hover / unmount (mirroring the alignment-guide
lifecycle) and on resize end.
Codex-reviewed — no blockers; lifecycle/leaks, coordinate frame, sill-snap
precedence, and resize disposal confirmed. Typecheck + biome + 23 core + 170
nodes tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
652 lines
21 KiB
TypeScript
652 lines
21 KiB
TypeScript
import {
|
|
type AnyNodeId,
|
|
collectAlignmentAnchors,
|
|
emitter,
|
|
type GridEvent,
|
|
isCurvedWall,
|
|
type RoofEvent,
|
|
type RoofNode,
|
|
sceneRegistry,
|
|
spatialGridManager,
|
|
useScene,
|
|
type WallEvent,
|
|
WindowNode,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
calculateCursorRotation,
|
|
calculateItemRotation,
|
|
EDITOR_LAYER,
|
|
getSideFromNormal,
|
|
isValidWallSideFace,
|
|
snapToHalf,
|
|
triggerSFX,
|
|
useAlignmentGuides,
|
|
} from '@pascal-app/editor'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useEffect, useRef } from 'react'
|
|
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
|
import {
|
|
clearOpeningGuides3D,
|
|
publishOpeningGuidesForWallEvent,
|
|
resolveSillSnap,
|
|
} from '../shared/opening-guides-runtime'
|
|
import {
|
|
getRoofWallOpeningCursorPose,
|
|
type RoofWallOpeningTarget,
|
|
resolveRoofWallOpeningTarget,
|
|
worldToSelectedBuildingLocal,
|
|
} from '../shared/roof-wall-opening-placement'
|
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
|
|
|
// Shared edge material — reuse across renders, just toggle color
|
|
const edgeMaterial = new LineBasicNodeMaterial({
|
|
color: 0xef_44_44, // red-500 default (invalid)
|
|
linewidth: 3,
|
|
depthTest: false,
|
|
depthWrite: false,
|
|
})
|
|
|
|
const FALLBACK_WIDTH = 1.5
|
|
const FALLBACK_HEIGHT = 1.5
|
|
const FALLBACK_SILL_LIFT = 0.45
|
|
const roofFallbackPoint = new Vector3()
|
|
|
|
/**
|
|
* Window tool — places WindowNodes on walls and on roof-segment wall
|
|
* faces (the generated base walls under a roof, including coplanar gable
|
|
* ends — a window can sit in the gable pediment).
|
|
* Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
|
|
*/
|
|
const WindowTool: React.FC = () => {
|
|
const draftRef = useRef<WindowNode | null>(null)
|
|
const cursorGroupRef = useRef<Group>(null!)
|
|
const edgesRef = useRef<LineSegments>(null!)
|
|
|
|
useEffect(() => {
|
|
useScene.temporal.getState().pause()
|
|
|
|
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 markHostDirty = (hostId: string) => {
|
|
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
|
}
|
|
|
|
const destroyDraft = () => {
|
|
if (!draftRef.current) return
|
|
const wallId = draftRef.current.parentId
|
|
useScene.getState().deleteNode(draftRef.current.id)
|
|
draftRef.current = null
|
|
// Rebuild wall so it removes the cutout from the deleted draft
|
|
if (wallId) markHostDirty(wallId)
|
|
}
|
|
|
|
const hideCursor = () => {
|
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
|
useAlignmentGuides.getState().clear()
|
|
clearOpeningGuides3D()
|
|
}
|
|
|
|
// Alignment candidates — anchors of every alignable object; refreshed
|
|
// after each placement. A window aligns by the plan position of its centre
|
|
// (along-wall only; the floor-plane guides don't cover sill height).
|
|
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
|
|
|
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 showFallbackCursor = (event: GridEvent) => {
|
|
if (draftRef.current) return
|
|
const [x, y, z] = event.localPosition
|
|
updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
|
useAlignmentGuides.getState().clear()
|
|
clearOpeningGuides3D()
|
|
}
|
|
|
|
const showRoofFallbackCursor = (event: RoofEvent) => {
|
|
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
|
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
|
useAlignmentGuides.getState().clear()
|
|
clearOpeningGuides3D()
|
|
}
|
|
|
|
const showWallFallbackCursor = (event: WallEvent) => {
|
|
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
|
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
|
useAlignmentGuides.getState().clear()
|
|
clearOpeningGuides3D()
|
|
}
|
|
|
|
// Sill alignment (snap + guide): a sibling sill/centre/top wins over the
|
|
// 0.5m grid when within threshold; Shift bypasses both. `movingId` is the
|
|
// draft's id once it exists (so it's excluded from the sibling scan), or ''
|
|
// before the draft is created (nothing to exclude yet).
|
|
const resolvePlacementY = (args: {
|
|
event: WallEvent
|
|
movingId: string
|
|
localX: number
|
|
width: number
|
|
height: number
|
|
}): number => {
|
|
const rawY = args.event.localPosition[1]
|
|
if (args.event.nativeEvent?.shiftKey === true) return rawY
|
|
const sillY = resolveSillSnap({
|
|
wall: args.event.node,
|
|
movingId: args.movingId,
|
|
localX: args.localX,
|
|
localY: rawY,
|
|
width: args.width,
|
|
height: args.height,
|
|
nodes: useScene.getState().nodes,
|
|
})
|
|
return sillY ?? snapToHalf(rawY)
|
|
}
|
|
|
|
const onWallEnter = (event: WallEvent) => {
|
|
if (!isValidWallSideFace(event.normal)) {
|
|
destroyDraft()
|
|
showWallFallbackCursor(event)
|
|
return
|
|
}
|
|
if (isCurvedWall(event.node)) {
|
|
destroyDraft()
|
|
showWallFallbackCursor(event)
|
|
return
|
|
}
|
|
const levelId = getLevelId()
|
|
if (!levelId) {
|
|
destroyDraft()
|
|
showWallFallbackCursor(event)
|
|
return
|
|
}
|
|
// Only interact with walls on the current level
|
|
if (event.node.parentId !== levelId) {
|
|
destroyDraft()
|
|
showWallFallbackCursor(event)
|
|
return
|
|
}
|
|
|
|
destroyDraft()
|
|
|
|
const side = getSideFromNormal(event.normal)
|
|
const itemRotation = calculateItemRotation(event.normal)
|
|
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
|
|
const width = 1.5
|
|
const height = 1.5
|
|
const localX = resolveWallSlideAlignment({
|
|
wallNode: event.node,
|
|
rawLocalX: event.localPosition[0],
|
|
width,
|
|
candidates: alignmentCandidates,
|
|
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
|
bypassSnap: event.nativeEvent?.shiftKey === true,
|
|
})
|
|
const localY = resolvePlacementY({ event, movingId: '', localX, width, height })
|
|
|
|
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
|
|
|
const node = WindowNode.parse({
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: [0, itemRotation, 0],
|
|
side,
|
|
wallId: event.node.id,
|
|
parentId: event.node.id,
|
|
metadata: { isTransient: true },
|
|
})
|
|
|
|
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
|
draftRef.current = node
|
|
|
|
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
|
|
|
updateCursor(
|
|
wallLocalToWorld(
|
|
event.node,
|
|
clampedX,
|
|
clampedY,
|
|
getLevelYOffset(),
|
|
getSlabElevation(event),
|
|
),
|
|
cursorRotation,
|
|
valid,
|
|
)
|
|
|
|
publishOpeningGuidesForWallEvent({
|
|
wall: event.node,
|
|
movingId: node.id,
|
|
centerS: clampedX,
|
|
centerY: clampedY,
|
|
width,
|
|
height,
|
|
includeVertical: true,
|
|
levelYOffset: getLevelYOffset(),
|
|
slabElevation: getSlabElevation(event),
|
|
})
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const onWallMove = (event: WallEvent) => {
|
|
if (!isValidWallSideFace(event.normal)) {
|
|
destroyDraft()
|
|
showWallFallbackCursor(event)
|
|
return
|
|
}
|
|
if (isCurvedWall(event.node)) {
|
|
destroyDraft()
|
|
showWallFallbackCursor(event)
|
|
return
|
|
}
|
|
// Only interact with walls on the current level
|
|
if (event.node.parentId !== getLevelId()) {
|
|
destroyDraft()
|
|
showWallFallbackCursor(event)
|
|
return
|
|
}
|
|
|
|
const side = getSideFromNormal(event.normal)
|
|
const itemRotation = calculateItemRotation(event.normal)
|
|
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
|
|
const width = draftRef.current?.width ?? 1.5
|
|
const height = draftRef.current?.height ?? 1.5
|
|
const localX = resolveWallSlideAlignment({
|
|
wallNode: event.node,
|
|
rawLocalX: event.localPosition[0],
|
|
width,
|
|
candidates: alignmentCandidates,
|
|
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
|
bypassSnap: event.nativeEvent?.shiftKey === true,
|
|
})
|
|
const localY = resolvePlacementY({
|
|
event,
|
|
movingId: draftRef.current?.id ?? '',
|
|
localX,
|
|
width,
|
|
height,
|
|
})
|
|
|
|
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
|
|
|
// Draft may be null after a successful placement (the click handler
|
|
// deletes it and relies on the wall rebuild → pointer-enter cascade to
|
|
// recreate it). Recreate it here on the first subsequent move so the
|
|
// preview is ready for the next click without requiring a leave/enter.
|
|
if (!draftRef.current) {
|
|
const levelId = getLevelId()
|
|
if (levelId && event.node.parentId === levelId) {
|
|
const node = WindowNode.parse({
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: [0, itemRotation, 0],
|
|
side,
|
|
wallId: event.node.id,
|
|
parentId: event.node.id,
|
|
metadata: { isTransient: true },
|
|
})
|
|
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
|
draftRef.current = node
|
|
}
|
|
}
|
|
|
|
if (draftRef.current) {
|
|
// Update the scene store on every move so the 2D floor plan
|
|
// stays in sync (it re-renders from `node.position`). Only
|
|
// forward `parentId` / `wallId` when the wall actually changed
|
|
// — otherwise the reparent path churns the host wall's
|
|
// `children` array every tick, which re-renders the wall and
|
|
// briefly draws its 0-vertex placeholder geometry (WebGPU then
|
|
// flags "Vertex buffer slot 0 ... was not set").
|
|
const isSameWall = event.node.id === draftRef.current.parentId
|
|
if (isSameWall) {
|
|
useScene.getState().updateNode(draftRef.current.id, {
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: [0, itemRotation, 0],
|
|
side,
|
|
})
|
|
markHostDirty(event.node.id)
|
|
} else {
|
|
useScene.getState().updateNode(draftRef.current.id, {
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: [0, itemRotation, 0],
|
|
side,
|
|
parentId: event.node.id,
|
|
wallId: event.node.id,
|
|
// The draft may arrive from a roof-segment face hover.
|
|
roofSegmentId: undefined,
|
|
roofFace: undefined,
|
|
})
|
|
}
|
|
}
|
|
|
|
const valid = !hasWallChildOverlap(
|
|
event.node.id,
|
|
clampedX,
|
|
clampedY,
|
|
width,
|
|
height,
|
|
draftRef.current?.id,
|
|
)
|
|
|
|
updateCursor(
|
|
wallLocalToWorld(
|
|
event.node,
|
|
clampedX,
|
|
clampedY,
|
|
getLevelYOffset(),
|
|
getSlabElevation(event),
|
|
),
|
|
cursorRotation,
|
|
valid,
|
|
)
|
|
|
|
if (draftRef.current) {
|
|
publishOpeningGuidesForWallEvent({
|
|
wall: event.node,
|
|
movingId: draftRef.current.id,
|
|
centerS: clampedX,
|
|
centerY: clampedY,
|
|
width,
|
|
height,
|
|
includeVertical: true,
|
|
levelYOffset: getLevelYOffset(),
|
|
slabElevation: getSlabElevation(event),
|
|
})
|
|
}
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const onWallClick = (event: WallEvent) => {
|
|
if (!draftRef.current) return
|
|
if (!isValidWallSideFace(event.normal)) return
|
|
if (isCurvedWall(event.node)) return
|
|
// Only interact with walls on the current level
|
|
if (event.node.parentId !== getLevelId()) return
|
|
|
|
const side = getSideFromNormal(event.normal)
|
|
const itemRotation = calculateItemRotation(event.normal)
|
|
|
|
const localX = resolveWallSlideAlignment({
|
|
wallNode: event.node,
|
|
rawLocalX: event.localPosition[0],
|
|
width: draftRef.current.width,
|
|
candidates: alignmentCandidates,
|
|
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
|
bypassSnap: event.nativeEvent?.shiftKey === true,
|
|
})
|
|
const localY = resolvePlacementY({
|
|
event,
|
|
movingId: draftRef.current.id,
|
|
localX,
|
|
width: draftRef.current.width,
|
|
height: draftRef.current.height,
|
|
})
|
|
const { clampedX, clampedY } = clampToWall(
|
|
event.node,
|
|
localX,
|
|
localY,
|
|
draftRef.current.width,
|
|
draftRef.current.height,
|
|
)
|
|
const valid = !hasWallChildOverlap(
|
|
event.node.id,
|
|
clampedX,
|
|
clampedY,
|
|
draftRef.current.width,
|
|
draftRef.current.height,
|
|
draftRef.current.id,
|
|
)
|
|
if (!valid) return
|
|
|
|
const draft = draftRef.current
|
|
draftRef.current = null
|
|
|
|
// Delete transient draft (paused, invisible to undo)
|
|
useScene.getState().deleteNode(draft.id)
|
|
|
|
// Resume → create permanent node (single undoable action)
|
|
useScene.temporal.getState().resume()
|
|
|
|
const levelId = getLevelId()
|
|
const state = useScene.getState()
|
|
const windowCount = Object.values(state.nodes).filter((n) => {
|
|
if (n.type !== 'window') return false
|
|
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
|
return wall?.parentId === levelId
|
|
}).length
|
|
const name = `Window ${windowCount + 1}`
|
|
|
|
const node = WindowNode.parse({
|
|
name,
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: [0, itemRotation, 0],
|
|
side,
|
|
wallId: event.node.id,
|
|
parentId: event.node.id,
|
|
width: draft.width,
|
|
height: draft.height,
|
|
windowType: draft.windowType,
|
|
operationState: draft.operationState,
|
|
awningDirection: draft.awningDirection,
|
|
casementStyle: draft.casementStyle,
|
|
hingesSide: draft.hingesSide,
|
|
frameThickness: draft.frameThickness,
|
|
frameDepth: draft.frameDepth,
|
|
columnRatios: draft.columnRatios,
|
|
rowRatios: draft.rowRatios,
|
|
columnDividerThickness: draft.columnDividerThickness,
|
|
rowDividerThickness: draft.rowDividerThickness,
|
|
sill: draft.sill,
|
|
sillDepth: draft.sillDepth,
|
|
sillThickness: draft.sillThickness,
|
|
})
|
|
|
|
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
|
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
|
useScene.temporal.getState().pause()
|
|
triggerSFX('sfx:structure-build')
|
|
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
|
useAlignmentGuides.getState().clear()
|
|
clearOpeningGuides3D()
|
|
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const onWallLeave = () => {
|
|
destroyDraft()
|
|
hideCursor()
|
|
}
|
|
|
|
// ── Roof-segment wall faces ─────────────────────────────────────
|
|
// The merged roof mesh emits `roof:*`; hits are resolved against the
|
|
// segments' vertical wall faces (base walls + coplanar gable ends),
|
|
// so a window can sit anywhere inside the face profile — including
|
|
// the gable pediment triangle.
|
|
|
|
const resolveRoofTarget = (event: RoofEvent) =>
|
|
resolveRoofWallOpeningTarget({
|
|
event,
|
|
width: draftRef.current?.width ?? 1.5,
|
|
height: draftRef.current?.height ?? 1.5,
|
|
ignoreId: draftRef.current?.id,
|
|
vertical: {
|
|
kind: 'free',
|
|
snap: event.nativeEvent?.shiftKey === true ? undefined : snapToHalf,
|
|
},
|
|
})
|
|
|
|
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
|
|
const pose = getRoofWallOpeningCursorPose(target, roof)
|
|
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
|
|
}
|
|
|
|
const onRoofHover = (event: RoofEvent) => {
|
|
const target = resolveRoofTarget(event)
|
|
if (!target) {
|
|
// On the roof but not over a placeable wall face (slope, soffit,
|
|
// or a face the window 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 = WindowNode.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
|
|
}
|
|
// 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)
|
|
if (!target?.valid) return
|
|
const { segment, face, position } = target
|
|
|
|
const draft = draftRef.current
|
|
draftRef.current = null
|
|
|
|
useScene.getState().deleteNode(draft.id)
|
|
useScene.temporal.getState().resume()
|
|
|
|
const state = useScene.getState()
|
|
const windowCount = Object.values(state.nodes).filter(
|
|
(n) => n.type === 'window' && (n as WindowNode).roofSegmentId !== undefined,
|
|
).length
|
|
|
|
const node = WindowNode.parse({
|
|
name: `Window ${windowCount + 1}`,
|
|
position,
|
|
rotation: [0, 0, 0],
|
|
side: 'front',
|
|
roofSegmentId: segment.id,
|
|
roofFace: face.id,
|
|
parentId: segment.id,
|
|
width: draft.width,
|
|
height: draft.height,
|
|
windowType: draft.windowType,
|
|
operationState: draft.operationState,
|
|
awningDirection: draft.awningDirection,
|
|
casementStyle: draft.casementStyle,
|
|
hingesSide: draft.hingesSide,
|
|
frameThickness: draft.frameThickness,
|
|
frameDepth: draft.frameDepth,
|
|
columnRatios: draft.columnRatios,
|
|
rowRatios: draft.rowRatios,
|
|
columnDividerThickness: draft.columnDividerThickness,
|
|
rowDividerThickness: draft.rowDividerThickness,
|
|
sill: draft.sill,
|
|
sillDepth: draft.sillDepth,
|
|
sillThickness: draft.sillThickness,
|
|
})
|
|
|
|
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] })
|
|
useScene.temporal.getState().pause()
|
|
triggerSFX('sfx:structure-build')
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const onRoofLeave = () => {
|
|
if (!draftRef.current?.roofSegmentId) return
|
|
destroyDraft()
|
|
hideCursor()
|
|
}
|
|
|
|
const onCancel = () => {
|
|
destroyDraft()
|
|
hideCursor()
|
|
}
|
|
|
|
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', showFallbackCursor)
|
|
emitter.on('tool:cancel', onCancel)
|
|
|
|
return () => {
|
|
destroyDraft()
|
|
hideCursor()
|
|
useAlignmentGuides.getState().clear()
|
|
clearOpeningGuides3D()
|
|
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', showFallbackCursor)
|
|
emitter.off('tool:cancel', onCancel)
|
|
}
|
|
}, [])
|
|
|
|
// Cursor geometry: window outline rectangle.
|
|
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
|
const edgesGeo = new EdgesGeometry(boxGeo)
|
|
boxGeo.dispose()
|
|
|
|
return (
|
|
<group ref={cursorGroupRef} visible={false}>
|
|
<lineSegments
|
|
geometry={edgesGeo}
|
|
layers={EDITOR_LAYER}
|
|
material={edgeMaterial}
|
|
ref={edgesRef}
|
|
/>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
export default WindowTool
|