* fix(snapping): show alignment lines in all modes, snap only in lines; north export; R/T hint; grid default - Alignment guides display in every snapping mode (grid/lines/angles/off); magnetic pull applies only in 'lines'. New isAlignmentGuideActive() predicate decouples guide display from the snap delta across all placement/move/draw producers (item, wall, fence, slab/ceiling/roof, column/shelf/spawn, door/ window, MEP, 2D floorplan drafting, surface snap). - Floorplan PDF export now rotates to north-up (FLOORPLAN_VIEW_ROTATION_DEG - building rotation), matching the on-screen aligned-to-north view. - Item placement rotate hint collapsed to a single 'R / T Rotate' row. - Default item snapping mode changed lines -> grid. * feat(walls): detect rooms across shared walls + tight connect-snap in all modes Room detection: planarize the wall graph before face-finding — split straight walls at T-junctions where another wall ends mid-span, so a room closed against the middle of an existing wall is detected, not just isolated 4-wall rooms. Auto-close: wire the wall builder's "Room (auto-close)" to the same room graph via wallClosesRoom(), so drafting stops when a segment seals a room against the existing structure — not only when the chain returns to its own start. Connectivity snap: add a tight wall-connect snap (WALL_CONNECT_SNAP_RADIUS 0.05) that also runs in grid/off/angles — within range of a wall (body or corner, uniform radius) the endpoint sticks onto it and the beacon shows, so rooms close in every mode. Lines keeps its wider magnetic radii. Gate alignment guides in non-magnetic modes to the same connect distance so a corner dot no longer magnetises the cursor from far. Tests: cover T-junction detection + wallClosesRoom; update the item-default and wall-split tests for the mode-driven behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(surfaces): gate alignment guides to connect distance in non-magnetic modes Port the wall drafting/endpoint gating to the shared slab/ceiling/roof snap (resolveSurfacePlanPointSnap): in grid/off/angles, only anchors within the connect distance are fed to the alignment resolver, so guides form to nearby points and far corner dots stop lighting up from across the plan. Filtering the candidates (local-frame, like the cursor) rather than the resolved guides avoids the floor-plan view rotation baked into the guide coords. Lines mode keeps the full-range guides; the surface wall connect-snap is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(slabs): deterministic wall-slab overlap via clipped overlap length Wall elevation used ray-cast point samples that landed exactly on the slab boundary for perpendicular walls, so whether a butting wall followed the slab's elevation depended on which side of the slab it touched. Clip the wall centerline and face lines against the polygon and require >=5cm of on/inside length instead: walls along the slab edge follow it on every side, point contact never does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(grid): lift lattice 1mm off slab tops; wall-align on wall-item move start The horizontal snap lattice sat exactly at the followed surface Y, so it z-fought elevated slab tops while moving items. The visual mesh now rides 1mm above; the grid event plane keeps the true height. Moving a wall-hosted item showed a horizontal grid until the first pointer move published a wall surface — the mesh fallback assumed UP. Derive the host wall normal from the item mesh's world orientation (local +Z faces out of the wall) so the lattice is wall-aligned from the first frame. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): unify selection shortcuts + truthful select-mode hints Shift+click now toggles selection membership in 3D like Cmd/Ctrl (and appends on box-select release), matching the 2D floorplan. The HUD rows describe what actually works in both views: move is plain left-drag (the grip/dot), the two vague selection rows collapse into one Cmd/Shift or-group, and the modifier-held variants (freely / with guides / bypass snaps) are gone — guides follow the snapping mode now. Key pills join with + for combos and / for alternatives; Shift renders as the shift icon. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): group gizmos — reliable rotate grabs, mutual hide, snapping-mode integration The group-rotate gizmo never received pointer events through the shared invisible hit-area path (EDITOR_LAYER + custom raycast) in its portalled context; its handlers now live on the visible arrow plus a plain default-layer invisible torus, so hover and drag work with a fat target. Group drags begin a handle-drag scope: each gizmo hides while its sibling drags (the frozen corner goes stale), idle hints leave the HUD, and the drag gets contextual hints — Shift free-rotation for rotate, the snapping chips for move. Group move joins the snapping-mode system via the 'item' context: Shift cycles the mode, Ctrl the grid step (both read live mid-drag), and 'lines' runs the same Figma-style alignment as single-node moves against the group's bbox anchors. Chip clicks now tick like the keyboard cycles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): group-move ticks in every snapping mode Mirror the single-node move's sfx: emit per delta change rather than only on grid crossings, so lines/off get the same rate-limited texture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(items): keep host transfers glued to the cursor Moving between hosts used to diverge from the pointer and mangle state: - Grab offsets: every surface anchor (wall / ceiling / shelf / item surface / floor) preserved the grab offset by re-seeding from the item's carried-over position on each new host, landing it far from the cursor. The grab offset now survives only on the original host and only until the item anchors anywhere else — after that every host (the original included) centers the item under the cursor. Applied uniformly to the placement coordinator and the window/door move tools. - Rotation: detaching from a rotated shelf/table back to the floor kept the HOST-local yaw as the level yaw, visibly spinning the item. The detach now re-expresses the item's world yaw in the level frame. - Elevation: the same detach wrote the level parentId to the store but not the draft ref, so the floor-elevation resolver bailed on its parent-must-be-a-level guard — the snap grid and the item stopped following slab elevations for the rest of the drag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(items): latch grab-offset forgetting across every host kind Review follow-up (PR #458): unify the per-surface grab-forget rule into one grabForgotten latch. A wall/ceiling item could not actually reach a shelf or the floor (item-surface enter rejects attachTo assets), but a wall item CAN anchor on a roof face — and returning to its original wall then restored the stale grab offset. Roof-wall transitions (and floor landings after a host visit) now trip the latch in the coordinator and the window/door move tools alike. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
705 lines
25 KiB
TypeScript
705 lines
25 KiB
TypeScript
import {
|
|
type AnyNodeId,
|
|
DoorNode,
|
|
emitter,
|
|
type GridEvent,
|
|
isCurvedWall,
|
|
type RoofEvent,
|
|
type RoofNode,
|
|
sceneRegistry,
|
|
spatialGridManager,
|
|
useScene,
|
|
type WallEvent,
|
|
type WallNode,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
calculateCursorRotation,
|
|
calculateItemRotation,
|
|
EDITOR_LAYER,
|
|
getSideFromNormal,
|
|
isMagneticSnapActive,
|
|
isValidWallSideFace,
|
|
triggerSFX,
|
|
useAlignmentGuides,
|
|
useEditor,
|
|
useFacingPose,
|
|
} 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()
|
|
|
|
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 = () => {
|
|
if (!draftRef.current) return
|
|
const wallId = draftRef.current.parentId
|
|
useScene.getState().deleteNode(draftRef.current.id)
|
|
draftRef.current = null
|
|
if (wallId) markHostDirty(wallId)
|
|
}
|
|
|
|
const hideCursor = () => {
|
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
|
useAlignmentGuides.getState().clear()
|
|
clearOpeningGuides3D()
|
|
setFallbackPose(null)
|
|
useFacingPose.getState().clear()
|
|
}
|
|
|
|
// 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',
|
|
})
|
|
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,
|
|
})
|
|
}
|
|
|
|
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
|
|
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
|
|
}
|
|
// 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
|
|
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()
|
|
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
|