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:
@@ -0,0 +1,98 @@
|
||||
import type { LiquidLineNode } from './schema'
|
||||
|
||||
type Point = [number, number, number]
|
||||
type LiquidLineId = LiquidLineNode['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: LiquidLineNode, 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 liquid line whose start or end coincides with `p`. */
|
||||
function findConnection(
|
||||
existing: LiquidLineNode[],
|
||||
p: Point,
|
||||
): { line: LiquidLineNode; 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
|
||||
* liquid-line 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 LiquidLineConnectPlan =
|
||||
| { kind: 'create'; path: Point[] }
|
||||
| { kind: 'extend'; id: LiquidLineId; path: Point[] }
|
||||
| { kind: 'bridge'; id: LiquidLineId; path: Point[]; deleteId: LiquidLineId }
|
||||
|
||||
/**
|
||||
* Decide how a freshly drawn `start`→`end` segment folds into existing
|
||||
* liquid-line 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 planLiquidLineConnect(
|
||||
existing: LiquidLineNode[],
|
||||
start: Point,
|
||||
end: Point,
|
||||
): LiquidLineConnectPlan {
|
||||
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] }
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
|
||||
import { buildLiquidLineFloorplan } from './floorplan'
|
||||
import { buildLiquidLineGeometry } from './geometry'
|
||||
import { liquidLineParametrics } from './parametrics'
|
||||
import { LiquidLineNode } from './schema'
|
||||
|
||||
/**
|
||||
* Standalone refrigerant liquid line — the thin bare-copper line broken out of
|
||||
* the lineset so it can be drawn on its own. The refrigerant-side sibling of
|
||||
* `lineset`: same polyline model and draw tool, snapping onto refrigerant
|
||||
* service ports, but a single thin line. Its tool adds a Follow mode that
|
||||
* traces an existing lineset's path at an offset.
|
||||
*
|
||||
* Composition: `def.geometry` only, plus a selection-time path-handle system
|
||||
* shared in spirit with the lineset. The framework's `<ParametricNodeRenderer>`
|
||||
* mounts an empty group; `<GeometrySystem>` fills it via
|
||||
* `buildLiquidLineGeometry` on dirty.
|
||||
*/
|
||||
export const liquidLineDefinition: NodeDefinition<typeof LiquidLineNode> = {
|
||||
kind: 'liquid-line',
|
||||
schemaVersion: 1,
|
||||
schema: LiquidLineNode,
|
||||
category: 'utility',
|
||||
distributionRole: 'run',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
path: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
],
|
||||
diameter: 0.375,
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
parametrics: liquidLineParametrics,
|
||||
|
||||
geometry: buildLiquidLineGeometry,
|
||||
geometryKey: (n) => JSON.stringify([n.path, n.diameter]),
|
||||
|
||||
// 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.diameter
|
||||
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: buildLiquidLineFloorplan,
|
||||
|
||||
// 2D selection-time path-point handles — the floor-plan twin of the 3D
|
||||
// `affordanceTools.selection` handles.
|
||||
floorplanAffordances: {
|
||||
'move-path-point': createPathPointMoveAffordance('liquid-line'),
|
||||
},
|
||||
|
||||
// Selection-time path-point handles (drag to edit a committed run) and the
|
||||
// ghost-preview duplicate / move tool (drag-to-place a translucent copy).
|
||||
affordanceTools: {
|
||||
selection: () => import('./selection'),
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Click', label: 'Start liquid line' },
|
||||
{ key: 'Click again', label: 'Place it (locked to 45°)' },
|
||||
{ key: 'Shift', label: 'Free angle' },
|
||||
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
|
||||
{ key: 'F', label: 'Follow: trace a lineset' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Liquid Line',
|
||||
description:
|
||||
'Standalone refrigerant liquid line — a thin bare-copper run; Follow mode traces an existing lineset.',
|
||||
icon: { kind: 'url', src: '/icons/lineset.png' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 94,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A standalone refrigerant liquid line defined as a polyline of thin bare copper. Snaps onto refrigerant service ports; can be traced alongside an existing lineset.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
|
||||
import { INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||
import type { LiquidLineNode } from './schema'
|
||||
|
||||
const COPPER_LINE = '#b06b3f'
|
||||
|
||||
/**
|
||||
* Floor-plan representation of a liquid line: a single thin copper polyline at
|
||||
* the line's real width. Vertical risers collapse to a point in plan;
|
||||
* consecutive duplicate plan points are dropped so they don't render
|
||||
* zero-length artifacts.
|
||||
*/
|
||||
export function buildLiquidLineFloorplan(
|
||||
node: LiquidLineNode,
|
||||
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 = node.diameter * 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: Math.max(widthM, 0.02),
|
||||
fill: COPPER_LINE,
|
||||
stroke: showSelectedChrome && palette ? palette.selectedStroke : COPPER_LINE,
|
||||
strokeWidth: 0.02,
|
||||
opacity: 0.9,
|
||||
}
|
||||
}
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'polyline',
|
||||
points,
|
||||
stroke: showSelectedChrome && palette ? palette.selectedStroke : COPPER_LINE,
|
||||
strokeWidth: Math.max(widthM * 2, 0.04),
|
||||
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,66 @@
|
||||
import { CylinderGeometry, Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three'
|
||||
import { INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||
import type { LiquidLineNode } from './schema'
|
||||
|
||||
const RADIAL_SEGMENTS = 16
|
||||
const COPPER_COLOR = '#b06b3f'
|
||||
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
/** 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 standalone liquid line: a single thin bare-copper
|
||||
* cylinder following the node path centerline, with joint spheres capping
|
||||
* 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 buildLiquidLineGeometry(node: LiquidLineNode): Group {
|
||||
const group = new Group()
|
||||
if (node.path.length < 2) return group
|
||||
|
||||
const radius = (node.diameter * INCHES_TO_METERS) / 2
|
||||
const copperMat = new MeshStandardMaterial({
|
||||
color: COPPER_COLOR,
|
||||
metalness: 0.8,
|
||||
roughness: 0.3,
|
||||
})
|
||||
|
||||
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const run = buildRun(points[i]!, points[i + 1]!, radius, copperMat, `liquid-line-${i}`)
|
||||
if (run) group.add(run)
|
||||
}
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const joint = new Mesh(new SphereGeometry(radius, RADIAL_SEGMENTS, 10), copperMat)
|
||||
joint.name = `liquid-line-joint-${i}`
|
||||
joint.position.copy(points[i] as Vector3)
|
||||
group.add(joint)
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { type LiquidLineConnectPlan, planLiquidLineConnect } from './connect'
|
||||
export { liquidLineDefinition } from './definition'
|
||||
export { buildLiquidLineGeometry } from './geometry'
|
||||
export { useLiquidLineToolOptions } from './options'
|
||||
export { LiquidLineNode } from './schema'
|
||||
@@ -0,0 +1,300 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
LiquidLineNode,
|
||||
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 liquid line's footprint radius (meters) — half its OD, used as box /
|
||||
* footprint padding and ghost radius. */
|
||||
function liquidLineRadiusM(line: LiquidLineNode): number {
|
||||
return (line.diameter * IN_TO_M) / 2
|
||||
}
|
||||
|
||||
/** XZ bounds of a path padded by the line'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 liquid lines — the path-mover sibling
|
||||
* of `MoveLinesetTool`. A translucent cylinder at the line's OD per section
|
||||
* stands in for the run (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 rides the cursor
|
||||
* inside a footprint bounding box 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 MoveLiquidLineTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
const line = node as LiquidLineNode
|
||||
const originalPathRef = useRef<Vec3[]>(line.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 = liquidLineRadiusM(line)
|
||||
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 = LiquidLineNode.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()
|
||||
}
|
||||
}, [line, 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 = liquidLineRadiusM(line)
|
||||
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, line.diameter * IN_TO_M, box.maxZ - box.minZ]}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
/** Translucent stand-in for one liquid-line 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 MoveLiquidLineTool
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
/**
|
||||
* Shared draw-time options for the liquid-line tool. Lives in the nodes
|
||||
* package so both the tool (which reads + key-toggles it) and the app's MEP
|
||||
* panel (which renders the toggle button) can bind to the same state.
|
||||
*
|
||||
* `follow` arms "trace a lineset": while on, clicking an existing lineset
|
||||
* lays a liquid line beside it along the same path instead of free-drawing.
|
||||
*/
|
||||
type LiquidLineToolOptions = {
|
||||
follow: boolean
|
||||
setFollow: (value: boolean) => void
|
||||
toggleFollow: () => void
|
||||
}
|
||||
|
||||
export const useLiquidLineToolOptions = create<LiquidLineToolOptions>((set) => ({
|
||||
follow: false,
|
||||
setFollow: (value) => set({ follow: value }),
|
||||
toggleFollow: () => set((s) => ({ follow: !s.follow })),
|
||||
}))
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { LiquidLineNode } from './schema'
|
||||
|
||||
export const liquidLineParametrics: ParametricDescriptor<LiquidLineNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Line',
|
||||
fields: [
|
||||
{
|
||||
key: 'diameter',
|
||||
kind: 'number',
|
||||
unit: 'in',
|
||||
min: 0.125,
|
||||
max: 0.75,
|
||||
step: 0.125,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { LiquidLineNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,282 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type LiquidLineNode,
|
||||
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.07
|
||||
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 liquid-line runs: one draggable handle
|
||||
* per path point. Mirrors the lineset path-handle system; dragged run
|
||||
* endpoints snap onto refrigerant ports only.
|
||||
*
|
||||
* Handles are PORTALED into the line'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 LiquidLineSelectionAffordance = () => {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const line = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return null
|
||||
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
||||
return node?.type === 'liquid-line' ? (node as LiquidLineNode) : null
|
||||
})
|
||||
|
||||
const lineId = line?.id ?? null
|
||||
const [target, setTarget] = useState<Object3D | null>(null)
|
||||
useEffect(() => {
|
||||
if (!lineId) {
|
||||
setTarget(null)
|
||||
return
|
||||
}
|
||||
let frameId = 0
|
||||
const resolve = () => {
|
||||
const next = sceneRegistry.nodes.get(lineId as AnyNodeId) ?? null
|
||||
setTarget((cur) => (cur === next ? cur : next))
|
||||
if (!next) frameId = window.requestAnimationFrame(resolve)
|
||||
}
|
||||
resolve()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [lineId])
|
||||
|
||||
if (!line || !target) return null
|
||||
return createPortal(<LiquidLinePointHandles line={line} target={target} />, target, undefined)
|
||||
}
|
||||
|
||||
const LiquidLinePointHandles = ({ line, target }: { line: LiquidLineNode; 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 = line.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: line.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 = line.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
|
||||
useScene.getState().updateNode(line.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(line.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(line.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>
|
||||
{line.path.map((p, i) => {
|
||||
const active = draggingIndex === i
|
||||
const hovered = hoverIndex === i
|
||||
return (
|
||||
<mesh
|
||||
key={`liquid-line-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 &&
|
||||
line.path[draggingIndex] &&
|
||||
(() => {
|
||||
const point = line.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 LiquidLineSelectionAffordance
|
||||
@@ -0,0 +1,545 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type LinesetNode,
|
||||
LiquidLineNode,
|
||||
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 { offsetPathHorizontal } from '../shared/path-offset'
|
||||
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
|
||||
import { planLiquidLineConnect } from './connect'
|
||||
import { liquidLineDefinition } from './definition'
|
||||
import { useLiquidLineToolOptions } from './options'
|
||||
|
||||
/**
|
||||
* One-segment-at-a-time placement tool for standalone liquid lines — the same
|
||||
* draw model as the lineset tool (the line it used to be a rail of):
|
||||
* - **First click** anchors the run start; within range of a refrigerant
|
||||
* service port it snaps onto it so a run mates flush.
|
||||
* - **Second click** commits a two-point line and re-arms; the in-flight end
|
||||
* is angle-locked to 45° (Shift frees it), Alt drags it vertical.
|
||||
*
|
||||
* **Follow mode** (toggled by the MEP panel's Follow button or the `F` key):
|
||||
* instead of free-drawing, hover an existing lineset and click — a liquid line
|
||||
* is laid beside it, tracing the lineset's whole path at a fixed offset on the
|
||||
* side the cursor is on. This is the "place it exactly next to this" affordance.
|
||||
*/
|
||||
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
|
||||
|
||||
const IN_TO_M = 0.0254
|
||||
/** Default liquid OD (~3/8") — the ghost radius and trace-line size. */
|
||||
const DEFAULT_DIAMETER_IN = 0.375
|
||||
const GHOST_RADIUS_M = (DEFAULT_DIAMETER_IN * IN_TO_M) / 2
|
||||
/** Matches the lineset's foam-jacket thickness so the traced line sits just
|
||||
* outside an insulated suction line, exactly where the old paired rail was. */
|
||||
const INSULATION_THICKNESS_M = 0.01
|
||||
/** How close (meters, XZ) the cursor must be to a lineset path to trace it. */
|
||||
const FOLLOW_PICK_RADIUS_M = 0.6
|
||||
/** Clear-air gap (meters) between the lineset's outer surface and the traced
|
||||
* liquid line, so the new run reads as its own line instead of fusing onto
|
||||
* the lineset (~2"). */
|
||||
const FOLLOW_GAP_M = 0.05
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
|
||||
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: Vec3): Vec3 | 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: Vec3, raw: Vec3): Vec3 {
|
||||
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]
|
||||
}
|
||||
|
||||
/** Distance (XZ) from point `p` to segment `a`→`b`. */
|
||||
function distToSegmentXZ(p: Vec3, a: Vec3, b: Vec3): number {
|
||||
const dx = b[0] - a[0]
|
||||
const dz = b[2] - a[2]
|
||||
const len2 = dx * dx + dz * dz
|
||||
let t = len2 > 0 ? ((p[0] - a[0]) * dx + (p[2] - a[2]) * dz) / len2 : 0
|
||||
t = Math.max(0, Math.min(1, t))
|
||||
const cx = a[0] + t * dx
|
||||
const cz = a[2] + t * dz
|
||||
return Math.hypot(p[0] - cx, p[2] - cz)
|
||||
}
|
||||
|
||||
/** Center-to-center offset (meters) that drops the liquid line a small gap
|
||||
* outside the lineset's outer surface, so the two read as separate lines. */
|
||||
function traceOffsetMeters(lineset: LinesetNode): number {
|
||||
const suctionR = (lineset.suctionDiameter * IN_TO_M) / 2
|
||||
const jacket = lineset.insulated ? INSULATION_THICKNESS_M : 0
|
||||
return suctionR + jacket + FOLLOW_GAP_M + GHOST_RADIUS_M
|
||||
}
|
||||
|
||||
type FollowTarget = { lineset: LinesetNode; sign: number }
|
||||
|
||||
/**
|
||||
* Nearest lineset whose path passes within `FOLLOW_PICK_RADIUS_M` of the
|
||||
* cursor, plus which side of it the cursor is on (`sign`, matching
|
||||
* `offsetPathHorizontal`'s side convention). Restricted to the active level.
|
||||
*/
|
||||
function findFollowTarget(point: Vec3, levelId: AnyNodeId): FollowTarget | null {
|
||||
const scene = useScene.getState()
|
||||
let best: FollowTarget | null = null
|
||||
let bestD = FOLLOW_PICK_RADIUS_M
|
||||
for (const n of Object.values(scene.nodes)) {
|
||||
if (!n || n.type !== 'lineset') continue
|
||||
if ((n.parentId as AnyNodeId | null) !== levelId) continue
|
||||
const ls = n as LinesetNode
|
||||
if (ls.path.length < 2) continue
|
||||
for (let i = 0; i < ls.path.length - 1; i++) {
|
||||
const a = ls.path[i] as Vec3
|
||||
const b = ls.path[i + 1] as Vec3
|
||||
const d = distToSegmentXZ(point, a, b)
|
||||
if (d >= bestD) continue
|
||||
bestD = d
|
||||
// Side vector = normalize(heading_xz) × UP = (-hz, 0, hx); sign is which
|
||||
// side of the segment the cursor sits on.
|
||||
const hx = b[0] - a[0]
|
||||
const hz = b[2] - a[2]
|
||||
const hlen = Math.hypot(hx, hz)
|
||||
const sx = hlen > 1e-9 ? -hz / hlen : 0
|
||||
const sz = hlen > 1e-9 ? hx / hlen : 0
|
||||
const dot = (point[0] - a[0]) * sx + (point[2] - a[2]) * sz
|
||||
best = { lineset: ls, sign: dot >= 0 ? 1 : -1 }
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** The offset path a follow-target would trace, or null if degenerate. */
|
||||
function tracePath(target: FollowTarget): Vec3[] | null {
|
||||
const offset = target.sign * traceOffsetMeters(target.lineset)
|
||||
const traced = offsetPathHorizontal(target.lineset.path as Vec3[], offset)
|
||||
return traced.length >= 2 ? traced : null
|
||||
}
|
||||
|
||||
const LiquidLineTool = () => {
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const follow = useLiquidLineToolOptions((s) => s.follow)
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const [draftPoints, setDraftPoints] = useState<Vec3[]>([])
|
||||
const [cursorPos, setCursorPos] = useState<Vec3 | null>(null)
|
||||
const [snapTarget, setSnapTarget] = useState<Vec3 | null>(null)
|
||||
const [traceGhost, setTraceGhost] = useState<Vec3[] | null>(null)
|
||||
const [altActive, setAltActive] = useState(false)
|
||||
const draftRef = useRef(draftPoints)
|
||||
draftRef.current = draftPoints
|
||||
const followTargetRef = useRef<FollowTarget | null>(null)
|
||||
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
|
||||
const lastClientYRef = useRef<number | null>(null)
|
||||
|
||||
// Clear in-flight draft / trace whenever Follow toggles (panel button or F).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: `follow` is an intentional re-run trigger; the body clears the in-flight draft when it toggles.
|
||||
useEffect(() => {
|
||||
setDraftPoints([])
|
||||
setTraceGhost(null)
|
||||
followTargetRef.current = null
|
||||
altAnchorRef.current = null
|
||||
setAltActive(false)
|
||||
}, [follow])
|
||||
|
||||
// Leaving the tool clears Follow so re-arming it starts in free-draw.
|
||||
useEffect(() => () => useLiquidLineToolOptions.getState().setFollow(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
const commitSegment = (start: Vec3, end: Vec3) => {
|
||||
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.
|
||||
const scene = useScene.getState()
|
||||
const existing = Object.values(scene.nodes).filter(
|
||||
(n): n is LiquidLineNode =>
|
||||
n?.type === 'liquid-line' && (n.parentId as AnyNodeId | null) === activeLevelId,
|
||||
)
|
||||
const plan = planLiquidLineConnect(existing, start, end)
|
||||
|
||||
if (plan.kind === 'create') {
|
||||
const line = LiquidLineNode.parse({
|
||||
...liquidLineDefinition.defaults(),
|
||||
name: 'Liquid Line',
|
||||
path: plan.path,
|
||||
})
|
||||
scene.createNode(line, 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)
|
||||
}
|
||||
|
||||
// Lay a liquid line beside a lineset, tracing its whole path at the offset.
|
||||
const commitTrace = (target: FollowTarget) => {
|
||||
const traced = tracePath(target)
|
||||
if (!traced) return
|
||||
const scene = useScene.getState()
|
||||
const line = LiquidLineNode.parse({
|
||||
...liquidLineDefinition.defaults(),
|
||||
name: 'Liquid Line',
|
||||
path: traced,
|
||||
})
|
||||
scene.createNode(line, activeLevelId)
|
||||
triggerSFX('sfx:item-place')
|
||||
setTraceGhost(null)
|
||||
followTargetRef.current = null
|
||||
}
|
||||
|
||||
const resolveSnappedPoint = (event: GridEvent): { point: Vec3; snapped: Vec3 | null } => {
|
||||
const last = draftRef.current.at(-1)
|
||||
if (!last) {
|
||||
const raw: Vec3 = [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: Vec3 = [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): Vec3 | 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]]
|
||||
}
|
||||
|
||||
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) => {
|
||||
// Follow mode: track the lineset under the cursor and preview its trace.
|
||||
if (useLiquidLineToolOptions.getState().follow) {
|
||||
const raw: Vec3 = [event.localPosition[0], 0, event.localPosition[2]]
|
||||
clearDrawAlignment()
|
||||
setCursorPos(raw)
|
||||
setSnapTarget(null)
|
||||
const target = findFollowTarget(raw, activeLevelId as AnyNodeId)
|
||||
followTargetRef.current = target
|
||||
setTraceGhost(target ? tracePath(target) : null)
|
||||
return
|
||||
}
|
||||
|
||||
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) => {
|
||||
// Follow mode: a click commits the trace beside the hovered lineset.
|
||||
if (useLiquidLineToolOptions.getState().follow) {
|
||||
const target = followTargetRef.current
|
||||
if (target) commitTrace(target)
|
||||
return
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (useLiquidLineToolOptions.getState().follow) return
|
||||
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 === 'f' || e.key === 'F') {
|
||||
e.preventDefault()
|
||||
useLiquidLineToolOptions.getState().toggleFollow()
|
||||
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 && !followTargetRef.current) return
|
||||
markToolCancelConsumed()
|
||||
setDraftPoints([])
|
||||
setCursorPos(null)
|
||||
setSnapTarget(null)
|
||||
setTraceGhost(null)
|
||||
followTargetRef.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
|
||||
|
||||
const previewSegments: Array<{ a: Vec3; b: Vec3 }> = []
|
||||
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 traceSegments: Array<{ a: Vec3; b: Vec3 }> = []
|
||||
if (traceGhost) {
|
||||
for (let i = 0; i < traceGhost.length - 1; i++) {
|
||||
traceSegments.push({ a: traceGhost[i]!, b: traceGhost[i + 1]! })
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
{cursorPos && (
|
||||
<>
|
||||
<CursorSphere color={PREVIEW_COLOR} position={cursorPos} ref={cursorRef} />
|
||||
{follow ? (
|
||||
<group position={cursorPos}>
|
||||
<Html
|
||||
center
|
||||
position={[0, 0.45, 0]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(17,17,20,0.85)',
|
||||
border: '1px solid rgba(176,107,63,0.6)',
|
||||
borderRadius: 6,
|
||||
color: '#f3e7dd',
|
||||
fontSize: 11,
|
||||
padding: '3px 7px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{followTargetRef.current
|
||||
? 'Click to trace this lineset'
|
||||
: 'Follow: hover a lineset'}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
) : (
|
||||
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.05, 16, 12]} />
|
||||
<meshBasicMaterial color={PREVIEW_COLOR} depthTest={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{previewSegments.map((seg, i) => (
|
||||
<PreviewSegment a={seg.a} b={seg.b} key={`seg-${i}`} />
|
||||
))}
|
||||
{traceSegments.map((seg, i) => (
|
||||
<PreviewSegment a={seg.a} b={seg.b} key={`trace-${i}`} />
|
||||
))}
|
||||
</LevelOffsetGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewSegment({ a, b }: { a: Vec3; b: Vec3 }) {
|
||||
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={[GHOST_RADIUS_M, GHOST_RADIUS_M, length, 16, 1, false]} />
|
||||
<meshBasicMaterial
|
||||
color={PREVIEW_COLOR}
|
||||
depthTest={false}
|
||||
opacity={PREVIEW_OPACITY}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
export default LiquidLineTool
|
||||
Reference in New Issue
Block a user