* fix(core): elect wall slab support from the carrying profile, not face coverage An elevated deck drawn against a house wall covers the wall's outer face line end-to-end (boundary contact counts), so the max-across-polylines election handed the wall origin to the deck: every wall-hosted window/door rode along whenever the deck height changed, and placement local-Y was clamped above the deck top. Elect from the carrying profile instead (per arc segment: highest support per face, min across supported faces), with the pointer cap applied inside the profile so a capped-away deck still falls back to the floor that carries the wall. Also pass curveOffset/ thickness/supportSlabId at the window/door tool query sites so their cursor agrees with the rendered wall frame. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): honor live node overrides in spatial-grid support queries Group drags publish translated slab polygons and wall endpoints to useLiveNodeOverrides only; the committed spatial index made floor items and walls re-elect support against the pre-drag slab footprint, so multi-selection moves and room-preset placement jumped vertically until the validating click committed the batch. Support queries now read live-effective slab/wall records and bypass the rendered-polygon cache while a slab or wall on the level has an override; the committed cached path stays the fast path otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): give the room-preset stamp tool a polygon snap context The host app's room stamp drives placement with tool='room', which has no registry entry, so snapContextOf resolved null: Shift never cycled the snapping mode and the HUD chip stayed hidden during room preset placement. A tool-level context map hands the stamp the no-angle polygon set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): only synthesize walkthrough fallback floors for ground levels Every slab-less visible level got a >=30x30m opening-free fallback floor box at its elevation, so a walkthrough spawned on an upper level (the no-spawn-node fallback ray picks the highest surface) stood on a phantom plane it could never descend from. Match the baked-GLB viewer policy: only the lowest level of each building (derived baseY === 0) gets the fallback; upper levels rely on their real slabs, whose stair openings are cut into the geometry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): fold useLiveTransforms slab deltas into support queries The slab move tool and the room-preset stamp publish a translation DELTA to useLiveTransforms (no polygon override), so the spatial index still elected support against the slab's committed footprint: furniture riding a room-preset preview (or sitting on a dragged deck) dropped to ground under the visually-moved deck until the validating click. Effective slab records now apply the live delta to polygon/holes/elevation, mapped exactly once at each query's loop entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(viewer): reapply floor lift every frame for nodes with live previews FloorElevationSystem only wrote mesh Y for dirty nodes, but the React commit that rebinds a dragged node's base-Y group position can land between frames, after priority-2 systems consumed the dirty mark — the lift then vanished until the next pointer tick re-dirtied the node, blinking the Y of items dragged over elevated slabs. Nodes holding a live override or transform now get the lift reapplied every frame. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): stop the entry camera swap from insta-cancelling walkthrough Entering walkthrough with a persisted orthographic camera swaps it to perspective, which recreates the interaction callbacks and re-ran the pointer-lock effect: its cleanup called exitPointerLock, and the unlock handler read that as "user left walkthrough" — instantly cancelling the fresh entry and arming the browser's ~1.25s re-lock cooldown (hence needing multiple button presses). The effect is now mount-stable (the changing callback rides a ref, deps down to [gl]), and the entry lock request swallows async cooldown rejections like the P-resume path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): don't block build-JSON import over plugin-node children Exports from projects with plugins carry nodes like trees:tree whose ids sit in level.children; the import validator parsed parents against the static children id union, so one tree id hard-failed the level schema and blocked the whole import — while the same data loads fine from the DB (setScene never runs this gate). Parents are now validated against a copy with non-static-schema child ids filtered out; those nodes keep surfacing through the unknown-types warning and the imported payload is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(core): treat registered plugin kinds as first-class in import validation Nodes of runtime-registered plugin kinds (trees:tree, trees:grass) were lumped into the unknown-types warning even when the plugin is loaded. The validator now consults the node registry: registered kinds validate against their own registered schema (corrupt plugin nodes still block), count under stats.pluginTypes, and raise no warning — only genuinely unregistered types do, which stays correct for hosts without the plugin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: appease biome (format + forEach block body) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
843 lines
30 KiB
TypeScript
843 lines
30 KiB
TypeScript
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
emitter,
|
|
type GridEvent,
|
|
isCurvedWall,
|
|
type RoofEvent,
|
|
type RoofNode,
|
|
sceneRegistry,
|
|
spatialGridManager,
|
|
useScene,
|
|
type WallEvent,
|
|
type WallNode,
|
|
WallNode as WallNodeSchema,
|
|
WindowNode,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
calculateCursorRotation,
|
|
calculateItemRotation,
|
|
EDITOR_LAYER,
|
|
getSideFromNormal,
|
|
isMagneticSnapActive,
|
|
isValidWallSideFace,
|
|
snapToHalf,
|
|
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,
|
|
resolveSillSnap,
|
|
} 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 { WindowFloorProjection } from './floor-projection'
|
|
import WindowPreview from './preview'
|
|
import {
|
|
clampToWall,
|
|
DEFAULT_WINDOW_SILL_M,
|
|
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
|
|
// Off-wall ghost lift = the default sill, so the floating preview matches the
|
|
// sill the floor-cursor placement commits at.
|
|
const FALLBACK_SILL_LIFT = DEFAULT_WINDOW_SILL_M
|
|
// Default sill centre for a window snapped from the floor (the floor cursor
|
|
// carries no wall-face height): the default sill + half the default height.
|
|
const DEFAULT_SILL_CENTER_Y = DEFAULT_WINDOW_SILL_M + FALLBACK_HEIGHT / 2
|
|
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
|
|
|
|
/**
|
|
* 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).
|
|
*
|
|
* 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.
|
|
*/
|
|
const WindowTool: React.FC = () => {
|
|
const draftRef = useRef<WindowNode | null>(null)
|
|
const cursorGroupRef = useRef<Group>(null!)
|
|
const edgesRef = useRef<LineSegments>(null!)
|
|
|
|
// Off-host floating ghost: the real window geometry follows the cursor
|
|
// over the grid (tinted invalid). Mutually exclusive with the on-host draft.
|
|
// `floorY` feeds the floor "shadow" projection; `side` carries the R-flip so
|
|
// the floating ghost faces the side that will be committed.
|
|
const [fallbackPose, setFallbackPose] = useState<{
|
|
position: [number, number, number]
|
|
rotationY: number
|
|
floorY: number
|
|
side: WindowNode['side']
|
|
} | null>(null)
|
|
|
|
// Ghost preview node — zeroed transform + the live facing side (rebuilds on R).
|
|
const ghostStub = useMemo(
|
|
() =>
|
|
WindowNode.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 = WindowNode.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 !== 'window') 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 window's facing side mid-placement (front ↔ back); 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) + floor Y, so an R-flip
|
|
// while free-following can re-render the floating ghost with the new facing.
|
|
let lastFloorPoint: { pos: [number, number, number]; floorY: 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,
|
|
wall.curveOffset ?? 0,
|
|
wall.thickness,
|
|
wall.supportSlabId,
|
|
)
|
|
|
|
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()
|
|
// 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()
|
|
setFallbackPose(null)
|
|
useFacingPose.getState().clear()
|
|
clearPlacementPreview()
|
|
}
|
|
|
|
// 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 = 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 window's front is +Z.
|
|
// The indicator rides at the sill (`indicatorYOffset`).
|
|
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 window
|
|
// geometry (tinted invalid) at the cursor so the armed tool is visible.
|
|
const showGhostAt = (position: [number, number, number], floorY: number) => {
|
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
|
lastFloorPoint = { pos: position, floorY }
|
|
// `sideFlip` (R) flips the facing — back is a π yaw on the floating ghost.
|
|
setFallbackPose({
|
|
position,
|
|
rotationY: sideFlip ? Math.PI : 0,
|
|
floorY,
|
|
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 = WindowNode.parse({
|
|
...fallbackPreview,
|
|
metadata: { isTransient: true },
|
|
parentId: wall.id,
|
|
position: [halfWidth, FALLBACK_SILL_LIFT + 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 + FALLBACK_SILL_LIFT, z],
|
|
getLevelYOffset(),
|
|
)
|
|
}
|
|
|
|
const showWallFallbackCursor = (event: WallEvent) => {
|
|
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
|
showGhostAt(
|
|
[x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z],
|
|
getLevelYOffset(),
|
|
)
|
|
}
|
|
|
|
// Sill alignment (snap + guide): a sibling sill/centre/top wins over the
|
|
// grid when within threshold — it's the magnetic ("lines") component for the
|
|
// vertical axis, so it runs only when magnetic snap is on; otherwise the
|
|
// grid `snapToHalf` (itself mode-aware) decides Y. `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: {
|
|
wall: WallNode
|
|
movingId: string
|
|
localX: number
|
|
rawLocalY: number
|
|
width: number
|
|
height: number
|
|
}): number => {
|
|
const sillY = isMagneticSnapActive()
|
|
? resolveSillSnap({
|
|
wall: args.wall,
|
|
movingId: args.movingId,
|
|
localX: args.localX,
|
|
localY: args.rawLocalY,
|
|
width: args.width,
|
|
height: args.height,
|
|
nodes: useScene.getState().nodes,
|
|
})
|
|
: null
|
|
return sillY ?? snapToHalf(args.rawLocalY)
|
|
}
|
|
|
|
// Settle a wall target: alignment snap → sill clamp → overlap check.
|
|
const resolveWallPlacement = (
|
|
wall: WallNode,
|
|
rawLocalX: number,
|
|
rawLocalY: 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` (mode-aware).
|
|
const localX = resolveWallSlideAlignment({
|
|
wallNode: wall,
|
|
rawLocalX,
|
|
width,
|
|
candidates: alignmentCandidates,
|
|
applySnap,
|
|
})
|
|
const localY = resolvePlacementY({
|
|
wall,
|
|
movingId: ignoreId ?? '',
|
|
localX,
|
|
rawLocalY,
|
|
width,
|
|
height,
|
|
})
|
|
const { clampedX, clampedY } = clampToWall(
|
|
wall,
|
|
localX,
|
|
localY,
|
|
width,
|
|
height,
|
|
useScene.getState().nodes,
|
|
)
|
|
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
|
|
rawLocalY: number
|
|
side: 'front' | 'back'
|
|
itemRotation: number
|
|
cursorRotationY: number
|
|
applySnap: boolean
|
|
}) => {
|
|
const { wall, rawLocalX, rawLocalY, side, itemRotation, cursorRotationY, applySnap } = args
|
|
const width = draftRef.current?.width ?? 1.5
|
|
const height = draftRef.current?.height ?? 1.5
|
|
|
|
if (!draftRef.current) {
|
|
const node = WindowNode.parse({
|
|
position: [0, DEFAULT_SILL_CENTER_Y, 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,
|
|
rawLocalY,
|
|
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: true,
|
|
levelYOffset: getLevelYOffset(),
|
|
slabElevation: getSlabElevationForWall(wall),
|
|
})
|
|
}
|
|
return { clampedX, clampedY, valid }
|
|
}
|
|
|
|
// Promote the draft into a permanent window. Shared by the wall-mesh click
|
|
// and the floor proximity click.
|
|
const commitWindowAtWall = (
|
|
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 windowCount = Object.values(state.nodes).filter((n) => {
|
|
if (n.type !== 'window') return false
|
|
const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
|
return w?.parentId === levelId
|
|
}).length
|
|
|
|
const node = WindowNode.parse({
|
|
name: `Window ${windowCount + 1}`,
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: [0, itemRotation, 0],
|
|
side,
|
|
wallId: wall.id,
|
|
parentId: wall.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, 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],
|
|
rawLocalY: event.localPosition[1],
|
|
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],
|
|
event.localPosition[1],
|
|
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
|
|
|
|
commitWindowAtWall(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
|
|
destroyDraft()
|
|
showGhostAt([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], y)
|
|
}
|
|
|
|
// ── 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',
|
|
// `snapToHalf` is mode-aware (raw cursor when grid snap is off).
|
|
snap: snapToHalf,
|
|
},
|
|
})
|
|
|
|
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 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
|
|
}
|
|
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 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] })
|
|
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 window's facing side mid-placement (front ↔ back). ALWAYS
|
|
// toggles the persistent flip intent — never a no-op (the old `!lastWallEvent`
|
|
// guard dropped R off-wall / before the first hover). Then re-renders the
|
|
// current preview 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) {
|
|
onWallHover(lastWallEvent)
|
|
} else if (lastFloorPoint) {
|
|
showGhostAt(lastFloorPoint.pos, lastFloorPoint.floorY)
|
|
}
|
|
// 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: window outline rectangle. 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}>
|
|
<WindowPreview invalid node={ghostStub} />
|
|
</group>
|
|
)}
|
|
{/* Floor "shadow" projection for the off-host ghost (drop-line + footprint)
|
|
so the elevated window's plan position is legible while placing. */}
|
|
{fallbackPose && (
|
|
<WindowFloorProjection
|
|
centerX={fallbackPose.position[0]}
|
|
centerY={fallbackPose.position[1]}
|
|
centerZ={fallbackPose.position[2]}
|
|
floorY={fallbackPose.floorY}
|
|
rotationY={fallbackPose.rotationY}
|
|
width={FALLBACK_WIDTH}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default WindowTool
|