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.
This commit is contained in:
Sudhir Yadav
2026-06-16 15:30:39 -04:00
committed by GitHub
parent a0d3d9c701
commit 5551500d98
172 changed files with 17361 additions and 150 deletions
@@ -0,0 +1,130 @@
import type { NodeDefinition } from '@pascal-app/core'
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
import { buildPipeSegmentFloorplan } from './floorplan'
import { buildPipeSegmentGeometry } from './geometry'
import { pipeSegmentParametrics } from './parametrics'
import { PipeSegmentNode } from './schema'
/**
* Phase 4 of the distribution-system effort (the research doc's Phase 2)
* — DWV plumbing's first kind: the pipe run. The plumbing sibling of
* `duct-segment`: same polyline + typed-ports model, with SLOPE as the
* new ingredient (the draw tool drops waste runs ¼"/ft; vents run level
* or vertical).
*
* Deferred to later slices: DWV fittings (wye / sanitary tee / closet
* bend), fixtures, traps, cleanouts, IPC validators, riser view.
*/
export const pipeSegmentDefinition: NodeDefinition<typeof PipeSegmentNode> = {
kind: 'pipe-segment',
schemaVersion: 1,
schema: PipeSegmentNode,
category: 'utility',
distributionRole: 'run',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
path: [
[0, 0, 0],
[3, -0.0625, 0],
],
diameter: 2,
pipeMaterial: 'pvc',
system: 'waste',
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: pipeSegmentParametrics,
geometry: buildPipeSegmentGeometry,
geometryKey: (n) => JSON.stringify([n.path, n.diameter, n.pipeMaterial, n.system]),
// Open run ends as typed ports — system 'waste'/'vent' keeps the DWV
// network invisible to duct / refrigerant tools and vice versa.
ports: (n) => {
if (n.path.length < 2) return []
const unit = (
a: readonly [number, number, number],
b: readonly [number, number, number],
): [number, number, number] => {
const d: [number, number, number] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
const len = Math.hypot(d[0], d[1], d[2])
return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len]
}
const first = n.path[0]!
const second = n.path[1]!
const last = n.path[n.path.length - 1]!
const prev = n.path[n.path.length - 2]!
return [
{
id: 'start',
position: first,
direction: unit(first, second),
diameter: n.diameter,
system: n.system,
},
{
id: 'end',
position: last,
direction: unit(last, prev),
diameter: n.diameter,
system: n.system,
},
]
},
floorplan: buildPipeSegmentFloorplan,
// 2D selection-time path-point handles — the floor-plan twin of the 3D
// `affordanceTools.selection` handles. The builder emits an
// `endpoint-handle` per path vertex; this drags the matching point.
floorplanAffordances: {
'move-path-point': createPathPointMoveAffordance('pipe-segment'),
},
// Selection-time path-point handles (drag to edit a committed run).
// Editor-only UI (reads gridSnapStep, renders DimensionPill), so it
// mounts via the editor's SelectionAffordanceManager — not `def.system`,
// which the viewer package mounts for the read-only route.
affordanceTools: {
selection: () => import('./selection'),
// Ghost-preview duplicate / move (the plumbing sibling of duct-segment's
// mover). Duplicate is pure drag-to-place: a translucent copy of the run,
// wrapped in a footprint bounding box, follows the cursor and only lands
// on the commit click — nothing is inserted into the scene before that.
move: () => import('./move-tool'),
},
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Start run' },
{ key: 'Click again', label: 'Place it (waste falls ¼″/ft)' },
{ key: 'Q', label: 'Waste / vent' },
{ key: '[ / ]', label: 'Pipe size down / up' },
{ key: 'Alt + drag', label: 'Vertical stack ↕, click to place' },
{ key: 'Shift', label: 'Free angle' },
{ key: 'Esc', label: 'Cancel start point' },
],
presentation: {
label: 'DWV Pipe',
description:
'Drain / waste / vent pipe run — waste lines fall at ¼″ per foot, vents run level or vertical.',
icon: { kind: 'url', src: '/icons/dwv-pipes.png' },
paletteSection: 'structure',
paletteOrder: 95,
},
mcp: {
description:
'A DWV (drain-waste-vent) pipe run defined as a polyline. Waste runs slope downward (slope lives in the path Y coordinates); vents run level or vertical. Sized in nominal inches.',
},
}
@@ -0,0 +1,99 @@
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import type { PipeSegmentNode } from './schema'
const WASTE_COLOR = '#57534e'
const VENT_COLOR = '#78716c'
/**
* Floor-plan representation of a DWV run, following drafting convention:
* waste lines draw SOLID at the pipe's width, vent lines draw DASHED and
* thin. Vertical stacks collapse to a circle.
*/
export function buildPipeSegmentFloorplan(
node: PipeSegmentNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
if (node.path.length < 2) return null
const points: FloorplanPoint[] = []
// Plan point k ← original path index indexMap[k] (stacks collapse to one
// plan point), so the path-point drag handle edits the right vertex.
const indexMap: number[] = []
for (let i = 0; i < node.path.length; i++) {
const [x, , z] = node.path[i]!
const prev = points[points.length - 1]
if (prev && Math.abs(prev[0] - x) < 1e-6 && Math.abs(prev[1] - z) < 1e-6) continue
points.push([x, z])
indexMap.push(i)
}
const diameterM = node.diameter * INCHES_TO_METERS
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
const isVent = node.system === 'vent'
const stroke =
showSelectedChrome && palette ? palette.selectedStroke : isVent ? VENT_COLOR : WASTE_COLOR
// Vertical stack — a single plan point: hub circle.
if (points.length < 2) {
const p = points[0] ?? [node.path[0]![0], node.path[0]![2]]
return {
kind: 'group',
children: [
{
kind: 'circle',
cx: p[0],
cy: p[1],
r: diameterM / 2 + 0.01,
fill: 'none',
stroke,
strokeWidth: 2,
vectorEffect: 'non-scaling-stroke',
opacity: 0.95,
},
],
}
}
const children: FloorplanGeometry[] = [
isVent
? {
kind: 'polyline',
points,
stroke,
strokeWidth: 1.5,
vectorEffect: 'non-scaling-stroke',
strokeDasharray: '6 4',
strokeLinecap: 'round',
strokeLinejoin: 'round',
opacity: 0.9,
}
: {
kind: 'polyline',
points,
stroke,
strokeWidth: diameterM,
strokeLinecap: 'round',
strokeLinejoin: 'round',
opacity: showSelectedChrome ? 0.95 : 0.85,
},
]
// Selection chrome: one draggable handle per path vertex (2D twin of the
// 3D selection handles). Routes to the shared `move-path-point` affordance.
if (view?.selected) {
for (let k = 0; k < points.length; k++) {
children.push({
kind: 'endpoint-handle',
point: points[k]!,
state: 'idle',
affordance: 'move-path-point',
payload: { pointIndex: indexMap[k]! },
})
}
}
return { kind: 'group', children }
}
@@ -0,0 +1,64 @@
import { Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three'
import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry'
import type { PipeSegmentNode } from './schema'
const PVC_COLOR = '#f5f5f5'
const ABS_COLOR = '#3a3a3a'
const CAST_IRON_COLOR = '#54575c'
/** Vents read slightly translucent-matte so they don't visually compete
* with the water-carrying waste runs. */
const VENT_OPACITY = 0.85
const RADIAL_SEGMENTS = 20
type PipeAppearance = {
pipeMaterial: 'pvc' | 'abs' | 'cast-iron'
system: 'waste' | 'vent'
}
function getPipeColor(node: PipeAppearance): string {
if (node.pipeMaterial === 'abs') return ABS_COLOR
if (node.pipeMaterial === 'cast-iron') return CAST_IRON_COLOR
return PVC_COLOR
}
export function createPipeMaterial(node: PipeAppearance): MeshStandardMaterial {
return new MeshStandardMaterial({
color: getPipeColor(node),
metalness: node.pipeMaterial === 'cast-iron' ? 0.5 : 0.05,
roughness: node.pipeMaterial === 'cast-iron' ? 0.6 : 0.45,
transparent: node.system === 'vent',
opacity: node.system === 'vent' ? VENT_OPACITY : 1,
})
}
/**
* Pure geometry builder for a DWV pipe run: capped cylinder sections
* between consecutive path points with sphere hubs at interior joints
* (proper wyes / sanitary tees come in the next slice). Slope lives in
* the path's Y coordinates — nothing here is slope-aware.
*/
export function buildPipeSegmentGeometry(node: PipeSegmentNode): Group {
const group = new Group()
if (node.path.length < 2) return group
const radius = (node.diameter * INCHES_TO_METERS) / 2
const material = createPipeMaterial(node)
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
for (let i = 0; i < points.length - 1; i++) {
const a = points[i] as Vector3
const b = points[i + 1] as Vector3
const mesh = buildSection(a, b, radius, material, `pipe-section-${i}`)
if (mesh) group.add(mesh)
}
// Slightly proud hubs at interior joints — reads as a coupling.
for (let i = 1; i < points.length - 1; i++) {
const hub = new Mesh(new SphereGeometry(radius * 1.12, RADIAL_SEGMENTS, 12), material)
hub.name = `pipe-hub-${i}`
hub.position.copy(points[i] as Vector3)
group.add(hub)
}
return group
}
+3
View File
@@ -0,0 +1,3 @@
export { pipeSegmentDefinition } from './definition'
export { buildPipeSegmentGeometry } from './geometry'
export { PipeSegmentNode } from './schema'
@@ -0,0 +1,302 @@
'use client'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
emitter,
type GridEvent,
PipeSegmentNode,
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 { Vector3 } from 'three'
import {
type Aabb2D,
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
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]
}
/** The pipe's radius (meters) — half the nominal diameter, used as the
* box / footprint padding and the ghost cylinder radius. */
function pipeRadiusM(pipe: PipeSegmentNode): number {
return (pipe.diameter * IN_TO_M) / 2
}
/** XZ bounds of a path padded by the pipe'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 DWV pipe runs — the plumbing
* sibling of `MoveDuctSegmentTool`. Pipes are always round, so the ghost
* is a translucent cylinder per section (no rect branch).
*
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
* inserted into the scene until the commit click. A translucent ghost of
* the run 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. The run's Y coords (slope) ride along untouched: the move
* only shifts XZ.
*
* **Move** (existing run): the real node's mesh 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 MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
const pipe = node as PipeSegmentNode
const originalPathRef = useRef<Vec3[]>(pipe.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 = pipeRadiusM(pipe)
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 (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 = PipeSegmentNode.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()
}
}, [pipe, 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 = pipeRadiusM(pipe)
const box = pathAabb(previewPath, r)
const boxY = previewPath[0]?.[1] ?? 0
return (
<group>
{segments.map((seg, i) => (
<GhostSegment a={seg.a} b={seg.b} radius={r} 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, pipe.diameter * IN_TO_M, box.maxZ - box.minZ]}
/>
</group>
)
}
/** Translucent stand-in for one pipe section — mirrors the draw tool's
* `PreviewPipe` so the ghost matches what actually lands. */
function GhostSegment({ a, b, radius }: { a: Vec3; b: Vec3; radius: number }) {
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)
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 MovePipeSegmentTool
@@ -0,0 +1,36 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { PipeSegmentNode } from './schema'
export const pipeSegmentParametrics: ParametricDescriptor<PipeSegmentNode> = {
groups: [
{
label: 'Drainage',
fields: [
{
key: 'system',
kind: 'enum',
options: ['waste', 'vent'],
display: 'segmented',
},
{
key: 'diameter',
kind: 'number',
unit: 'in',
min: 1.25,
max: 6,
step: 0.25,
},
],
},
{
label: 'Construction',
fields: [
{
key: 'pipeMaterial',
kind: 'enum',
options: ['pvc', 'abs', 'cast-iron'],
},
],
},
],
}
@@ -0,0 +1 @@
export { PipeSegmentNode } from '@pascal-app/core'
@@ -0,0 +1,362 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
analyzePortConnectivity,
type PipeSegmentNode,
type PortConnectivity,
pauseSceneHistory,
resolveConnectivityUpdates,
resumeSceneHistory,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useRef, useState } from 'react'
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import { collectScenePorts, DWV_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports'
/** Handle pip radius (meters). */
const HANDLE_RADIUS = 0.09
/** Port-snap radius for dragged run endpoints (meters, XZ). */
const PORT_SNAP_RADIUS_M = 0.4
const UP = new Vector3(0, 1, 0)
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
type Point = [number, number, number]
/**
* Selection-time editing for committed DWV pipe runs: one draggable
* handle per path point. The plumbing sibling of the duct-segment
* affordance — same portal / constrained-drag / single-undo model, snapping
* to DWV ports instead of duct ports.
*
* Handles are PORTALED into the pipe's registered scene group so they
* share its exact frame — path coords are node-local, and the level /
* building transform above the group applies to the handles for free.
*
* Drag model: by default the point is CONSTRAINED to the axis the
* segment was drawn along. Holding **Alt** releases it into free
* horizontal-plane movement (endpoints port-snap onto nearby DWV ports).
* Holding **Shift** bypasses grid snapping for a precision drag.
*/
const PipeSegmentSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const pipe = useScene((s) => {
if (selectedIds.length !== 1) return null
const node = s.nodes[selectedIds[0] as AnyNodeId]
return node?.type === 'pipe-segment' ? (node as PipeSegmentNode) : null
})
// Portal target: the pipe's registered group. Resolved with a rAF
// retry because registration happens on the renderer's mount, which
// can land a frame after selection.
const pipeId = pipe?.id ?? null
const [target, setTarget] = useState<Object3D | null>(null)
useEffect(() => {
if (!pipeId) {
setTarget(null)
return
}
let frameId = 0
const resolve = () => {
const next = sceneRegistry.nodes.get(pipeId as AnyNodeId) ?? null
setTarget((cur) => (cur === next ? cur : next))
if (!next) frameId = window.requestAnimationFrame(resolve)
}
resolve()
return () => window.cancelAnimationFrame(frameId)
}, [pipeId])
if (!pipe || !target) return null
return createPortal(<PipePointHandles pipe={pipe} target={target} />, target, undefined)
}
const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Object3D }) => {
const { camera, gl } = useThree()
const unit = useViewer((s) => s.unit)
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
// Set while a drag is live; null otherwise. Holds everything the window
// pointer handlers need so they never read stale React state.
const dragRef = useRef<{
index: number
initialPath: Point[]
current: Point
cleanup: () => void
// Connectivity snapshot taken at pointer-down: which fittings / pipes are
// mated to this run's endpoints, so they follow as the endpoint moves.
connectivity: PortConnectivity | null
} | null>(null)
const makeRay = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const ndc = new Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
const raycaster = new Raycaster()
raycaster.setFromCamera(ndc, camera)
return raycaster.ray
}
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
const hit = new Vector3()
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
}
/**
* Signed distance along `axisWorld` (unit, through `anchorWorld`) of the
* point on that line closest to the cursor ray. Null when the ray runs
* (near-)parallel to the axis and the projection is unstable.
*/
const projectOntoAxis = (
clientX: number,
clientY: number,
anchorWorld: Vector3,
axisWorld: Vector3,
): number | null => {
const ray = makeRay(clientX, clientY)
const w0 = new Vector3().subVectors(ray.origin, anchorWorld)
const b = ray.direction.dot(axisWorld)
const denom = 1 - b * b
if (Math.abs(denom) < 1e-6) return null
const d0 = ray.direction.dot(w0)
const e0 = axisWorld.dot(w0)
return (e0 - b * d0) / denom
}
/** World-space position of a local path point. */
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
/** Convert a world-space hit back into the pipe group's local frame. */
const toLocal = (world: Vector3): Point => {
const local = target.worldToLocal(world.clone())
return [local.x, local.y, local.z]
}
// Follow-updates for fittings / pipes mated to this run's endpoints, given
// the run's live path. Endpoints whose position didn't change resolve to a
// zero delta, so only the dragged endpoint's partner actually moves.
const connectivityUpdatesForPath = (
connectivity: PortConnectivity | null,
path: Point[],
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
if (!connectivity) return []
const preview = { ...(pipe as Record<string, unknown>), path } as AnyNode
return resolveConnectivityUpdates(connectivity, preview).filter(
(u) => useScene.getState().nodes[u.id],
)
}
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const initialPath = pipe.path.map((p) => [...p] as Point)
const startPoint = initialPath[index]!
const connectivity = analyzePortConnectivity(pipe as AnyNode, useScene.getState().nodes)
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
document.body.style.cursor = 'grabbing'
setDraggingIndex(index)
const isEndpoint = index === 0 || index === initialPath.length - 1
// Axis the segment was drawn along, at this point: from the
// neighbouring path point toward the dragged one. The default drag
// is constrained to this line.
const neighbor = initialPath[index === 0 ? 1 : index - 1]!
const axisLocal = new Vector3(
startPoint[0] - neighbor[0],
startPoint[1] - neighbor[1],
startPoint[2] - neighbor[2],
)
if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0)
axisLocal.normalize()
// World-space anchor + axis, derived once — the constraint line is
// fixed for the whole drag regardless of where the point currently is.
const anchorWorldStart = toWorld(startPoint)
const axisWorld = toWorld([
startPoint[0] + axisLocal.x,
startPoint[1] + axisLocal.y,
startPoint[2] + axisLocal.z,
])
.sub(anchorWorldStart)
.normalize()
const onMove = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag) return
const current = drag.current
// Shift = precision: bypass grid snapping for a perfectly smooth
// drag (snap() is a no-op at step 0).
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
let next: Point | null = null
if (event.altKey) {
// Alt = freedom: slide on the horizontal plane at the point's
// height. Endpoints can port-snap here to mate onto a fitting.
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current))
const hit = intersect(event.clientX, event.clientY, plane)
if (hit) {
const local = toLocal(hit)
next = [snap(local[0], step), current[1], snap(local[2], step)]
if (isEndpoint) {
const port = findNearestPortXZ(
[local[0], current[1], local[2]],
collectScenePorts({ excludeNodeId: pipe.id, systems: DWV_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (port) next = [port.position[0], port.position[1], port.position[2]]
}
}
} else {
// Default: constrained to the axis the segment was drawn along —
// slide the point closer / further along its own line.
const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld)
if (t !== null) {
const dist = snap(t, step)
next = [
startPoint[0] + axisLocal.x * dist,
Math.max(0, startPoint[1] + axisLocal.y * dist),
startPoint[2] + axisLocal.z * dist,
]
}
}
if (!next) return
if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return
drag.current = next
const path = pipe.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
// Drag the run + any fittings mated to the moved endpoint as one batch.
useScene
.getState()
.updateNodes([
{ id: pipe.id as AnyNodeId, data: { path } },
...connectivityUpdatesForPath(drag.connectivity, path),
])
}
const onUp = () => {
const drag = dragRef.current
if (!drag) return
drag.cleanup()
dragRef.current = null
setDraggingIndex(null)
// Single-undo dance: revert (still paused), resume, re-apply the
// final path — plus any connected fitting moves — as one tracked batch.
const finalPath = drag.initialPath.map((p, i) =>
i === drag.index ? drag.current : p,
) as Point[]
const finalUpdates = connectivityUpdatesForPath(drag.connectivity, finalPath)
// Revert the run AND the followers to their pre-drag state while paused
// so history captures a clean before→after delta.
const revertUpdates = (drag.connectivity?.connections ?? []).flatMap((conn) =>
conn.kind === 'rigid-node'
? [{ id: conn.nodeId, data: { position: conn.startPosition } as Partial<AnyNode> }]
: [{ id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> }],
)
useScene
.getState()
.updateNodes([
{ id: pipe.id as AnyNodeId, data: { path: drag.initialPath } },
...revertUpdates.filter((u) => useScene.getState().nodes[u.id]),
])
resumeSceneHistory(useScene)
const moved = finalPath[drag.index]!.some(
(v, axis) => v !== drag.initialPath[drag.index]![axis],
)
if (moved) {
useScene
.getState()
.updateNodes([{ id: pipe.id as AnyNodeId, data: { path: finalPath } }, ...finalUpdates])
}
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
useViewer.getState().setInputDragging(false)
document.body.style.cursor = ''
}
dragRef.current = { index, initialPath, current: startPoint, cleanup, connectivity }
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
return (
<group>
{pipe.path.map((p, i) => {
const active = draggingIndex === i
const hovered = hoverIndex === i
return (
<mesh
key={`pipe-handle-${i}`}
layers={EDITOR_LAYER}
onPointerDown={onHandleDown(i)}
onPointerEnter={(e) => {
e.stopPropagation()
setHoverIndex(i)
if (draggingIndex === null) document.body.style.cursor = 'grab'
}}
onPointerLeave={() => {
setHoverIndex((prev) => (prev === i ? null : prev))
if (draggingIndex === null) document.body.style.cursor = ''
}}
position={p as Point}
>
<sphereGeometry args={[HANDLE_RADIUS, 16, 12]} />
<meshBasicMaterial
color={active || hovered ? '#7dd3fc' : '#38bdf8'}
depthTest={false}
opacity={active ? 1 : 0.85}
transparent
/>
</mesh>
)
})}
{draggingIndex !== null &&
pipe.path[draggingIndex] &&
(() => {
// Same pill as the draw tool: signed per-axis deltas from the
// drag-start position, dominant axis emphasised.
const point = pipe.path[draggingIndex]!
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
const axes = ['x', 'y', 'z'] as const
const primary = axes.reduce((best, axis, i) =>
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
)
return (
<Html
center
position={[point[0], point[1] + 0.35, point[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<DimensionPill
parts={axes.map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: deltas[i]!,
signed: true,
}))}
primary={primary}
unit={unit}
/>
</Html>
)
})()}
</group>
)
}
export default PipeSegmentSelectionAffordance
+691
View File
@@ -0,0 +1,691 @@
'use client'
import { type AnyNode, emitter, type GridEvent, PipeSegmentNode, useScene } from '@pascal-app/core'
import {
CursorSphere,
DimensionPill,
EDITOR_LAYER,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useRef, useState } from 'react'
import { Vector3 } from 'three'
import {
planPipeBranchTap,
planPipeCrossAtRunBody,
planPipeElbowAtPort,
} from '../shared/auto-fitting'
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import {
collectScenePorts,
DWV_PORT_SYSTEMS,
findNearestPortXZ,
findNearestRunBodyXZ,
findRunBodyCrossingXZ,
type RunBodyHit,
type ScenePort,
} from '../shared/ports'
import { pipeSegmentDefinition } from './definition'
/**
* Slope-aware two-click placement tool for DWV pipe runs — the plumbing
* sibling of the duct tool.
*
* - **First click** anchors the run start (port snap joins onto an
* existing pipe end — DWV ports only, duct/refrigerant collars are
* invisible to it). The start inherits the snapped port's height.
* - **Second click** commits a two-point pipe and re-arms.
* - **Slope**: runs draw LEVEL by default. **S** toggles slope mode,
* where waste runs fall at ¼" per foot (1:48) of horizontal
* distance, the IPC default for residential drains. When sloped, a
* freely placed start is RAISED so the run falls onto the grid plane
* (nothing clips below); a port/body-snapped start keeps its fixed
* height and the end drops instead. Vent runs always stay level.
* The pill shows the live drop in the Y part.
* - **Q** toggles waste ↔ vent. **[ / ]** steps the pipe size through
* nominal DWV diameters.
* - Hold **Alt** → vertical mode (stacks): XZ locks to the start,
* mouse vertical motion drives Y, click commits the riser.
* - 45° XZ angle lock from the start; **Shift** frees the angle and
* grid snap.
* - Esc clears an anchored start point.
*/
const PREVIEW_OPACITY = 0.55
/** Nominal residential DWV sizes (inches). */
const PIPE_DIAMETERS_IN = [1.25, 1.5, 2, 3, 4, 6] as const
/** IPC default drain slope — ¼" per foot (1:48). */
const DRAIN_SLOPE = 1 / 48
/** Snap radius (meters, XZ) for joining onto an existing pipe end. */
const PORT_SNAP_RADIUS_M = 0.5
/** Snap radius (meters, XZ) for tapping the side of an existing run. */
const BODY_SNAP_RADIUS_M = 0.3
const ANGLE_STEP_RAD = Math.PI / 4
const ALT_PIXELS_PER_METER = 100
const ALT_Y_MIN_M = -3
const ALT_Y_MAX_M = 10
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
function dist2(a: readonly [number, number, number], b: readonly [number, number, number]): number {
const dx = a[0] - b[0]
const dy = a[1] - b[1]
const dz = a[2] - b[2]
return dx * dx + dy * dy + dz * dz
}
function findNearbyPort(point: [number, number, number]): ScenePort | null {
return findNearestPortXZ(
point,
collectScenePorts({ systems: DWV_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
}
function projectToAngleLock(
from: [number, number, number],
raw: [number, number, number],
): [number, number, number] {
const dx = raw[0] - from[0]
const dz = raw[2] - from[2]
const len = Math.hypot(dx, dz)
if (len < 1e-4) return [from[0], from[1], from[2]]
const theta = Math.atan2(dz, dx)
const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD
const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped)
const d = Math.max(0, proj)
return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d]
}
const PipeSegmentTool = () => {
const activeLevelId = useViewer((s) => s.selection.levelId)
const unit = useViewer((s) => s.unit)
const [system, setSystem] = useState<'waste' | 'vent'>('waste')
const [sloped, setSloped] = useState(false)
const [diameter, setDiameter] = useState<number>(
(pipeSegmentDefinition.defaults() as { diameter: number }).diameter,
)
const [draftStart, setDraftStart] = useState<[number, number, number] | null>(null)
const [cursorPos, setCursorPos] = useState<[number, number, number] | null>(null)
const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null)
const [altActive, setAltActive] = useState(false)
const startRef = useRef(draftStart)
startRef.current = draftStart
const systemRef = useRef(system)
systemRef.current = system
const slopedRef = useRef(sloped)
slopedRef.current = sloped
const diameterRef = useRef(diameter)
diameterRef.current = diameter
// Port / run-body the anchored start snapped onto — read at commit so
// joints mint bends (corner) or wyes / sanitary tees (body tap).
const startPortRef = useRef<ScenePort | null>(null)
const startBodyRef = useRef<RunBodyHit | null>(null)
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
const lastClientYRef = useRef<number | null>(null)
useEffect(() => {
if (!activeLevelId) return
/** Corner-bend gate: joints onto another PIPE run's open end. */
const bendPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
if (!port) return null
const owner = useScene.getState().nodes[port.nodeId]
if (owner?.type !== 'pipe-segment') return null
const plan = planPipeElbowAtPort(port, awayDir, diameterRef.current, owner.pipeMaterial)
if (!plan) return null
// Trim the run's snapped endpoint back to the bend's inlet collar.
const path = owner.path.map((p) => [...p] as [number, number, number])
const index = port.id === 'start' ? 0 : path.length - 1
const neighbor = path[index === 0 ? 1 : index - 1]!
const remaining = Math.hypot(
plan.trimmedPortPoint[0] - neighbor[0],
plan.trimmedPortPoint[1] - neighbor[1],
plan.trimmedPortPoint[2] - neighbor[2],
)
const original = path[index]!
const originalLen = Math.hypot(
original[0] - neighbor[0],
original[1] - neighbor[1],
original[2] - neighbor[2],
)
if (remaining < 0.05 || remaining >= originalLen) return null
path[index] = plan.trimmedPortPoint
return { ...plan, trim: { id: port.nodeId, data: { path } as Partial<AnyNode> } }
}
const commitSegment = (
rawStart: [number, number, number],
end: [number, number, number],
endPort: ScenePort | null = null,
endBody: RunBodyHit | null = null,
) => {
// Free waste start: lift it by the drain fall so the run lands ON
// the grid plane instead of sinking below it. Snapped starts are
// height-fixed (fixture drain, run end), so their end drops instead.
let start = rawStart
if (
slopedRef.current &&
systemRef.current === 'waste' &&
!startPortRef.current &&
!startBodyRef.current &&
!endPort
) {
const run = Math.hypot(end[0] - rawStart[0], end[2] - rawStart[2])
start = [rawStart[0], rawStart[1] + run * DRAIN_SLOPE, rawStart[2]]
}
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
if (length < 1e-4) return
const dir: [number, number, number] = [
(end[0] - start[0]) / length,
(end[1] - start[1]) / length,
(end[2] - start[2]) / length,
]
const startPlan = bendPlanFor(startPortRef.current, dir)
const endPlan = bendPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
// Body tap (wye / sanitary tee) when the start landed on a run's side.
const body = startPlan ? null : startBodyRef.current
const bodyOwner = body ? useScene.getState().nodes[body.nodeId] : null
const tapPlan =
body && bodyOwner?.type === 'pipe-segment'
? planPipeBranchTap(bodyOwner, body, dir, diameterRef.current)
: null
// End body tap: the END landed on a run's side — split that trunk and
// the new run ends at the branch collar, the branch leaving back
// toward the drawn run (along -dir, since dir points start→end).
const endTapBody = endPlan ? null : endBody
const endTapOwner = endTapBody ? useScene.getState().nodes[endTapBody.nodeId] : null
const endTapPlan =
endTapBody && endTapOwner?.type === 'pipe-segment'
? planPipeBranchTap(
endTapOwner,
endTapBody,
[-dir[0], -dir[1], -dir[2]],
diameterRef.current,
)
: null
// Both ends tapping the SAME run would split one polyline twice in a
// single change — drop the end tap and let the end butt-join instead.
const endTap = endTapPlan && endTapBody?.nodeId === body?.nodeId ? null : endTapPlan
let pipeStart = startPlan?.collarPoint ?? tapPlan?.branchCollar ?? start
let pipeEnd = endPlan?.collarPoint ?? endTap?.branchCollar ?? end
const remaining = Math.hypot(
pipeEnd[0] - pipeStart[0],
pipeEnd[1] - pipeStart[1],
pipeEnd[2] - pipeStart[2],
)
let bends = [startPlan, endPlan].filter((p) => p !== null)
let tap = tapPlan
let endTapFinal = endTap
// Cross tap: the drawn run passes straight THROUGH a run's body
// (interior crossing, not an end touch). Split that run and the drawn
// pipe into two halves meeting the cross's opposed branch collars.
// Skip a run already tapped by a start / end tee so one polyline isn't
// split twice in a single change.
const crossHit = findRunBodyCrossingXZ(start, end, BODY_SNAP_RADIUS_M, {
kinds: ['pipe-segment'],
})
const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null
const crossTappedElsewhere =
crossHit?.nodeId === body?.nodeId || crossHit?.nodeId === endTapBody?.nodeId
let cross =
crossHit && !crossTappedElsewhere && crossOwner?.type === 'pipe-segment'
? planPipeCrossAtRunBody(crossOwner, crossHit, dir, diameterRef.current)
: null
if (remaining <= 0.05) {
bends = []
tap = null
endTapFinal = null
cross = null
pipeStart = start
pipeEnd = end
}
const makePipe = (from: [number, number, number], to: [number, number, number]) =>
PipeSegmentNode.parse({
...pipeSegmentDefinition.defaults(),
name: systemRef.current === 'vent' ? 'Vent' : 'Drain',
path: [from, to],
diameter: diameterRef.current,
system: systemRef.current,
})
// A cross splits the drawn run into two halves that meet its opposed
// branch collars; otherwise it's one pipe end-to-end. Degenerate
// halves (the crossing too near an end) are dropped.
const pipes = cross
? [
dist2(pipeStart, cross.branchCollarNear) > 0.05 * 0.05
? makePipe(pipeStart, cross.branchCollarNear)
: null,
dist2(cross.branchCollarFar, pipeEnd) > 0.05 * 0.05
? makePipe(cross.branchCollarFar, pipeEnd)
: null,
].filter((p) => p !== null)
: [makePipe(pipeStart, pipeEnd)]
useScene.getState().applyNodeChanges({
create: [
...bends.map((plan) => ({ node: plan.fitting, parentId: activeLevelId })),
...(tap
? [
{ node: tap.fitting, parentId: activeLevelId },
{ node: tap.runTail, parentId: activeLevelId },
]
: []),
...(endTapFinal
? [
{ node: endTapFinal.fitting, parentId: activeLevelId },
{ node: endTapFinal.runTail, parentId: activeLevelId },
]
: []),
...(cross
? [
{ node: cross.fitting, parentId: activeLevelId },
{ node: cross.runTail, parentId: activeLevelId },
]
: []),
...pipes.map((node) => ({ node, parentId: activeLevelId })),
],
update: [
...bends.map((plan) => plan.trim),
...(tap ? [tap.runUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
...(endTapFinal
? [endTapFinal.runUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }]
: []),
...(cross ? [cross.runUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
],
})
triggerSFX('sfx:item-place')
setDraftStart(null)
setSnapTarget(null)
startPortRef.current = null
startBodyRef.current = null
altAnchorRef.current = null
setAltActive(false)
}
/** Apply the drain fall to an XZ-resolved end point. Only snapped
* starts (fixture drain, run end/body) drop the end — they're
* height-fixed. A free start keeps the end on the grid plane and
* gets LIFTED at commit instead, so the run never sinks below it. */
const applySlope = (
start: [number, number, number],
end: [number, number, number],
): [number, number, number] => {
if (!slopedRef.current || systemRef.current !== 'waste') return end
if (!startPortRef.current && !startBodyRef.current) return end
const run = Math.hypot(end[0] - start[0], end[2] - start[2])
return [end[0], start[1] - run * DRAIN_SLOPE, end[2]]
}
const resolveSnappedPoint = (
event: GridEvent,
): {
point: [number, number, number]
snapped: [number, number, number] | null
port: ScenePort | null
body: RunBodyHit | null
} => {
const start = startRef.current
if (!start) {
const raw: [number, number, number] = [event.localPosition[0], 0, event.localPosition[2]]
const step = useEditor.getState().gridSnapStep
const shift = event.nativeEvent?.shiftKey === true
if (event.nativeEvent?.altKey !== true) {
const port = findNearbyPort(raw)
if (port) {
const p: [number, number, number] = [
port.position[0],
port.position[1],
port.position[2],
]
return { point: p, snapped: p, port, body: null }
}
// No open end nearby — try the side of a run (wye / santee tap).
// Probe with a grid-snapped cursor so the tap steps along the run
// like every other placement; Shift frees it to ride smoothly.
const probe: [number, number, number] = shift
? raw
: [snap(raw[0], step), 0, snap(raw[2], step)]
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M, {
kinds: ['pipe-segment'],
})
if (body) return { point: body.point, snapped: body.point, port: null, body }
}
return {
point: [snap(raw[0], step), 0, snap(raw[2], step)],
snapped: null,
port: null,
body: null,
}
}
const rawXZ: [number, number, number] = [
event.localPosition[0],
start[1],
event.localPosition[2],
]
const shift = event.nativeEvent?.shiftKey === true
const angled = shift ? rawXZ : projectToAngleLock(start, rawXZ)
const step = useEditor.getState().gridSnapStep
if (event.nativeEvent?.altKey !== true && !shift) {
const port = findNearbyPort(rawXZ)
if (port) {
const p: [number, number, number] = [port.position[0], port.position[1], port.position[2]]
return { point: p, snapped: p, port, body: null }
}
// No open end nearby — landing on the side of a run taps a wye /
// sanitary tee there (mirror of the first-point tap). Probe with a
// grid-snapped cursor so the tap steps along the run; checked against
// the cursor, not the 45° projection, so a slightly-off trunk captures.
const probe: [number, number, number] = [
snap(rawXZ[0], step),
rawXZ[1],
snap(rawXZ[2], step),
]
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M, { kinds: ['pipe-segment'] })
if (body) return { point: body.point, snapped: body.point, port: null, body }
}
let end: [number, number, number]
if (shift) {
end = [snap(angled[0], step), angled[1], snap(angled[2], step)]
} else {
// Snap the run LENGTH along the locked ray, not each axis — an
// off-grid start (port / body snap) plus per-axis rounding pulls
// the end off the 45° ray, bending the run as the cursor moves.
const dx = angled[0] - start[0]
const dz = angled[2] - start[2]
const len = Math.hypot(dx, dz)
if (len < 1e-6) {
end = angled
} else {
const s = snap(len, step) / len
end = [start[0] + dx * s, angled[1], start[2] + dz * s]
}
}
return { point: applySlope(start, end), snapped: null, port: null, body: null }
}
const resolveAltVerticalPoint = (clientY: number): [number, number, number] | null => {
const anchor = altAnchorRef.current
const start = startRef.current
if (!anchor || !start) return null
const step = useEditor.getState().gridSnapStep
const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER
const snappedDy = snap(dy, step)
const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy))
return [start[0], y, start[2]]
}
// Resolve the cursor point (port / body / grid / angle snap) then layer
// Figma-style alignment so a run lines up with other runs, fittings, and
// items as it's drawn. Free point (first vertex / Shift) snaps; an
// angle-locked continuation shows the guide passively. Port / body snap or
// Alt bypasses alignment.
const resolveAlignedPoint = (event: GridEvent) => {
const r = resolveSnappedPoint(event)
const hasStart = !!startRef.current
const shift = event.nativeEvent?.shiftKey === true
const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, {
applySnap: !hasStart || shift,
bypass: alt || r.snapped !== null,
})
return { ...r, point }
}
const onMove = (event: GridEvent) => {
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
if (typeof clientY === 'number') lastClientYRef.current = clientY
if (altAnchorRef.current && typeof clientY === 'number') {
const point = resolveAltVerticalPoint(clientY)
if (point) {
clearDrawAlignment()
setCursorPos(point)
setSnapTarget(null)
return
}
}
const { point, snapped } = resolveAlignedPoint(event)
setCursorPos(point)
setSnapTarget(snapped)
}
const onClick = (event: GridEvent) => {
const start = startRef.current
if (altAnchorRef.current && start) {
const clientY =
(event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current
if (typeof clientY === 'number') {
const point = resolveAltVerticalPoint(clientY)
if (point && Math.abs(point[1] - start[1]) >= 1e-4) commitSegment(start, point)
}
return
}
const { point, port, body } = resolveAlignedPoint(event)
if (!start) {
// First click: anchor the start, remembering the port / run body
// it snapped to so the commit can mint a bend / wye.
triggerSFX('sfx:grid-snap')
startPortRef.current = port
startBodyRef.current = port ? null : body
setDraftStart(point)
return
}
commitSegment(start, point, port, port ? null : body)
}
const enterAltMode = () => {
const start = startRef.current
if (!start || lastClientYRef.current === null) return
if (altAnchorRef.current) return
altAnchorRef.current = { clientY: lastClientYRef.current, baseY: start[1] }
setAltActive(true)
}
const exitAltMode = () => {
if (!altAnchorRef.current) return
altAnchorRef.current = null
setAltActive(false)
}
const stepDiameter = (step: 1 | -1) => {
const sizes = PIPE_DIAMETERS_IN
const current = diameterRef.current
let nearest = 0
for (let i = 1; i < sizes.length; i++) {
if (Math.abs(sizes[i]! - current) < Math.abs(sizes[nearest]! - current)) nearest = i
}
const next = sizes[Math.min(sizes.length - 1, Math.max(0, nearest + step))]!
if (next === current) return
setDiameter(next)
triggerSFX('sfx:grid-snap')
}
const onKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (e.key === 'Alt') {
e.preventDefault()
enterAltMode()
} else if (e.key === '[') {
e.preventDefault()
stepDiameter(-1)
} else if (e.key === ']') {
e.preventDefault()
stepDiameter(1)
} else if (e.key === 'q' || e.key === 'Q') {
e.preventDefault()
setSystem((s) => (s === 'waste' ? 'vent' : 'waste'))
triggerSFX('sfx:grid-snap')
} else if (e.key === 's' || e.key === 'S') {
e.preventDefault()
setSloped((s) => !s)
triggerSFX('sfx:grid-snap')
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Alt') {
e.preventDefault()
exitAltMode()
}
}
const onCancel = () => {
clearDrawAlignment()
if (!startRef.current) return
markToolCancelConsumed()
setDraftStart(null)
setCursorPos(null)
setSnapTarget(null)
startPortRef.current = null
startBodyRef.current = null
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', onClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', onClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
altAnchorRef.current = null
clearDrawAlignment()
}
}, [activeLevelId])
if (!activeLevelId) return null
// Free waste start lifts at commit so the run falls ONTO the grid —
// mirror that here so the preview line / pill match the placed pipe.
// A snapped end (snapTarget set) keeps the start where it is.
const displayStart =
draftStart &&
cursorPos &&
sloped &&
system === 'waste' &&
!startPortRef.current &&
!startBodyRef.current &&
!snapTarget &&
!altActive
? ([
draftStart[0],
draftStart[1] +
Math.hypot(cursorPos[0] - draftStart[0], cursorPos[2] - draftStart[2]) * DRAIN_SLOPE,
draftStart[2],
] as [number, number, number])
: draftStart
const pillParts = cursorPos
? [
...(['x', 'y', 'z'] as const).map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: displayStart ? cursorPos[i]! - displayStart[i]! : cursorPos[i]!,
signed: !!displayStart,
})),
{ key: 'diameter', prefix: 'Ø', value: diameter * 0.0254, signed: false },
]
: null
const pillPrimary = draftStart && cursorPos ? (altActive ? 'y' : 'y') : undefined
return (
<LevelOffsetGroup>
{/* Cursor marker — the same ground ring + vertical line + tool-icon
badge the duct draw tool shows in 3D (icon resolved from the active
`pipe-segment` structure-tools entry). In 2D the floorplan overlay
draws this for every tool; in 3D each tool renders its own. The
dimension pill rides just above the cursor. */}
{cursorPos && (
<>
<CursorSphere position={cursorPos} />
{pillParts && (
<group position={cursorPos}>
<Html
center
position={[0, 0.3, 0]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<div className="flex flex-col items-center gap-1">
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
<div className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-3 py-0.5 text-[10px] text-muted-foreground shadow-sm backdrop-blur">
{system === 'waste'
? sloped
? 'Waste · ¼″/ft fall'
: 'Waste · level'
: 'Vent · level'}{' '}
· Q system{system === 'waste' ? ' · S slope' : ''}
</div>
</div>
</Html>
</group>
)}
</>
)}
{snapTarget && (
<mesh layers={EDITOR_LAYER} position={snapTarget}>
<sphereGeometry args={[0.1, 24, 16]} />
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
</mesh>
)}
{displayStart && (
<mesh layers={EDITOR_LAYER} position={displayStart}>
<sphereGeometry args={[0.05, 16, 12]} />
<meshBasicMaterial color="#818cf8" depthTest={false} />
</mesh>
)}
{displayStart && cursorPos && (
<PreviewPipe a={displayStart} b={cursorPos} diameterIn={diameter} />
)}
</LevelOffsetGroup>
)
}
function PreviewPipe({
a,
b,
diameterIn,
}: {
a: [number, number, number]
b: [number, number, number]
diameterIn: number
}) {
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)
const radius = (diameterIn * 0.0254) / 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, 20, 1, false]} />
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={PREVIEW_OPACITY} transparent />
</mesh>
)
}
export default PipeSegmentTool