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
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test'
import { planLinesetConnect } from './connect'
import type { LinesetNode } from './schema'
type Point = [number, number, number]
/** Minimal stand-in — the planner only reads `id` and `path`. */
function line(id: string, path: Point[]): LinesetNode {
return { id, path } as unknown as LinesetNode
}
describe('planLinesetConnect', () => {
test('no shared endpoint → create', () => {
const plan = planLinesetConnect(
[
line('a', [
[0, 0, 0],
[1, 0, 0],
]),
],
[5, 0, 0],
[6, 0, 0],
)
expect(plan).toEqual({
kind: 'create',
path: [
[5, 0, 0],
[6, 0, 0],
],
})
})
test('new start meets run end → extend, old end becomes interior', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const plan = planLinesetConnect([a], [1, 0, 0], [1, 0, 2])
expect(plan).toEqual({
kind: 'extend',
id: 'a',
path: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 2],
],
})
})
test('new start meets run start → extend, run reversed so join is interior', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const plan = planLinesetConnect([a], [0, 0, 0], [0, 0, 2])
expect(plan).toEqual({
kind: 'extend',
id: 'a',
path: [
[1, 0, 0],
[0, 0, 0],
[0, 0, 2],
],
})
})
test('new end meets a run → extend, new segment leads', () => {
const a = line('a', [
[1, 0, 0],
[2, 0, 0],
])
const plan = planLinesetConnect([a], [1, 0, 3], [1, 0, 0])
expect(plan).toEqual({
kind: 'extend',
id: 'a',
path: [
[1, 0, 3],
[1, 0, 0],
[2, 0, 0],
],
})
})
test('both ends meet distinct runs → bridge, second run absorbed', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const b = line('b', [
[1, 0, 5],
[2, 0, 5],
])
const plan = planLinesetConnect([a, b], [1, 0, 0], [1, 0, 5])
expect(plan).toEqual({
kind: 'bridge',
id: 'a',
deleteId: 'b',
path: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 5],
[2, 0, 5],
],
})
})
test('both ends meet the SAME run → not a bridge (extends at start)', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const plan = planLinesetConnect([a], [0, 0, 0], [1, 0, 0])
expect(plan.kind).toBe('extend')
})
test('float drift within tolerance still coincides', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const plan = planLinesetConnect([a], [1.0000001, 0, 0], [1, 0, 2])
expect(plan.kind).toBe('extend')
})
})
+98
View File
@@ -0,0 +1,98 @@
import type { LinesetNode } from './schema'
type Point = [number, number, number]
type LinesetId = LinesetNode['id']
/** Coincidence tolerance (meters) for folding endpoints into one run. The
* draw tool snaps onto an existing run's endpoint exactly, so this only
* needs to absorb float drift, not user aim. */
const COINCIDENT_EPS_M = 1e-3
function samePoint(a: Point, b: Point): boolean {
return (
Math.abs(a[0] - b[0]) < COINCIDENT_EPS_M &&
Math.abs(a[1] - b[1]) < COINCIDENT_EPS_M &&
Math.abs(a[2] - b[2]) < COINCIDENT_EPS_M
)
}
/** Which terminal of `line` coincides with `p`, if either. */
function matchEnd(line: LinesetNode, p: Point): 'start' | 'end' | null {
const path = line.path as Point[]
if (samePoint(path[0]!, p)) return 'start'
if (samePoint(path[path.length - 1]!, p)) return 'end'
return null
}
/** First lineset whose start or end coincides with `p`. */
function findConnection(
existing: LinesetNode[],
p: Point,
): { line: LinesetNode; side: 'start' | 'end' } | null {
for (const line of existing) {
if (line.path.length < 2) continue
const side = matchEnd(line, p)
if (side) return { line, side }
}
return null
}
/** Path re-ordered so the connecting terminal is its LAST point. */
function endLast(path: Point[], side: 'start' | 'end'): Point[] {
return side === 'end' ? path : [...path].reverse()
}
/** Path re-ordered so the connecting terminal is its FIRST point. */
function startFirst(path: Point[], side: 'start' | 'end'): Point[] {
return side === 'start' ? path : [...path].reverse()
}
/**
* Outcome of committing a new `start`→`end` segment against the existing
* lineset runs on the same level:
* - `create` — no shared endpoint; place a fresh standalone run.
* - `extend` — one end lands on run `id`; grow that run's path so the old
* terminal becomes an interior point (the geometry miters it).
* - `bridge` — both ends land on two *different* runs; weld them plus the
* new segment into one path on `id` and delete the absorbed `deleteId`.
*/
export type LinesetConnectPlan =
| { kind: 'create'; path: Point[] }
| { kind: 'extend'; id: LinesetId; path: Point[] }
| { kind: 'bridge'; id: LinesetId; path: Point[]; deleteId: LinesetId }
/**
* Decide how a freshly drawn `start`→`end` segment folds into existing
* lineset runs that share an endpoint coordinate. Pure: returns a plan, the
* caller mutates the scene. Coords are level-local, so `existing` must be
* pre-filtered to the segment's level.
*/
export function planLinesetConnect(
existing: LinesetNode[],
start: Point,
end: Point,
): LinesetConnectPlan {
const atStart = findConnection(existing, start)
const atEnd = findConnection(existing, end)
// Both ends meet distinct runs → weld the three into one path.
if (atStart && atEnd && atStart.line.id !== atEnd.line.id) {
const left = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
const right = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
return {
kind: 'bridge',
id: atStart.line.id,
path: [...left, ...right],
deleteId: atEnd.line.id,
}
}
if (atStart) {
const base = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
return { kind: 'extend', id: atStart.line.id, path: [...base, end] }
}
if (atEnd) {
const base = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
return { kind: 'extend', id: atEnd.line.id, path: [start, ...base] }
}
return { kind: 'create', path: [start, end] }
}
+132
View File
@@ -0,0 +1,132 @@
import type { NodeDefinition } from '@pascal-app/core'
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
import { buildLinesetFloorplan } from './floorplan'
import { buildLinesetGeometry } from './geometry'
import { linesetParametrics } from './parametrics'
import { LinesetNode } from './schema'
/**
* Refrigerant lineset — the copper suction + liquid pair joining a split
* system's outdoor condenser to its indoor coil. The refrigerant-side
* sibling of `duct-segment`: same polyline model and draw tool, but it
* snaps onto refrigerant service ports instead of duct collars.
*
* Composition: `def.geometry` only, plus a selection-time path-handle
* system shared in spirit with the duct segment. The framework's
* `<ParametricNodeRenderer>` mounts an empty group; `<GeometrySystem>`
* fills it via `buildLinesetGeometry` on dirty.
*/
export const linesetDefinition: NodeDefinition<typeof LinesetNode> = {
kind: 'lineset',
schemaVersion: 1,
schema: LinesetNode,
category: 'utility',
distributionRole: 'run',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
path: [
[0, 0, 0],
[2, 0, 0],
],
suctionDiameter: 0.875,
liquidDiameter: 0.375,
insulated: true,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: linesetParametrics,
geometry: buildLinesetGeometry,
geometryKey: (n) => JSON.stringify([n.path, n.suctionDiameter, n.liquidDiameter, n.insulated]),
// Open run ends as typed refrigerant ports — directions point outward
// along the path tangent so they mate flush onto a service valve. Path
// coords are already level-local, so no transform is needed.
ports: (n) => {
if (n.path.length < 2) return []
const diameter = n.suctionDiameter
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,
system: 'refrigerant',
},
{
id: 'end',
position: last,
direction: unit(last, prev),
diameter,
system: 'refrigerant',
},
]
},
floorplan: buildLinesetFloorplan,
// 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('lineset'),
},
// 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 refrigerant-loop 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 lineset' },
{ key: 'Click again', label: 'Place it (locked to 45°)' },
{ key: 'Shift', label: 'Free angle' },
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
{ key: 'Esc', label: 'Cancel start point' },
],
presentation: {
label: 'Lineset',
description:
'Refrigerant lineset — copper suction + liquid pair joining a condenser to the indoor coil.',
icon: { kind: 'url', src: '/icons/lineset.png' },
paletteSection: 'structure',
paletteOrder: 93,
},
mcp: {
description:
'A refrigerant lineset defined as a polyline: an insulated suction line plus a bare liquid line, joining an HVAC condenser to its indoor coil. Snaps onto refrigerant service ports.',
},
}
+89
View File
@@ -0,0 +1,89 @@
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import type { LinesetNode } from './schema'
const COPPER_LINE = '#b06b3f'
const BODY_COLOR = '#9ca3af'
/**
* Floor-plan representation of a lineset: the path drawn at the suction
* jacket's real width with a dashed copper centerline. Vertical risers
* collapse to a point in plan; consecutive duplicate plan points are
* dropped so they don't render zero-length artifacts.
*/
export function buildLinesetFloorplan(
node: LinesetNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
if (node.path.length < 2) return null
const points: FloorplanPoint[] = []
// Plan point k ← original path index indexMap[k] (risers 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 widthM = Math.max(node.suctionDiameter, node.liquidDiameter) * INCHES_TO_METERS
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
if (points.length < 2) {
const p = points[0] ?? [node.path[0]![0], node.path[0]![2]]
return {
kind: 'circle',
cx: p[0],
cy: p[1],
r: widthM,
fill: BODY_COLOR,
stroke: showSelectedChrome && palette ? palette.selectedStroke : COPPER_LINE,
strokeWidth: 0.02,
opacity: 0.9,
}
}
const children: FloorplanGeometry[] = [
{
kind: 'polyline',
points,
stroke: showSelectedChrome && palette ? palette.selectedStroke : BODY_COLOR,
strokeWidth: widthM * 2,
strokeLinecap: 'round',
strokeLinejoin: 'round',
opacity: showSelectedChrome ? 0.95 : 0.8,
},
{
kind: 'polyline',
points,
stroke: COPPER_LINE,
strokeWidth: 1.5,
vectorEffect: 'non-scaling-stroke',
strokeDasharray: '4 3',
strokeLinecap: 'round',
strokeLinejoin: 'round',
opacity: 0.9,
},
]
// 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 }
}
+105
View File
@@ -0,0 +1,105 @@
import { CylinderGeometry, Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import type { LinesetNode } from './schema'
const RADIAL_SEGMENTS = 16
const COPPER_COLOR = '#b06b3f'
// Light foam sleeve. Real Armaflex is black, but a light jacket reads
// cleaner against the scene and matches the white pipe materials.
const INSULATION_COLOR = '#e8e8ea'
const UP = new Vector3(0, 1, 0)
/**
* Foam-jacket thickness (meters) wrapped around the line when `insulated`. A
* real ~3/4" black Armaflex sleeve adds ~3/8" of wall; this matches that so an
* insulated line reads visibly fatter than the bare copper underneath.
*/
const INSULATION_THICKNESS_M = 0.01
/** Cylinder spanning `start`→`end` at `radius`, named for debugging. */
function buildRun(
start: Vector3,
end: Vector3,
radius: number,
material: MeshStandardMaterial,
name: string,
): Mesh | null {
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-6) return null
dir.normalize()
const mesh = new Mesh(
new CylinderGeometry(radius, radius, length, RADIAL_SEGMENTS, 1, false),
material,
)
mesh.name = name
mesh.position.copy(start).addScaledVector(dir, length / 2)
mesh.quaternion.setFromUnitVectors(UP, dir)
return mesh
}
/**
* Pure geometry builder for a refrigerant lineset: a single copper line that
* follows the node path centerline, optionally wrapped in a foam jacket.
*
* One line per node — what the ghost previews is exactly what commits. To run
* the suction line beside the liquid line, draw them as two separate linesets
* rather than rendering both together off one path. Joint spheres cap interior
* corners so turns read as continuous pipe.
*
* Children are level-local meters; `<ParametricNodeRenderer>` owns the
* node transform (identity today — the path is absolute within the level).
*/
export function buildLinesetGeometry(node: LinesetNode): Group {
const group = new Group()
if (node.path.length < 2) return group
const copperR = (node.suctionDiameter * INCHES_TO_METERS) / 2
const jacketR = node.insulated ? copperR + INSULATION_THICKNESS_M : copperR
const copperMat = new MeshStandardMaterial({
color: COPPER_COLOR,
metalness: 0.8,
roughness: 0.3,
})
const insulationMat = new MeshStandardMaterial({
color: INSULATION_COLOR,
metalness: 0.1,
roughness: 0.9,
})
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
for (let i = 0; i < points.length - 1; i++) {
const copper = buildRun(points[i]!, points[i + 1]!, copperR, copperMat, `lineset-copper-${i}`)
if (copper) group.add(copper)
if (node.insulated) {
const jacket = buildRun(
points[i]!,
points[i + 1]!,
jacketR,
insulationMat,
`lineset-jacket-${i}`,
)
if (jacket) group.add(jacket)
}
}
// Joint caps at interior corners so turns read as continuous pipe.
for (let i = 1; i < points.length - 1; i++) {
const joint = new Mesh(new SphereGeometry(copperR, RADIAL_SEGMENTS, 10), copperMat)
joint.name = `lineset-copper-joint-${i}`
joint.position.copy(points[i] as Vector3)
group.add(joint)
if (node.insulated) {
const jJoint = new Mesh(new SphereGeometry(jacketR, RADIAL_SEGMENTS, 10), insulationMat)
jJoint.name = `lineset-jacket-joint-${i}`
jJoint.position.copy(points[i] as Vector3)
group.add(jJoint)
}
}
return group
}
+4
View File
@@ -0,0 +1,4 @@
export { type LinesetConnectPlan, planLinesetConnect } from './connect'
export { linesetDefinition } from './definition'
export { buildLinesetGeometry } from './geometry'
export { LinesetNode } from './schema'
+304
View File
@@ -0,0 +1,304 @@
'use client'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
emitter,
type GridEvent,
LinesetNode,
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 lineset's footprint radius (meters) — half the suction OD (the
* bigger of the pair), used as box / footprint padding and ghost radius. */
function linesetRadiusM(lineset: LinesetNode): number {
return (lineset.suctionDiameter * IN_TO_M) / 2
}
/** XZ bounds of a path padded by the lineset'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 refrigerant linesets — the
* refrigerant-loop sibling of `MovePipeSegmentTool`. A lineset is a
* suction + liquid copper pair; the ghost stands in with a single
* translucent cylinder at the suction OD per section (mirrors the draw
* tool's `PreviewSegment`).
*
* **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 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 MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
const lineset = node as LinesetNode
const originalPathRef = useRef<Vec3[]>(lineset.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 = linesetRadiusM(lineset)
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 = LinesetNode.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()
}
}, [lineset, 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 = linesetRadiusM(lineset)
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, lineset.suctionDiameter * IN_TO_M, box.maxZ - box.minZ]}
/>
</group>
)
}
/** Translucent stand-in for one lineset section — mirrors the draw tool's
* `PreviewSegment` 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, 16, 1, false]} />
<meshBasicMaterial
color={GHOST_COLOR}
depthTest={false}
opacity={GHOST_OPACITY}
transparent
/>
</mesh>
)
}
export default MoveLinesetTool
+37
View File
@@ -0,0 +1,37 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { LinesetNode } from './schema'
export const linesetParametrics: ParametricDescriptor<LinesetNode> = {
groups: [
{
label: 'Lines',
fields: [
{
key: 'suctionDiameter',
kind: 'number',
unit: 'in',
min: 0.25,
max: 1.5,
step: 0.125,
},
{
key: 'liquidDiameter',
kind: 'number',
unit: 'in',
min: 0.125,
max: 0.75,
step: 0.125,
},
],
},
{
label: 'Insulation',
fields: [
{
key: 'insulated',
kind: 'boolean',
},
],
},
],
}
+1
View File
@@ -0,0 +1 @@
export { LinesetNode } from '@pascal-app/core'
+282
View File
@@ -0,0 +1,282 @@
'use client'
import {
type AnyNodeId,
type LinesetNode,
pauseSceneHistory,
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, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
const HANDLE_RADIUS = 0.08
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 lineset runs: one draggable handle
* per path point. Mirrors the duct-segment path-handle system, but dragged
* run endpoints snap onto refrigerant ports only.
*
* Handles are PORTALED into the lineset's registered scene group so they
* share its exact frame. Drag raycasts run in world space and convert hits
* back into the group's local frame before writing the path.
*/
const LinesetSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const lineset = useScene((s) => {
if (selectedIds.length !== 1) return null
const node = s.nodes[selectedIds[0] as AnyNodeId]
return node?.type === 'lineset' ? (node as LinesetNode) : null
})
const linesetId = lineset?.id ?? null
const [target, setTarget] = useState<Object3D | null>(null)
useEffect(() => {
if (!linesetId) {
setTarget(null)
return
}
let frameId = 0
const resolve = () => {
const next = sceneRegistry.nodes.get(linesetId as AnyNodeId) ?? null
setTarget((cur) => (cur === next ? cur : next))
if (!next) frameId = window.requestAnimationFrame(resolve)
}
resolve()
return () => window.cancelAnimationFrame(frameId)
}, [linesetId])
if (!lineset || !target) return null
return createPortal(<LinesetPointHandles lineset={lineset} target={target} />, target, undefined)
}
const LinesetPointHandles = ({ lineset, target }: { lineset: LinesetNode; 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)
const dragRef = useRef<{
index: number
initialPath: Point[]
current: Point
cleanup: () => void
} | 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
}
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
}
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
const toLocal = (world: Vector3): Point => {
const local = target.worldToLocal(world.clone())
return [local.x, local.y, local.z]
}
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const initialPath = lineset.path.map((p) => [...p] as Point)
const startPoint = initialPath[index]!
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
document.body.style.cursor = 'grabbing'
setDraggingIndex(index)
const isEndpoint = index === 0 || index === initialPath.length - 1
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()
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
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
let next: Point | null = null
if (event.altKey) {
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: lineset.id, systems: REFRIGERANT_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (port) next = [port.position[0], port.position[1], port.position[2]]
}
}
} else {
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 = lineset.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
useScene.getState().updateNode(lineset.id, { path })
}
const onUp = () => {
const drag = dragRef.current
if (!drag) return
drag.cleanup()
dragRef.current = null
setDraggingIndex(null)
const finalPath = drag.initialPath.map((p, i) =>
i === drag.index ? drag.current : p,
) as Point[]
useScene.getState().updateNode(lineset.id, { path: drag.initialPath })
resumeSceneHistory(useScene)
const moved = finalPath[drag.index]!.some(
(v, axis) => v !== drag.initialPath[drag.index]![axis],
)
if (moved) useScene.getState().updateNode(lineset.id, { path: finalPath })
}
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 }
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
return (
<group>
{lineset.path.map((p, i) => {
const active = draggingIndex === i
const hovered = hoverIndex === i
return (
<mesh
key={`lineset-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 ? '#a5b4fc' : '#818cf8'}
depthTest={false}
opacity={active ? 1 : 0.85}
transparent
/>
</mesh>
)
})}
{draggingIndex !== null &&
lineset.path[draggingIndex] &&
(() => {
const point = lineset.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 LinesetSelectionAffordance
+388
View File
@@ -0,0 +1,388 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, LinesetNode, 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 { type Group, Vector3 } from 'three'
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
import { planLinesetConnect } from './connect'
import { linesetDefinition } from './definition'
/**
* One-segment-at-a-time placement tool for refrigerant linesets — the
* refrigerant-loop sibling of the duct-segment tool.
*
* Mouse-driven model:
* - **First click** anchors the run start. Within range of a refrigerant
* service port (a condenser / coil valve, or another lineset's end) it
* snaps onto the port so a run mates flush.
* - **Second click** commits a two-point lineset and re-arms the tool.
* - The in-flight end is angle-locked to the nearest 45° step in XZ from
* the start; Y stays at the start's height. Hold **Shift** to release.
* - Hold **Alt** → vertical mode. XZ locks to the start; vertical mouse
* motion drives Y. Click commits the riser segment.
* - Esc clears an anchored start point.
*
* Snapping is restricted to refrigerant ports, so a lineset never grabs a
* supply/return duct collar.
*/
const PREVIEW_OPACITY = 0.6
const PREVIEW_COLOR = '#b06b3f'
/** Snap radius (meters) for joining onto a refrigerant port. */
const ENDPOINT_SNAP_RADIUS_M = 0.5
/** Angle step (radians) for the XZ angle lock — 45°. */
const ANGLE_STEP_RAD = Math.PI / 4
/** Mouse pixels → meters mapping for Alt-vertical drag. 100 px ≈ 1 m. */
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
}
/** Nearest refrigerant port within snap range on the XZ plane, as a
* position tuple. Y is ignored for the distance check; the snap adopts the
* port's full 3D position. */
function findNearbyPort(point: [number, number, number]): [number, number, number] | null {
const port = findNearestPortXZ(
point,
collectScenePorts({ systems: REFRIGERANT_PORT_SYSTEMS }),
ENDPOINT_SNAP_RADIUS_M,
)
return port ? [port.position[0], port.position[1], port.position[2]] : null
}
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 LinesetTool = () => {
const activeLevelId = useViewer((s) => s.selection.levelId)
const unit = useViewer((s) => s.unit)
const cursorRef = useRef<Group>(null)
const [draftPoints, setDraftPoints] = useState<Array<[number, number, number]>>([])
const [cursorPos, setCursorPos] = useState<[number, number, number] | null>(null)
const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null)
const [altActive, setAltActive] = useState(false)
const draftRef = useRef(draftPoints)
draftRef.current = draftPoints
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
const lastClientYRef = useRef<number | null>(null)
useEffect(() => {
if (!activeLevelId) return
const commitSegment = (start: [number, number, number], end: [number, number, number]) => {
const sameSpot =
Math.abs(start[0] - end[0]) < 1e-4 &&
Math.abs(start[1] - end[1]) < 1e-4 &&
Math.abs(start[2] - end[2]) < 1e-4
if (sameSpot) return
// Fold into any existing run that shares this segment's endpoint, so
// two runs meeting at a coordinate become one mitered path instead of
// overlapping nodes. Only same-level runs are candidates — lineset
// paths are level-local.
const scene = useScene.getState()
const existing = Object.values(scene.nodes).filter(
(n): n is LinesetNode =>
n?.type === 'lineset' && (n.parentId as AnyNodeId | null) === activeLevelId,
)
const plan = planLinesetConnect(existing, start, end)
if (plan.kind === 'create') {
const lineset = LinesetNode.parse({
...linesetDefinition.defaults(),
name: 'Lineset',
path: plan.path,
})
scene.createNode(lineset, activeLevelId)
} else if (plan.kind === 'extend') {
scene.updateNode(plan.id, { path: plan.path })
} else {
scene.updateNode(plan.id, { path: plan.path })
scene.deleteNode(plan.deleteId)
}
triggerSFX('sfx:item-place')
setDraftPoints([])
setSnapTarget(null)
altAnchorRef.current = null
setAltActive(false)
}
const resolveSnappedPoint = (
event: GridEvent,
): { point: [number, number, number]; snapped: [number, number, number] | null } => {
const last = draftRef.current.at(-1)
if (!last) {
const raw: [number, number, number] = [event.localPosition[0], 0, event.localPosition[2]]
if (event.nativeEvent?.altKey !== true) {
const target = findNearbyPort(raw)
if (target) return { point: target, snapped: target }
}
const step = useEditor.getState().gridSnapStep
return { point: [snap(raw[0], step), 0, snap(raw[2], step)], snapped: null }
}
const rawXZ: [number, number, number] = [
event.localPosition[0],
last[1],
event.localPosition[2],
]
const shift = event.nativeEvent?.shiftKey === true
const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ)
if (event.nativeEvent?.altKey !== true && !shift) {
const target = findNearbyPort(rawXZ)
if (target) return { point: target, snapped: target }
}
const step = useEditor.getState().gridSnapStep
return { point: [snap(angled[0], step), angled[1], snap(angled[2], step)], snapped: null }
}
const resolveAltVerticalPoint = (clientY: number): [number, number, number] | null => {
const anchor = altAnchorRef.current
const last = draftRef.current.at(-1)
if (!anchor || !last) 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 [last[0], y, last[2]]
}
// Resolve the cursor point (port / grid / angle snap) then layer
// Figma-style alignment so a lineset lines up with other runs, equipment,
// and items as it's drawn. Free point (first vertex / Shift) snaps; an
// angle-locked continuation shows the guide passively. Port snap or Alt
// bypasses alignment.
const resolveAlignedPoint = (event: GridEvent) => {
const r = resolveSnappedPoint(event)
const hasStart = draftRef.current.length > 0
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 = draftRef.current.at(-1)
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 } = resolveAlignedPoint(event)
if (!start) {
triggerSFX('sfx:grid-snap')
setDraftPoints([point])
return
}
commitSegment(start, point)
}
const enterAltMode = () => {
const last = draftRef.current.at(-1)
if (!last || lastClientYRef.current === null) return
if (altAnchorRef.current) return
altAnchorRef.current = { clientY: lastClientYRef.current, baseY: last[1] }
setAltActive(true)
}
const exitAltMode = () => {
if (!altAnchorRef.current) return
altAnchorRef.current = null
setAltActive(false)
}
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()
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Alt') {
e.preventDefault()
exitAltMode()
}
}
const onCancel = () => {
clearDrawAlignment()
if (draftRef.current.length === 0) return
markToolCancelConsumed()
setDraftPoints([])
setCursorPos(null)
setSnapTarget(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
const previewSegments: Array<{ a: [number, number, number]; b: [number, number, number] }> = []
for (let i = 0; i < draftPoints.length - 1; i++) {
previewSegments.push({ a: draftPoints[i]!, b: draftPoints[i + 1]! })
}
const last = draftPoints.at(-1)
if (last && cursorPos) {
previewSegments.push({ a: last, b: cursorPos })
}
const pillParts = cursorPos
? (['x', 'y', 'z'] as const).map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: last ? cursorPos[i]! - last[i]! : cursorPos[i]!,
signed: !!last,
}))
: null
const pillPrimary =
last && cursorPos
? altActive
? 'y'
: Math.abs(cursorPos[0] - last[0]) >= Math.abs(cursorPos[2] - last[2])
? 'x'
: 'z'
: 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
`lineset` 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 color={PREVIEW_COLOR} position={cursorPos} ref={cursorRef} />
{pillParts && (
<group position={cursorPos}>
<Html
center
position={[0, 0.35, 0]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
</Html>
</group>
)}
</>
)}
{snapTarget && (
<mesh layers={EDITOR_LAYER} position={snapTarget}>
<sphereGeometry args={[0.1, 24, 16]} />
<meshBasicMaterial color={PREVIEW_COLOR} depthTest={false} opacity={0.35} transparent />
</mesh>
)}
{draftPoints.map((p, i) => (
<mesh key={`pt-${i}`} layers={EDITOR_LAYER} position={p}>
<sphereGeometry args={[0.06, 16, 12]} />
<meshBasicMaterial color={PREVIEW_COLOR} depthTest={false} />
</mesh>
))}
{previewSegments.map((seg, i) => (
<PreviewSegment a={seg.a} b={seg.b} key={`seg-${i}`} />
))}
</LevelOffsetGroup>
)
}
function PreviewSegment({ a, b }: { a: [number, number, number]; b: [number, number, 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)
// Default suction OD (~7/8") for the ghost.
const radius = (0.875 * 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, 16, 1, false]} />
<meshBasicMaterial
color={PREVIEW_COLOR}
depthTest={false}
opacity={PREVIEW_OPACITY}
transparent
/>
</mesh>
)
}
export default LinesetTool