Files
editor/packages/nodes/src/duct-segment/move-tool.tsx
T
Sudhir YadavandGitHub 5551500d98 feat: HVAC ductwork + DWV plumbing systems (#402)
Adds two new MEP node families (HVAC ductwork, DWV plumbing) built on a shared port-connectivity model. Co-authored by @sudhir9297.
2026-06-16 15:30:39 -04:00

331 lines
10 KiB
TypeScript

'use client'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
DuctSegmentNode,
emitter,
type GridEvent,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import {
DragBoundingBox,
EDITOR_LAYER,
markToolCancelConsumed,
stripPlacementMetadataFlags,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef, useState } from 'react'
import { Matrix4, Vector3 } from 'three'
import {
type Aabb2D,
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
import { rectSectionAxes } from './geometry'
type Vec3 = [number, number, number]
const GHOST_COLOR = '#818cf8'
const GHOST_OPACITY = 0.5
const IN_TO_M = 0.0254
/** Snap a coordinate to the editor's live grid step. */
function snapToGridStep(value: number): number {
const step = useEditor.getState().gridSnapStep
if (step <= 0) return value
return Math.round(value / step) * step
}
function pathCenterXZ(path: readonly Vec3[]): [number, number] {
let x = 0
let z = 0
for (const p of path) {
x += p[0]
z += p[2]
}
const n = path.length || 1
return [x / n, z / n]
}
/** Half the run's cross-section (meters) — the box / footprint padding. */
function runRadiusM(duct: DuctSegmentNode): number {
if (duct.shape === 'round') return (duct.diameter * IN_TO_M) / 2
return (Math.max(duct.width, duct.height) * IN_TO_M) / 2
}
/** The run's vertical box extent (meters). */
function runHeightM(duct: DuctSegmentNode): number {
return (duct.shape === 'round' ? duct.diameter : duct.height) * IN_TO_M
}
/** XZ bounds of a path padded by the run's radius. */
function pathAabb(path: readonly Vec3[], r: number): Aabb2D {
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (const p of path) {
if (p[0] < minX) minX = p[0]
if (p[0] > maxX) maxX = p[0]
if (p[2] < minZ) minZ = p[2]
if (p[2] > maxZ) maxZ = p[2]
}
return { minX: minX - r, maxX: maxX + r, minZ: minZ - r, maxZ: maxZ + r }
}
/**
* Ghost-preview duplicate / move tool for duct runs.
*
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
* inserted into the scene until the commit click. A translucent ghost of
* the run (cylinders / boxes matching its profile) rides the cursor inside
* a footprint bounding box — the same affordance other items get — and
* Figma-style alignment guides snap the box's edges to nearby geometry. The
* next grid click calls `createNode`; Esc discards.
*
* **Move** (existing run): the real node is hidden while the same ghost +
* box tracks the cursor; the commit click writes the translated `path` and
* reveals it, Esc reveals it unchanged.
*
* Wired via `def.affordanceTools.move`.
*/
export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
const duct = node as DuctSegmentNode
const originalPathRef = useRef<Vec3[]>(duct.path.map((p) => [...p] as Vec3))
const isNew =
typeof node.metadata === 'object' &&
node.metadata !== null &&
!Array.isArray(node.metadata) &&
(node.metadata as Record<string, unknown>).isNew === true
const [previewPath, setPreviewPath] = useState<Vec3[]>(originalPathRef.current)
const previewPathRef = useRef<Vec3[]>(originalPathRef.current)
const hasMovedRef = useRef(false)
const activatedAtRef = useRef<number>(Date.now())
const prevSnapRef = useRef<[number, number] | null>(null)
useEffect(() => {
const nodeId = node.id as AnyNodeId
const originalPath = originalPathRef.current
const [centerX, centerZ] = pathCenterXZ(originalPath)
const r = runRadiusM(duct)
const baseAabb = pathAabb(originalPath, r)
useScene.temporal.getState().pause()
let committed = false
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
useScene.getState().nodes,
nodeId,
useViewer.getState().selection.levelId ?? node.parentId,
)
// Moving an existing run: hide its 3D MESH imperatively (NOT the store
// `visible` flag — the 2D floor plan skips `visible:false` nodes, so a
// store hide makes the run vanish in 2D / split view). The ghost stands
// in until commit; the real mesh is restored on cancel / unmount.
const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId]
const setMeshHidden = (hidden: boolean) => {
const obj = sceneRegistry.nodes.get(nodeId)
if (obj) obj.visible = !hidden
}
if (existedAtStart) setMeshHidden(true)
const setPreview = (path: Vec3[]) => {
previewPathRef.current = path
setPreviewPath(path)
}
const onMove = (event: GridEvent) => {
const bypass = event.nativeEvent?.shiftKey === true
const snap = bypass ? (v: number) => v : snapToGridStep
let dx = snap(event.localPosition[0] - centerX)
let dz = snap(event.localPosition[2] - centerZ)
// Figma-style alignment: snap the run's footprint box edges onto
// nearby geometry and publish the guides (Alt / Shift bypass).
if (!bypass) {
const proposed: Aabb2D = {
minX: baseAabb.minX + dx,
maxX: baseAabb.maxX + dx,
minZ: baseAabb.minZ + dz,
maxZ: baseAabb.maxZ + dz,
}
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
dx += sdx
dz += sdz
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
}
const cur: [number, number] = [centerX + dx, centerZ + dz]
if (
!bypass &&
(!prevSnapRef.current ||
prevSnapRef.current[0] !== cur[0] ||
prevSnapRef.current[1] !== cur[1])
) {
triggerSFX('sfx:grid-snap')
}
prevSnapRef.current = cur
hasMovedRef.current = true
setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3))
}
const commit = (event: GridEvent) => {
if (committed) return
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
if (!hasMovedRef.current) {
event.nativeEvent?.stopPropagation?.()
return
}
committed = true
const finalPath = previewPathRef.current
useScene.temporal.getState().resume()
let selectId = nodeId
if (isNew && !useScene.getState().nodes[nodeId]) {
const created = DuctSegmentNode.parse({
...(node as Record<string, unknown>),
path: finalPath,
metadata: stripPlacementMetadataFlags(node.metadata),
visible: true,
})
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
useScene.getState().updateNode(nodeId, { path: finalPath } as Partial<AnyNode>)
useScene.getState().markDirty(nodeId)
}
useScene.temporal.getState().pause()
setMeshHidden(false)
useAlignmentGuides.getState().clear()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [selectId] })
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
}
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
markToolCancelConsumed()
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', commit)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
}
}, [duct, isNew, node])
const segments: Array<{ a: Vec3; b: Vec3 }> = []
for (let i = 0; i < previewPath.length - 1; i++) {
segments.push({ a: previewPath[i]!, b: previewPath[i + 1]! })
}
// Footprint box spanning the whole run (axis-aligned), drawn around the
// ghost the same way items get one. Recomputed from the live preview path.
const r = runRadiusM(duct)
const box = pathAabb(previewPath, r)
const boxY = previewPath[0]?.[1] ?? 0
return (
<group>
{segments.map((seg, i) => (
<GhostSegment a={seg.a} b={seg.b} duct={duct} key={`ghost-${i}`} />
))}
<DragBoundingBox
centerY={0}
nodeId={node.id}
position={[(box.minX + box.maxX) / 2, boxY, (box.minZ + box.maxZ) / 2]}
size={[box.maxX - box.minX, runHeightM(duct), box.maxZ - box.minZ]}
/>
</group>
)
}
/** Translucent stand-in for one duct section — mirrors the draw tool's
* `PreviewSegment` so the ghost matches what actually lands. */
function GhostSegment({ a, b, duct }: { a: Vec3; b: Vec3; duct: DuctSegmentNode }) {
const start = new Vector3(...a)
const end = new Vector3(...b)
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-4) return null
dir.normalize()
const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5)
if (duct.shape !== 'round') {
const w = duct.width * IN_TO_M
const h = duct.height * IN_TO_M
return (
<mesh
layers={EDITOR_LAYER}
position={mid.toArray()}
ref={(m) => {
if (!m) return
const { width: x, height: z } = rectSectionAxes(dir, duct.roll)
m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))
}}
>
<boxGeometry args={[w, length, h]} />
<meshBasicMaterial
color={GHOST_COLOR}
depthTest={false}
opacity={GHOST_OPACITY}
transparent
/>
</mesh>
)
}
const radius = (duct.diameter * IN_TO_M) / 2
return (
<mesh
layers={EDITOR_LAYER}
position={mid.toArray()}
ref={(m) => {
if (!m) return
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
}}
>
<cylinderGeometry args={[radius, radius, length, 24, 1, false]} />
<meshBasicMaterial
color={GHOST_COLOR}
depthTest={false}
opacity={GHOST_OPACITY}
transparent
/>
</mesh>
)
}
export default MoveDuctSegmentTool