'use client' import { DuctFittingNode, emitter, type GridEvent, useScene } from '@pascal-app/core' import { CursorSphere, EDITOR_LAYER, isGridSnapActive, triggerSFX, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useEffect, useMemo, useRef, useState } from 'react' import { Euler, Quaternion, Vector3 } from 'three' import { AXIS_VECTORS, cycleRotationAxis, getRotationAxis, ROTATE_STEP_RAD, } from '../shared/fitting-rotation' import { LevelOffsetGroup } from '../shared/level-offset-group' import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ, type ScenePort, } from '../shared/ports' import { ductFittingDefinition } from './definition' import { buildDuctFittingGeometry } from './geometry' import { localFittingPorts } from './ports' /** Snap radius (meters, XZ) for mating onto an existing port. */ const PORT_SNAP_RADIUS_M = 0.5 const PREVIEW_OPACITY = 0.55 function snap(value: number, step: number): number { if (step <= 0) return value return Math.round(value / step) * step } type Placement = { position: [number, number, number] rotation: [number, number, number] snapPort: ScenePort | null } /** * Resolve where the fitting would land for a cursor at `raw`: * - Near an existing port → mate: orientation aligns the inlet onto * the port (plus the user's manual R/T rotation, pivoting around * the inlet collar so it stays on the port while the body sweeps). * - Otherwise → grid-snapped free placement on the floor, manual * rotation only. */ function resolvePlacement( raw: [number, number, number], previewNode: DuctFittingNode, gridStep: number, manualQuat: Quaternion, ): Placement { const port = findNearestPortXZ( raw, collectScenePorts({ systems: DUCT_PORT_SYSTEMS }), PORT_SNAP_RADIUS_M, ) if (port) { const direction = new Vector3(...port.direction).normalize() // Local +X must map onto the port's outward direction so the inlet // (local -X) faces back into the run it's joining. Manual rotation // composes in the world frame on top of the mate orientation. const mate = new Quaternion().setFromUnitVectors(new Vector3(1, 0, 0), direction) const final = manualQuat.clone().multiply(mate) const inlet = localFittingPorts(previewNode)[0]! const inletWorldOffset = inlet.position.clone().applyQuaternion(final) const position = new Vector3(...port.position).sub(inletWorldOffset) const euler = new Euler().setFromQuaternion(final) return { position: [position.x, position.y, position.z], rotation: [euler.x, euler.y, euler.z], snapPort: port, } } const euler = new Euler().setFromQuaternion(manualQuat) return { position: [snap(raw[0], gridStep), 0, snap(raw[2], gridStep)], rotation: [euler.x, euler.y, euler.z], snapPort: null, } } /** * Click-place tool for duct fittings (elbow / tee / reducer). * * A translucent ghost of the fitting follows the cursor. Within snap * range of any scene port (duct run ends, other fittings' collars) the * ghost jumps onto the port — position AND orientation — so one click * mates the fitting onto the run. * * Rotation while placing: **R / T** turn the ghost ±45° around the * active world axis; **Alt** cycles the axis (Y → X → Z). The HUD badge * above the ghost shows the current axis. When snapped to a port the * rotation pivots around the inlet collar so the joint stays mated. * Handlers run in the capture phase so R doesn't also spin whatever * node happens to be selected. */ const DuctFittingTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) const [placement, setPlacement] = useState(null) const axis = useEditor((s) => s.rotationAxis) // Accumulated manual rotation from R/T presses. Ref (not state) so the // emitter callbacks always read the latest without re-subscribing; a // placement recompute is triggered explicitly after each change. const manualQuatRef = useRef(new Quaternion()) // Last raw cursor position so a key press can recompute the placement // without waiting for the next mouse move. const lastRawRef = useRef<[number, number, number] | null>(null) // Ghost matches exactly what a click creates (the kind's defaults). const previewNode = useMemo( () => DuctFittingNode.parse({ ...ductFittingDefinition.defaults(), name: 'Duct fitting' }), [], ) const ghost = useMemo(() => { const group = buildDuctFittingGeometry(previewNode) group.traverse((child) => { // Overlay layer keeps the placement ghost out of the ink / SSGI // buffers and the thumbnail export, like every other tool preview. child.layers.set(EDITOR_LAYER) 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 recompute = () => { const raw = lastRawRef.current if (!raw) return setPlacement( resolvePlacement( raw, previewNode, isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, manualQuatRef.current, ), ) } const onMove = (event: GridEvent) => { lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]] recompute() } const onClick = (event: GridEvent) => { lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]] const { position, rotation } = resolvePlacement( lastRawRef.current, previewNode, isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, manualQuatRef.current, ) const fitting = DuctFittingNode.parse({ ...ductFittingDefinition.defaults(), name: 'Duct fitting', position, rotation, }) useScene.getState().createNode(fitting, activeLevelId) useViewer.getState().setSelection({ selectedIds: [fitting.id] }) triggerSFX('sfx:item-place') } const onKeyDown = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement | null)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return const key = e.key if (key === 'r' || key === 'R' || key === 't' || key === 'T') { // Capture-phase + stopPropagation so the editor's selection-rotate // R handler doesn't also fire while the placement tool owns R. e.preventDefault() e.stopPropagation() const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1 const turn = new Quaternion().setFromAxisAngle( AXIS_VECTORS[getRotationAxis()], steps * ROTATE_STEP_RAD, ) manualQuatRef.current = turn.multiply(manualQuatRef.current) triggerSFX('sfx:item-rotate') recompute() } else if (key === 'Alt' && !e.repeat) { e.preventDefault() e.stopPropagation() cycleRotationAxis() } } emitter.on('grid:move', onMove) emitter.on('grid:click', onClick) window.addEventListener('keydown', onKeyDown, true) return () => { emitter.off('grid:move', onMove) emitter.off('grid:click', onClick) window.removeEventListener('keydown', onKeyDown, true) } }, [activeLevelId, previewNode]) if (!activeLevelId || !placement) return null return ( {/* Same ground ring + vertical line + tool-icon badge the duct draw tool shows in 3D (icon resolved from the active `duct-fitting` structure-tools entry). In 2D the floorplan overlay draws this for every tool; in 3D each tool renders its own. */} {/* Rotation HUD — active axis + key hints, pinned above the ghost. */} {/* Same pill shell as DimensionPill so the placement HUD matches the drawing / dragging readouts. */}
Axis {axis.toUpperCase()} · R/T rotate · ⌥ axis
{/* Port-snap halo so the user sees the click will mate, not free-place. */} {placement.snapPort && ( )}
) } export default DuctFittingTool