449 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import {
type AnyNodeId,
DuctTerminalNode,
emitter,
pointInPolygon,
resolveLevelId,
sceneRegistry,
useScene,
type WallEvent,
} from '@pascal-app/core'
import {
CursorSphere,
getFloorStackPreviewPosition,
isGridSnapActive,
isMagneticSnapActive,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Euler, Matrix3, Matrix4, Plane, Quaternion, Raycaster, Vector2, Vector3 } from 'three'
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports'
import { ductTerminalDefinition } from './definition'
import { buildDuctTerminalGeometry } from './geometry'
import { COLLAR_LENGTH, mountQuaternion } from './ports'
const PREVIEW_OPACITY = 0.55
/** R/T yaw step — 45°. */
const ROTATE_STEP_RAD = Math.PI / 4
/** Fallback height (meters) for a ceiling node that carries no `height`. */
const DEFAULT_CEILING_HEIGHT = 2.5
/** Snap radius (meters) for mating the collar onto a nearby duct port. */
const PORT_SNAP_RADIUS_M = 0.5
type Mount = DuctTerminalNode['mount']
const MOUNT_CYCLE: Mount[] = ['floor', 'ceiling', 'wall']
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
/**
* Collar-port offset from the node origin for a given mount + yaw, in
* level-local meters — the same transform `def.ports` applies, so the
* placement tool can predict where the collar lands and shift the whole
* terminal to mate it onto a duct port.
*/
function collarOffset(mount: Mount, yaw: number): Vector3 {
const transform = new Quaternion()
.setFromEuler(new Euler(0, yaw, 0))
.multiply(mountQuaternion(mount))
return new Vector3(0, -COLLAR_LENGTH, 0).applyQuaternion(transform)
}
/** The active level's mesh, or null. Carries the building transform plus the
* level's stacked elevation — the frame terminals are stored and parented in,
* so cursor hits resolve to true level-local coords on every floor. */
function activeLevelMesh() {
const levelId = useViewer.getState().selection.levelId
return levelId ? (sceneRegistry.nodes.get(levelId as AnyNodeId) ?? null) : null
}
type Placement = {
position: [number, number, number]
/** Yaw radians applied to the ghost / committed node. */
yaw: number
/** Mount the ghost / committed node uses — inferred from the mated port
* when snapped, else the user's manual M selection. */
mount: Mount
/** True when the collar mated onto a nearby duct port (magnetic snap). */
snapped?: boolean
}
/** Direction is "vertical" when its Y component dominates this much. */
const VERTICAL_DOT = 0.7
/**
* Pick the mount that makes a collar mate onto a duct port pointing
* `dir` (the port's outward direction). The collar leaves the face along
* −Y in the canonical frame, so the mount rotation must turn −Y to face
* *into* the port (i.e. opposite `dir`):
* - port pointing up (a riser top) → collar must point down → **floor**
* - port pointing down (a ceiling drop) → collar points up → **ceiling**
* - port horizontal (a wall stub) → **wall**, yawed so the collar runs
* back along the port. `lockYaw` is set only for wall (floor / ceiling
* yaw is free — the user keeps spinning the face with R/T).
*/
function inferMountFromPort(dir: readonly [number, number, number]): {
mount: Mount
lockYaw: number | null
} {
const v = new Vector3(dir[0], dir[1], dir[2])
if (v.lengthSq() < 1e-8) return { mount: 'floor', lockYaw: null }
v.normalize()
if (v.y > VERTICAL_DOT) return { mount: 'floor', lockYaw: null }
if (v.y < -VERTICAL_DOT) return { mount: 'ceiling', lockYaw: null }
// Wall collar dir after mount + yaw is (−sin yaw, 0, −cos yaw); set it
// opposite the port so the collar runs back into the wall stub.
return { mount: 'wall', lockYaw: Math.atan2(v.x, v.z) }
}
/**
* If a duct port is within snap range of `position` (XZ — ports hang at
* duct height, the grid hit rides the floor), mate the register onto it:
* the port's direction *picks the mount* (floor / ceiling / wall) and, for
* walls, the yaw; the whole terminal then hops so its collar lands exactly
* on the port. Null when nothing is in range. `fallbackYaw` keeps the
* user's R/T face orientation for floor / ceiling mounts.
*/
function resolvePortSnap(
position: [number, number, number],
fallbackYaw: number,
): { position: [number, number, number]; mount: Mount; yaw: number } | null {
const port = findNearestPortXZ(
position,
collectScenePorts({ systems: DUCT_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (!port) return null
const { mount, lockYaw } = inferMountFromPort(port.direction)
const yaw = lockYaw ?? fallbackYaw
const offset = collarOffset(mount, yaw)
return {
position: [
port.position[0] - offset.x,
port.position[1] - offset.y,
port.position[2] - offset.z,
],
mount,
yaw,
}
}
/**
* Click-place tool for duct terminals (registers / diffusers / grilles).
*
* **Mount drives the target surface** (cycle with **M**): a floor register
* snaps to the floor grid, a ceiling diffuser snaps to a horizontal plane at
* ceiling height (derived from the level's ceilings/walls), and a wall
* register snaps flush onto whichever wall the cursor is over, its face
* oriented along the wall's outward normal. **R / T** rotate the floor/ceiling
* yaw ±45°; wall yaw is fixed by the wall it mates to.
*/
const DuctTerminalTool = () => {
const { camera, gl } = useThree()
const activeLevelId = useViewer((s) => s.selection.levelId)
const [mount, setMount] = useState<Mount>('floor')
const [placement, setPlacement] = useState<Placement | null>(null)
const mountRef = useRef<Mount>('floor')
const yawRef = useRef(0)
const raycaster = useRef(new Raycaster())
const pointer = useRef(new Vector2())
// The ghost mirrors whatever mount will actually be committed: a snap can
// override the manual M selection (port direction picks floor / ceiling /
// wall), so the preview must show the inferred mount, not the toolbar one.
const effectiveMount = placement?.mount ?? mount
const previewNode = useMemo(
() =>
DuctTerminalNode.parse({
...ductTerminalDefinition.defaults(),
name: 'Register',
mount: effectiveMount,
}),
[effectiveMount],
)
const ghost = useMemo(() => {
const group = buildDuctTerminalGeometry(previewNode)
group.traverse((child) => {
const mesh = child as { material?: { transparent: boolean; opacity: number } }
if (mesh.material) {
mesh.material.transparent = true
mesh.material.opacity = PREVIEW_OPACITY
}
})
return group
}, [previewNode])
useEffect(() => {
if (!activeLevelId) return
const canvas = gl.domElement
/**
* Intersect the cursor ray with a level-local horizontal plane at `y`.
* The ray is transformed into level-local space first (building transform
* plus the floor's stacked elevation), so the hit is already in the frame
* terminals are stored and parented in — accurate on every floor.
*/
const hitLocalPlane = (nativeEvent: PointerEvent | MouseEvent, y: number): Vector3 | null => {
const rect = canvas.getBoundingClientRect()
pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1
pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1
raycaster.current.setFromCamera(pointer.current, camera)
const level = activeLevelMesh()
const ray = raycaster.current.ray.clone()
if (level) {
const inv = new Matrix4().copy(level.matrixWorld).invert()
ray.applyMatrix4(inv)
}
const plane = new Plane(new Vector3(0, 1, 0), -y)
const hit = new Vector3()
return ray.intersectPlane(plane, hit) ? hit : null
}
/**
* Ceiling mount only lands where the cursor ray actually hits a real
* ceiling. Walk the active level's ceiling nodes, raycast each against a
* plane at its own height, and keep the lowest one whose polygon (minus
* holes) contains the hit — the surface you'd see looking up. Null when
* the ray misses every ceiling, so a ceiling register never drops onto a
* fixed virtual plane; the height comes from the ceiling itself.
*/
const resolveCeilingHit = (
nativeEvent: PointerEvent | MouseEvent,
): { hit: Vector3; height: number } | null => {
const nodes = useScene.getState().nodes
let best: { hit: Vector3; height: number } | null = null
for (const node of Object.values(nodes)) {
if (node?.type !== 'ceiling') continue
if (resolveLevelId(node, nodes) !== activeLevelId) continue
const ceiling = node as {
height?: number
polygon: Array<[number, number]>
holes?: Array<Array<[number, number]>>
}
const height = ceiling.height ?? DEFAULT_CEILING_HEIGHT
const hit = hitLocalPlane(nativeEvent, height)
if (!hit) continue
if (!pointInPolygon(hit.x, hit.z, ceiling.polygon)) continue
if (ceiling.holes?.some((h) => h.length >= 3 && pointInPolygon(hit.x, hit.z, h))) continue
if (!best || height < best.height) best = { hit, height }
}
return best
}
const resolvePlanar = (nativeEvent: PointerEvent | MouseEvent): Placement | null => {
// Floor sits on the grid (y=0; the slab lift is applied to the committed
// mesh by FloorElevationSystem). Ceiling resolves the real ceiling the
// ray hits and takes that surface's height — no fixed fallback plane.
let hit: Vector3 | null
let y: number
if (mountRef.current === 'ceiling') {
const ceiling = resolveCeilingHit(nativeEvent)
if (!ceiling) return null
hit = ceiling.hit
y = ceiling.height
} else {
y = 0
hit = hitLocalPlane(nativeEvent, y)
}
if (!hit) return null
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
// Grid-snap, then layer Figma-style alignment so a floor / ceiling
// register lines up with ducts, equipment, and items. Grid + lines
// follow the active snapping mode (the contextual HUD chip — Shift
// cycles it); `'off'` is the no-snap bypass.
const position = alignDrawPoint([snap(hit.x, step), y, snap(hit.z, step)], {
applySnap: isMagneticSnapActive(),
bypass: false,
})
// Magnetic port snap: if a duct run end / fitting collar is in range,
// the port's direction picks the mount (floor / ceiling / wall) and
// hops the whole register so its collar mates exactly onto it. Takes
// precedence over grid / alignment and the manual M mount; the
// raw-cursor `'off'` mode bypasses it.
const snapEnabled = isGridSnapActive() || isMagneticSnapActive()
if (snapEnabled) {
const mated = resolvePortSnap(position, yawRef.current)
if (mated) {
return { position: mated.position, yaw: mated.yaw, mount: mated.mount, snapped: true }
}
}
return { position, yaw: yawRef.current, mount: mountRef.current }
}
const commit = (p: Placement) => {
const terminal = DuctTerminalNode.parse({
...ductTerminalDefinition.defaults(),
name: 'Register',
mount: p.mount,
position: p.position,
rotation: p.yaw,
})
useScene.getState().createNode(terminal, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [terminal.id] })
triggerSFX('sfx:item-place')
}
// ---- Floor / ceiling: own raycast against a horizontal plane ----
const onPointerMove = (e: PointerEvent) => {
if (mountRef.current === 'wall') return
setPlacement(resolvePlanar(e))
}
const onCanvasClick = (e: MouseEvent) => {
if (mountRef.current === 'wall') return
if (useViewer.getState().cameraDragging) return
if ((e as PointerEvent).button !== undefined && (e as PointerEvent).button !== 0) return
const p = resolvePlanar(e)
if (p) commit(p)
}
// ---- Wall: consume wall hover/click events, orient to the wall ----
const resolveWall = (event: WallEvent): Placement | null => {
if (!event.normal) return null
// Wall faces are the ±Z faces in wall-local space; skip the thin
// top / end caps so the terminal only mounts onto a real face.
if (Math.abs(event.normal[2]) <= 0.7) return null
const worldNormal = new Vector3(event.normal[0], event.normal[1], event.normal[2])
.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld))
.normalize()
// Face normal after the wall mount + yaw is (sin yaw, 0, cos yaw);
// align it with the wall's outward world normal.
const yaw = Math.atan2(worldNormal.x, worldNormal.z)
const world = new Vector3(event.position[0], event.position[1], event.position[2])
const level = activeLevelMesh()
const local = level ? level.worldToLocal(world.clone()) : world
return { position: [local.x, local.y, local.z], yaw, mount: 'wall' }
}
const onWallMove = (event: WallEvent) => {
if (mountRef.current !== 'wall') return
// Wall-mounted terminals snap flush to the wall — no plan alignment.
clearDrawAlignment()
const p = resolveWall(event)
if (p) setPlacement(p)
}
const onWallClick = (event: WallEvent) => {
if (mountRef.current !== 'wall') return
if (useViewer.getState().cameraDragging) return
const p = resolveWall(event)
if (p) commit(p)
}
const onKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
const key = e.key
if (key === 'm' || key === 'M') {
e.preventDefault()
e.stopPropagation()
const next = MOUNT_CYCLE[(MOUNT_CYCLE.indexOf(mountRef.current) + 1) % MOUNT_CYCLE.length]!
mountRef.current = next
setMount(next)
// Wall placement only resolves over a wall; clear the stale ghost.
if (next === 'wall') setPlacement(null)
triggerSFX('sfx:item-rotate')
return
}
if (key !== 'r' && key !== 'R' && key !== 't' && key !== 'T') return
// Wall yaw is dictated by the wall, so R/T only apply to planar mounts.
if (mountRef.current === 'wall') return
e.preventDefault()
e.stopPropagation()
const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1
yawRef.current += steps * ROTATE_STEP_RAD
setPlacement((prev) => (prev ? { ...prev, yaw: yawRef.current } : prev))
triggerSFX('sfx:item-rotate')
}
canvas.addEventListener('pointermove', onPointerMove)
canvas.addEventListener('click', onCanvasClick)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
window.addEventListener('keydown', onKeyDown, true)
return () => {
canvas.removeEventListener('pointermove', onPointerMove)
canvas.removeEventListener('click', onCanvasClick)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
window.removeEventListener('keydown', onKeyDown, true)
clearDrawAlignment()
}
}, [activeLevelId, camera, gl])
if (!activeLevelId || !placement) return null
const mountLabel = effectiveMount.charAt(0).toUpperCase() + effectiveMount.slice(1)
// The committed mesh's slab lift is applied by FloorElevationSystem, but the
// ghost renders here directly — preview it on the slab top too so a floor
// register doesn't appear to sink in before the click.
const previewPosition =
effectiveMount === 'floor'
? getFloorStackPreviewPosition({
node: previewNode,
position: placement.position,
rotation: placement.yaw,
levelId: activeLevelId,
})
: placement.position
return (
<LevelOffsetGroup>
{/* Same ground ring + vertical line + tool-icon badge the duct draw
tool shows in 3D (icon resolved from the active `duct-terminal`
structure-tools entry). In 2D the floorplan overlay draws this for
every tool; in 3D each tool renders its own. */}
<CursorSphere position={previewPosition} />
<group position={previewPosition} rotation={[0, placement.yaw, 0]}>
<primitive object={ghost} />
</group>
<Html
center
position={[previewPosition[0], previewPosition[1] + 1.45, previewPosition[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
{placement.snapped && (
<>
<span className="font-medium text-primary">Snapped to duct</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
</>
)}
<span className="font-medium text-foreground">Mount {mountLabel}</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground">M surface</span>
{effectiveMount !== 'wall' && (
<>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground">R/T rotate</span>
</>
)}
</div>
</Html>
</LevelOffsetGroup>
)
}
export default DuctTerminalTool