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,188 @@
|
||||
import { type AnyNode, type NodeDefinition, useScene } from '@pascal-app/core'
|
||||
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
|
||||
import { buildDuctSegmentFloorplan } from './floorplan'
|
||||
import { buildDuctSegmentGeometry, ductPortDiameterIn } from './geometry'
|
||||
import { ductSegmentParametrics } from './parametrics'
|
||||
import { DuctSegmentNode } from './schema'
|
||||
|
||||
/**
|
||||
* Phase 1 of the HVAC node system — round duct segment as a polyline.
|
||||
*
|
||||
* Composition: `def.geometry` only. No custom renderer, no per-frame
|
||||
* system. The framework's `<ParametricNodeRenderer>` mounts an empty
|
||||
* group; `<GeometrySystem>` calls `buildDuctSegmentGeometry` whenever
|
||||
* the node is dirty and swaps in the cylinder+sphere meshes.
|
||||
*
|
||||
* Deferred to later slices:
|
||||
* - Placement tool (polyline draw UX).
|
||||
* - Fittings (elbow / tee / reducer) — needs typed ports first.
|
||||
* - Terminals (registers / diffusers) — needs surface-snapping.
|
||||
* - Equipment (furnace / air-handler / condenser).
|
||||
* - Floor-plan rendering.
|
||||
* - Move / endpoint handles.
|
||||
*
|
||||
* The node can be created programmatically today via
|
||||
* `DuctSegmentNode.parse({ path: [...] })` + `useScene.createNode(...)`.
|
||||
*/
|
||||
/** R / T roll step (radians) — 45°, matching the fitting rotate. */
|
||||
const ROLL_STEP_RAD = Math.PI / 4
|
||||
|
||||
/**
|
||||
* R / T roll a selected rect / oval run's cross-section ±45° around its
|
||||
* drawn line, so a rectangular trunk can be turned on its side after
|
||||
* placement. Round runs look identical at any roll, so the action gates
|
||||
* itself off for them (`appliesTo`) and the editor's default rotation —
|
||||
* a no-op for a node with no `rotation` field — takes over harmlessly.
|
||||
*/
|
||||
function rollDuctSegment(node: AnyNode, steps: 1 | -1): void {
|
||||
const duct = node as DuctSegmentNode
|
||||
useScene.getState().updateNode(duct.id, { roll: duct.roll + steps * ROLL_STEP_RAD })
|
||||
}
|
||||
|
||||
export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
||||
kind: 'duct-segment',
|
||||
schemaVersion: 1,
|
||||
schema: DuctSegmentNode,
|
||||
category: 'utility',
|
||||
distributionRole: 'run',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
path: [
|
||||
[0, 0, 0],
|
||||
[3, 0, 0],
|
||||
],
|
||||
shape: 'rect',
|
||||
diameter: 6,
|
||||
width: 14,
|
||||
height: 8,
|
||||
ductMaterial: 'flex',
|
||||
seamDetail: false,
|
||||
insulated: false,
|
||||
insulationR: 0.5,
|
||||
system: 'supply',
|
||||
roll: 0,
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
parametrics: ductSegmentParametrics,
|
||||
|
||||
// R / T roll a selected rect / oval run ±45° around its drawn line.
|
||||
// `appliesTo` lets round runs fall through to the editor's default
|
||||
// (harmless — duct-segment has no `rotation` field).
|
||||
keyboardActions: {
|
||||
r: {
|
||||
appliesTo: (node) => node.type === 'duct-segment' && node.shape !== 'round',
|
||||
run: (node) => rollDuctSegment(node, 1),
|
||||
},
|
||||
t: {
|
||||
appliesTo: (node) => node.type === 'duct-segment' && node.shape !== 'round',
|
||||
run: (node) => rollDuctSegment(node, -1),
|
||||
},
|
||||
},
|
||||
|
||||
geometry: buildDuctSegmentGeometry,
|
||||
geometryKey: (n) =>
|
||||
JSON.stringify([
|
||||
n.path,
|
||||
n.shape,
|
||||
n.diameter,
|
||||
n.width,
|
||||
n.height,
|
||||
n.roll,
|
||||
n.ductMaterial,
|
||||
n.seamDetail,
|
||||
n.insulated,
|
||||
n.insulationR,
|
||||
n.system,
|
||||
]),
|
||||
|
||||
// Open run ends as typed ports — directions point outward along the
|
||||
// path tangent so fittings mate flush. Path coords are already
|
||||
// level-local, so no transform is needed.
|
||||
ports: (n) => {
|
||||
if (n.path.length < 2) return []
|
||||
const unit = (
|
||||
a: readonly [number, number, number],
|
||||
b: readonly [number, number, number],
|
||||
): [number, number, number] => {
|
||||
const d: [number, number, number] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
const len = Math.hypot(d[0], d[1], d[2])
|
||||
return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len]
|
||||
}
|
||||
const first = n.path[0]!
|
||||
const second = n.path[1]!
|
||||
const last = n.path[n.path.length - 1]!
|
||||
const prev = n.path[n.path.length - 2]!
|
||||
return [
|
||||
{
|
||||
id: 'start',
|
||||
position: first,
|
||||
direction: unit(first, second),
|
||||
diameter: ductPortDiameterIn(n),
|
||||
system: n.system,
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
position: last,
|
||||
direction: unit(last, prev),
|
||||
diameter: ductPortDiameterIn(n),
|
||||
system: n.system,
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
floorplan: buildDuctSegmentFloorplan,
|
||||
|
||||
// 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('duct-segment'),
|
||||
},
|
||||
|
||||
// Selection-time path-point handles (drag to edit a committed run).
|
||||
// Editor-only UI (reads gridSnapStep, renders DimensionPill), so it
|
||||
// mounts via the editor's SelectionAffordanceManager — not `def.system`,
|
||||
// which the viewer package mounts for the read-only route.
|
||||
affordanceTools: {
|
||||
selection: () => import('./selection'),
|
||||
// Ghost-preview duplicate / move. Duplicate is pure drag-to-place: a
|
||||
// translucent copy of the run 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 segment' },
|
||||
{ key: 'Click again', label: 'Place it (locked to 45°)' },
|
||||
{ key: 'Shift', label: 'Free angle' },
|
||||
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
|
||||
{ key: '[ / ]', label: 'Duct diameter down / up' },
|
||||
{ key: 'Q', label: 'Round / rect trunk' },
|
||||
{ key: 'C', label: 'Ceiling / floor height' },
|
||||
{ key: 'Esc', label: 'Cancel start point' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Duct',
|
||||
description: 'HVAC duct run — polyline of round, rect, or flat-oval sections.',
|
||||
icon: { kind: 'url', src: '/icons/duct.png' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 90,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'An HVAC duct run defined as a polyline — round (branches), rect (trunks/plenums), or flat-oval (tight joist bays). Supply or return, with configurable size, material (incl. spiral seam), and external insulation.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
|
||||
import { INCHES_TO_METERS } from './geometry'
|
||||
import type { DuctSegmentNode } from './schema'
|
||||
|
||||
const SUPPLY_CENTERLINE = '#d4825a'
|
||||
const RETURN_CENTERLINE = '#5a8ad4'
|
||||
const BODY_COLOR = '#9ca3af'
|
||||
|
||||
/**
|
||||
* Floor-plan representation of a duct run: the path drawn at the duct's
|
||||
* real width (plan-unit stroke so it scales with zoom), with a dashed
|
||||
* centerline tinted by system — orange for supply, blue for return, the
|
||||
* same hues the 3D tint uses. Vertical risers collapse to a point in
|
||||
* plan; consecutive duplicate plan points are dropped so they don't
|
||||
* render zero-length artifacts.
|
||||
*/
|
||||
export function buildDuctSegmentFloorplan(
|
||||
node: DuctSegmentNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
if (node.path.length < 2) return null
|
||||
|
||||
// Project to plan, dropping consecutive duplicates (risers). `indexMap[k]`
|
||||
// is the original path index plan point k came from, so the drag handle
|
||||
// edits the right vertex.
|
||||
const points: FloorplanPoint[] = []
|
||||
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)
|
||||
}
|
||||
|
||||
// Plan width: rect / oval runs draw at their actual width; round at diameter.
|
||||
const diameterM = (node.shape === 'round' ? node.diameter : node.width) * INCHES_TO_METERS
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
|
||||
const centerline = node.system === 'supply' ? SUPPLY_CENTERLINE : RETURN_CENTERLINE
|
||||
|
||||
// A pure riser (single plan point) still gets a marker: a circle at
|
||||
// the duct's diameter so the vertical run is visible in plan.
|
||||
if (points.length < 2) {
|
||||
const p = points[0] ?? [node.path[0]![0], node.path[0]![2]]
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: p[0],
|
||||
cy: p[1],
|
||||
r: diameterM / 2,
|
||||
fill: BODY_COLOR,
|
||||
stroke: showSelectedChrome && palette ? palette.selectedStroke : centerline,
|
||||
strokeWidth: 0.02,
|
||||
opacity: 0.9,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'polyline',
|
||||
points,
|
||||
stroke: showSelectedChrome && palette ? palette.selectedStroke : BODY_COLOR,
|
||||
strokeWidth: diameterM,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
opacity: showSelectedChrome ? 0.95 : 0.8,
|
||||
},
|
||||
{
|
||||
kind: 'polyline',
|
||||
points,
|
||||
stroke: centerline,
|
||||
strokeWidth: 1.5,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeDasharray: '5 4',
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import {
|
||||
BoxGeometry,
|
||||
CatmullRomCurve3,
|
||||
CylinderGeometry,
|
||||
ExtrudeGeometry,
|
||||
Group,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
Quaternion,
|
||||
Shape,
|
||||
SphereGeometry,
|
||||
TubeGeometry,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import type { DuctSegmentNode } from './schema'
|
||||
|
||||
export const INCHES_TO_METERS = 0.0254
|
||||
// Insulation wraps the duct in a roughly uniform shell. A strictly physical
|
||||
// mapping (fiberglass ≈ R-3.2 per inch) makes low R-values nearly invisible
|
||||
// at screen scale — R-1 would add only ~8 mm over a 15 cm duct. So the shell
|
||||
// uses a perceptual mapping: a visible base jacket as soon as insulation is
|
||||
// non-zero, plus a clear per-R increment. Anchored so R-8 still lands near
|
||||
// the real-world ~3" jacket.
|
||||
const INSULATION_BASE_IN = 0.5
|
||||
const INSULATION_INCHES_PER_R = 0.3125
|
||||
function pickInsulationThickness(r: number): number {
|
||||
if (r <= 0) return 0
|
||||
return (INSULATION_BASE_IN + r * INSULATION_INCHES_PER_R) * INCHES_TO_METERS
|
||||
}
|
||||
|
||||
// Supply/return tint — kept only for the spiral seam ridge accent; the duct
|
||||
// body itself is plain white (see createDuctMaterial).
|
||||
const SUPPLY_COLOR = '#d4825a'
|
||||
const RETURN_COLOR = '#5a8ad4'
|
||||
|
||||
const RADIAL_SEGMENTS = 24
|
||||
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
/**
|
||||
* Area-equivalent round diameter (inches) for a rect cross-section —
|
||||
* what a rect trunk advertises on its ports so round fittings / branches
|
||||
* mate at a sensible size.
|
||||
*/
|
||||
export function equivalentDiameterIn(widthIn: number, heightIn: number): number {
|
||||
return 2 * Math.sqrt((widthIn * heightIn) / Math.PI)
|
||||
}
|
||||
|
||||
/**
|
||||
* Area-equivalent round diameter (inches) for a flat-oval cross-section:
|
||||
* a rectangle of (width − height) × height plus the two semicircular caps.
|
||||
*/
|
||||
export function ovalEquivalentDiameterIn(widthIn: number, heightIn: number): number {
|
||||
const minor = Math.min(widthIn, heightIn)
|
||||
const major = Math.max(widthIn, heightIn)
|
||||
const area = (major - minor) * minor + Math.PI * (minor / 2) ** 2
|
||||
return 2 * Math.sqrt(area / Math.PI)
|
||||
}
|
||||
|
||||
/** The diameter (inches) a duct segment presents at its ports. */
|
||||
export function ductPortDiameterIn(node: {
|
||||
shape?: 'round' | 'rect' | 'oval'
|
||||
diameter: number
|
||||
width?: number
|
||||
height?: number
|
||||
}): number {
|
||||
if (node.shape === 'rect' && node.width && node.height) {
|
||||
return equivalentDiameterIn(node.width, node.height)
|
||||
}
|
||||
if (node.shape === 'oval' && node.width && node.height) {
|
||||
return ovalEquivalentDiameterIn(node.width, node.height)
|
||||
}
|
||||
return node.diameter
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-section axes for a rect run along `dir`, rolled `roll` radians
|
||||
* about the run direction. At roll 0: width is the horizontal axis
|
||||
* (UP × dir) and height the vertical one — vertical runs, where that
|
||||
* cross product degenerates, fall back to world X/Z. `roll` rotates the
|
||||
* pair in the plane perpendicular to `dir`, letting a riser carry the
|
||||
* orientation of the run it turned off instead of the bare fallback.
|
||||
*/
|
||||
export function rectSectionAxes(dir: Vector3, roll = 0): { width: Vector3; height: Vector3 } {
|
||||
const d = dir.clone().normalize()
|
||||
const xBase = new Vector3().crossVectors(UP, d)
|
||||
if (xBase.lengthSq() < 1e-8) xBase.set(1, 0, 0)
|
||||
xBase.normalize()
|
||||
const zBase = new Vector3().crossVectors(xBase, d)
|
||||
const c = Math.cos(roll)
|
||||
const s = Math.sin(roll)
|
||||
const width = xBase.clone().multiplyScalar(c).addScaledVector(zBase, s)
|
||||
const height = xBase.clone().multiplyScalar(-s).addScaledVector(zBase, c)
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll (radians) that keeps a rect cross-section continuous across an
|
||||
* elbow: the dimension lying along the joint's hinge — the bend-plane
|
||||
* normal `portDir × newDir`, perpendicular to both legs — must stay on
|
||||
* the same physical face on the new run as on the source run. Returns 0
|
||||
* for an in-plane (degenerate-normal) joint, so horizontal turns keep
|
||||
* the natural width-horizontal orientation.
|
||||
*/
|
||||
export function rollToContinueAcrossElbow(
|
||||
sourceDir: Vector3,
|
||||
sourceRoll: number,
|
||||
portDir: Vector3,
|
||||
newDir: Vector3,
|
||||
): number {
|
||||
const n = new Vector3().crossVectors(portDir, newDir)
|
||||
if (n.lengthSq() < 1e-8) return 0
|
||||
n.normalize()
|
||||
const src = rectSectionAxes(sourceDir, sourceRoll)
|
||||
const carriesWidth = Math.abs(src.width.dot(n)) >= Math.abs(src.height.dot(n))
|
||||
const d = newDir.clone().normalize()
|
||||
const xBase = new Vector3().crossVectors(UP, d)
|
||||
if (xBase.lengthSq() < 1e-8) xBase.set(1, 0, 0)
|
||||
xBase.normalize()
|
||||
const zBase = new Vector3().crossVectors(xBase, d)
|
||||
// Place the hinge-aligned face on the same axis the source carries it.
|
||||
return carriesWidth
|
||||
? Math.atan2(n.dot(zBase), n.dot(xBase))
|
||||
: Math.atan2(-n.dot(xBase), n.dot(zBase))
|
||||
}
|
||||
|
||||
/**
|
||||
* Rect box spanning `start`→`end`. Orientation comes from `rectSectionAxes`
|
||||
* (width horizontal, height vertical by default; `roll` reorients a riser
|
||||
* to stay continuous through its elbow). Quaternion from an explicit basis
|
||||
* — the minimal-rotation `setFromUnitVectors` used for cylinders would roll
|
||||
* the cross-section on axis-aligned runs.
|
||||
*/
|
||||
export function buildRectSection(
|
||||
start: Vector3,
|
||||
end: Vector3,
|
||||
widthM: number,
|
||||
heightM: number,
|
||||
material: MeshStandardMaterial,
|
||||
name: string,
|
||||
roll = 0,
|
||||
): Mesh | null {
|
||||
const dir = new Vector3().subVectors(end, start)
|
||||
const length = dir.length()
|
||||
if (length < 1e-6) return null
|
||||
dir.normalize()
|
||||
|
||||
const { width: x, height: z } = rectSectionAxes(dir, roll)
|
||||
|
||||
const geom = new BoxGeometry(widthM, length, heightM)
|
||||
const mesh = new Mesh(geom, material)
|
||||
mesh.name = name
|
||||
mesh.position.copy(start).addScaledVector(dir, length / 2)
|
||||
mesh.quaternion.copy(new Quaternion().setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z)))
|
||||
return mesh
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat-oval (stadium) profile in the XY plane: width along X, height
|
||||
* along Y, flat top/bottom joined by semicircular end caps of the height.
|
||||
* Degenerates to a circle when width ≤ height.
|
||||
*/
|
||||
function stadiumShape(widthM: number, heightM: number): Shape {
|
||||
const r = Math.min(widthM, heightM) / 2
|
||||
const straight = Math.max(0, widthM - heightM) / 2
|
||||
const shape = new Shape()
|
||||
shape.absarc(straight, 0, r, -Math.PI / 2, Math.PI / 2, false)
|
||||
shape.absarc(-straight, 0, r, Math.PI / 2, (3 * Math.PI) / 2, false)
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered flat-oval prism with the same local axes as the rect box
|
||||
* (X = width, Y = run length, Z = height), so sections and previews
|
||||
* orient it with the `rectSectionAxes` basis.
|
||||
*/
|
||||
export function createOvalSectionGeometry(
|
||||
widthM: number,
|
||||
heightM: number,
|
||||
lengthM: number,
|
||||
): ExtrudeGeometry {
|
||||
const geom = new ExtrudeGeometry(stadiumShape(widthM, heightM), {
|
||||
depth: lengthM,
|
||||
bevelEnabled: false,
|
||||
curveSegments: RADIAL_SEGMENTS / 2,
|
||||
})
|
||||
geom.translate(0, 0, -lengthM / 2)
|
||||
geom.rotateX(-Math.PI / 2)
|
||||
return geom
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat-oval section spanning `start`→`end` — the oval counterpart of
|
||||
* `buildRectSection`, sharing its orientation basis and roll semantics.
|
||||
*/
|
||||
export function buildOvalSection(
|
||||
start: Vector3,
|
||||
end: Vector3,
|
||||
widthM: number,
|
||||
heightM: number,
|
||||
material: MeshStandardMaterial,
|
||||
name: string,
|
||||
roll = 0,
|
||||
): Mesh | null {
|
||||
const dir = new Vector3().subVectors(end, start)
|
||||
const length = dir.length()
|
||||
if (length < 1e-6) return null
|
||||
dir.normalize()
|
||||
|
||||
const { width: x, height: z } = rectSectionAxes(dir, roll)
|
||||
|
||||
const mesh = new Mesh(createOvalSectionGeometry(widthM, heightM, length), material)
|
||||
mesh.name = name
|
||||
mesh.position.copy(start).addScaledVector(dir, length / 2)
|
||||
mesh.quaternion.copy(new Quaternion().setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z)))
|
||||
return mesh
|
||||
}
|
||||
|
||||
/**
|
||||
* Cylinder spanning `start`→`end` at `radius`. Shared by the segment and
|
||||
* fitting builders — fittings are just short sections + a junction.
|
||||
*/
|
||||
export function buildSection(
|
||||
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()
|
||||
|
||||
// Capped, front-side-only — ducts should read as solid metal tubes,
|
||||
// not hollow open-ended shells.
|
||||
const geom = new CylinderGeometry(radius, radius, length, RADIAL_SEGMENTS, 1, false)
|
||||
const mesh = new Mesh(geom, material)
|
||||
mesh.name = name
|
||||
mesh.position.copy(start).addScaledVector(dir, length / 2)
|
||||
mesh.quaternion.setFromUnitVectors(UP, dir)
|
||||
return mesh
|
||||
}
|
||||
|
||||
/**
|
||||
* Helical ridge wound around the cylinder spanning `start`→`end` at the
|
||||
* given `pitch` (meters of run per turn) and `ridge` tube radius. The
|
||||
* ridge sits centered on the body surface, so half its thickness reads
|
||||
* as raised. Two construction details share this: the spiral duct's
|
||||
* lock seam (long pitch, thin ridge) and the flex duct's wire helix
|
||||
* (tight pitch, fat ridge → corrugated look).
|
||||
*/
|
||||
function buildHelixRidge(
|
||||
start: Vector3,
|
||||
end: Vector3,
|
||||
radius: number,
|
||||
pitch: number,
|
||||
ridge: 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 turns = length / pitch
|
||||
const { width: u, height: v } = rectSectionAxes(dir)
|
||||
const samples = Math.min(4096, Math.max(8, Math.ceil(turns * 12)))
|
||||
const pts: Vector3[] = []
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const t = i / samples
|
||||
const theta = 2 * Math.PI * turns * t
|
||||
pts.push(
|
||||
start
|
||||
.clone()
|
||||
.addScaledVector(dir, t * length)
|
||||
.addScaledVector(u, radius * Math.cos(theta))
|
||||
.addScaledVector(v, radius * Math.sin(theta)),
|
||||
)
|
||||
}
|
||||
const geom = new TubeGeometry(new CatmullRomCurve3(pts), samples, ridge, 6, false)
|
||||
const mesh = new Mesh(geom, material)
|
||||
mesh.name = name
|
||||
return mesh
|
||||
}
|
||||
|
||||
/**
|
||||
* Helix parameters for a construction material's body detail, or null
|
||||
* for materials with a smooth body. Spiral: the machine seam keeps a
|
||||
* roughly constant helix angle, so pitch scales with the diameter.
|
||||
* Flex: the wire helix is tight and reads as corrugation; its pitch
|
||||
* also follows the diameter but is clamped much lower.
|
||||
*/
|
||||
function helixRidgeFor(
|
||||
ductMaterial: DuctAppearance['ductMaterial'],
|
||||
radius: number,
|
||||
): { pitch: number; ridge: number; color: string } | null {
|
||||
if (ductMaterial === 'spiral') {
|
||||
return {
|
||||
pitch: Math.min(0.3, Math.max(0.08, radius * 1.2)),
|
||||
ridge: Math.min(0.006, Math.max(0.002, radius * 0.06)),
|
||||
color: '#9b9b9b',
|
||||
}
|
||||
}
|
||||
if (ductMaterial === 'flex') {
|
||||
return {
|
||||
pitch: Math.min(0.06, Math.max(0.025, radius * 0.5)),
|
||||
ridge: Math.min(0.009, Math.max(0.004, radius * 0.12)),
|
||||
color: '#737373',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type DuctAppearance = {
|
||||
ductMaterial: 'sheet-metal' | 'spiral' | 'flex' | 'duct-board'
|
||||
system: 'supply' | 'return'
|
||||
}
|
||||
|
||||
function getSystemTint(node: DuctAppearance): string {
|
||||
return node.system === 'supply' ? SUPPLY_COLOR : RETURN_COLOR
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard duct body material — a plain white matte finish so runs and
|
||||
* fittings read like walls / other building elements rather than tinted
|
||||
* metal. Shared with the fitting builder so connected runs and junctions
|
||||
* look like one piece.
|
||||
*/
|
||||
export function createDuctMaterial(_node: DuctAppearance): MeshStandardMaterial {
|
||||
return new MeshStandardMaterial({
|
||||
color: '#ffffff',
|
||||
metalness: 0,
|
||||
roughness: 0.7,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure geometry builder for a round duct segment polyline.
|
||||
*
|
||||
* Strategy:
|
||||
* - For every consecutive pair of path points, build a cylinder of the
|
||||
* duct's inner diameter.
|
||||
* - Drop a sphere of the same radius at every interior joint to cap the
|
||||
* corner smoothly (no mitering yet — fittings come in a later slice).
|
||||
* - When insulation is non-zero, repeat the same pattern at a larger
|
||||
* radius using a translucent shell material.
|
||||
*
|
||||
* All children are returned in level-local meters; the framework's
|
||||
* `<ParametricNodeRenderer>` handles the node-level transform (currently
|
||||
* identity since the schema has no position field — the path itself is
|
||||
* absolute within the level).
|
||||
*/
|
||||
export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
||||
const group = new Group()
|
||||
if (node.path.length < 2) return group
|
||||
|
||||
const isRect = node.shape === 'rect'
|
||||
const isOval = node.shape === 'oval'
|
||||
const radius = (node.diameter * INCHES_TO_METERS) / 2
|
||||
const widthM = node.width * INCHES_TO_METERS
|
||||
const heightM = node.height * INCHES_TO_METERS
|
||||
const ductMaterial = createDuctMaterial(node)
|
||||
|
||||
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
|
||||
|
||||
const addRun = (
|
||||
half: number,
|
||||
rectW: number,
|
||||
rectH: number,
|
||||
material: MeshStandardMaterial,
|
||||
namePrefix: string,
|
||||
endInsetM = 0,
|
||||
) => {
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
// Loop bounds + min(2) on the schema guarantee both points exist.
|
||||
let a = points[i] as Vector3
|
||||
let b = points[i + 1] as Vector3
|
||||
// Pull the run's open ends in so this shell's end faces never sit
|
||||
// coplanar with the duct's own end caps (z-fighting). Clamped so
|
||||
// a short section can't invert.
|
||||
if (endInsetM > 0) {
|
||||
const dir = new Vector3().subVectors(b, a)
|
||||
const length = dir.length()
|
||||
if (length < 1e-6) continue
|
||||
dir.divideScalar(length)
|
||||
const inset = Math.min(endInsetM, length * 0.25)
|
||||
if (i === 0) a = a.clone().addScaledVector(dir, inset)
|
||||
if (i === points.length - 2) b = b.clone().addScaledVector(dir, -inset)
|
||||
}
|
||||
const mesh = isRect
|
||||
? buildRectSection(a, b, rectW, rectH, material, `${namePrefix}-section-${i}`, node.roll)
|
||||
: isOval
|
||||
? buildOvalSection(a, b, rectW, rectH, material, `${namePrefix}-section-${i}`, node.roll)
|
||||
: buildSection(a, b, half, material, `${namePrefix}-section-${i}`)
|
||||
if (mesh) group.add(mesh)
|
||||
}
|
||||
// Joint caps at interior points only (skip first and last — they're
|
||||
// open ends; equipment / terminal / fitting collars cap them). Rect
|
||||
// joints are cubes spanning the cross-section (oval joints the same
|
||||
// prism in stadium profile); round joints spheres.
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const joint = isRect
|
||||
? new Mesh(new BoxGeometry(rectW, rectH, rectW), material)
|
||||
: isOval
|
||||
? new Mesh(createOvalSectionGeometry(rectW, rectH, rectW), material)
|
||||
: new Mesh(new SphereGeometry(half, RADIAL_SEGMENTS, 12), material)
|
||||
joint.name = `${namePrefix}-joint-${i}`
|
||||
joint.position.copy(points[i] as Vector3)
|
||||
group.add(joint)
|
||||
}
|
||||
}
|
||||
|
||||
addRun(radius, widthM, heightM, ductMaterial, 'duct')
|
||||
|
||||
// Construction body detail: spiral winds its lock seam, flex its wire
|
||||
// helix (tight pitch — reads as corrugation) over each round section.
|
||||
// These are round-body details, so rect / oval runs render smooth.
|
||||
const helix =
|
||||
node.shape === 'round' && node.seamDetail ? helixRidgeFor(node.ductMaterial, radius) : null
|
||||
if (helix) {
|
||||
const ridgeMaterial = new MeshStandardMaterial({
|
||||
color: helix.color,
|
||||
metalness: node.ductMaterial === 'flex' ? 0.1 : 0.7,
|
||||
roughness: node.ductMaterial === 'flex' ? 0.85 : 0.35,
|
||||
emissive: getSystemTint(node),
|
||||
emissiveIntensity: 0.08,
|
||||
})
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const seam = buildHelixRidge(
|
||||
points[i] as Vector3,
|
||||
points[i + 1] as Vector3,
|
||||
radius,
|
||||
helix.pitch,
|
||||
helix.ridge,
|
||||
ridgeMaterial,
|
||||
`duct-seam-${i}`,
|
||||
)
|
||||
if (seam) group.add(seam)
|
||||
}
|
||||
}
|
||||
|
||||
const insulationThickness = node.insulated ? pickInsulationThickness(node.insulationR) : 0
|
||||
if (insulationThickness > 0) {
|
||||
const insulationMaterial = new MeshStandardMaterial({
|
||||
color: '#f0e4c8',
|
||||
roughness: 1,
|
||||
metalness: 0,
|
||||
transparent: true,
|
||||
opacity: 0.25,
|
||||
})
|
||||
addRun(
|
||||
radius + insulationThickness,
|
||||
widthM + insulationThickness * 2,
|
||||
heightM + insulationThickness * 2,
|
||||
insulationMaterial,
|
||||
'duct-insulation',
|
||||
0.01,
|
||||
)
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ductSegmentDefinition } from './definition'
|
||||
export { buildDuctSegmentGeometry } from './geometry'
|
||||
export { DuctSegmentNode } from './schema'
|
||||
@@ -0,0 +1,330 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
DuctSegmentNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
DragBoundingBox,
|
||||
EDITOR_LAYER,
|
||||
markToolCancelConsumed,
|
||||
stripPlacementMetadataFlags,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Matrix4, Vector3 } from 'three'
|
||||
import {
|
||||
type Aabb2D,
|
||||
collectGhostAlignmentCandidates,
|
||||
resolveGhostAlignment,
|
||||
} from '../shared/ghost-alignment'
|
||||
import { rectSectionAxes } from './geometry'
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
|
||||
const GHOST_COLOR = '#818cf8'
|
||||
const GHOST_OPACITY = 0.5
|
||||
const IN_TO_M = 0.0254
|
||||
|
||||
/** Snap a coordinate to the editor's live grid step. */
|
||||
function snapToGridStep(value: number): number {
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
function pathCenterXZ(path: readonly Vec3[]): [number, number] {
|
||||
let x = 0
|
||||
let z = 0
|
||||
for (const p of path) {
|
||||
x += p[0]
|
||||
z += p[2]
|
||||
}
|
||||
const n = path.length || 1
|
||||
return [x / n, z / n]
|
||||
}
|
||||
|
||||
/** Half the run's cross-section (meters) — the box / footprint padding. */
|
||||
function runRadiusM(duct: DuctSegmentNode): number {
|
||||
if (duct.shape === 'round') return (duct.diameter * IN_TO_M) / 2
|
||||
return (Math.max(duct.width, duct.height) * IN_TO_M) / 2
|
||||
}
|
||||
|
||||
/** The run's vertical box extent (meters). */
|
||||
function runHeightM(duct: DuctSegmentNode): number {
|
||||
return (duct.shape === 'round' ? duct.diameter : duct.height) * IN_TO_M
|
||||
}
|
||||
|
||||
/** XZ bounds of a path padded by the run's radius. */
|
||||
function pathAabb(path: readonly Vec3[], r: number): Aabb2D {
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minZ = Number.POSITIVE_INFINITY
|
||||
let maxZ = Number.NEGATIVE_INFINITY
|
||||
for (const p of path) {
|
||||
if (p[0] < minX) minX = p[0]
|
||||
if (p[0] > maxX) maxX = p[0]
|
||||
if (p[2] < minZ) minZ = p[2]
|
||||
if (p[2] > maxZ) maxZ = p[2]
|
||||
}
|
||||
return { minX: minX - r, maxX: maxX + r, minZ: minZ - r, maxZ: maxZ + r }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ghost-preview duplicate / move tool for duct runs.
|
||||
*
|
||||
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
|
||||
* inserted into the scene until the commit click. A translucent ghost of
|
||||
* the run (cylinders / boxes matching its profile) rides the cursor inside
|
||||
* a footprint bounding box — the same affordance other items get — and
|
||||
* Figma-style alignment guides snap the box's edges to nearby geometry. The
|
||||
* next grid click calls `createNode`; Esc discards.
|
||||
*
|
||||
* **Move** (existing run): the real node is hidden while the same ghost +
|
||||
* box tracks the cursor; the commit click writes the translated `path` and
|
||||
* reveals it, Esc reveals it unchanged.
|
||||
*
|
||||
* Wired via `def.affordanceTools.move`.
|
||||
*/
|
||||
export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
const duct = node as DuctSegmentNode
|
||||
const originalPathRef = useRef<Vec3[]>(duct.path.map((p) => [...p] as Vec3))
|
||||
|
||||
const isNew =
|
||||
typeof node.metadata === 'object' &&
|
||||
node.metadata !== null &&
|
||||
!Array.isArray(node.metadata) &&
|
||||
(node.metadata as Record<string, unknown>).isNew === true
|
||||
|
||||
const [previewPath, setPreviewPath] = useState<Vec3[]>(originalPathRef.current)
|
||||
const previewPathRef = useRef<Vec3[]>(originalPathRef.current)
|
||||
const hasMovedRef = useRef(false)
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const prevSnapRef = useRef<[number, number] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = node.id as AnyNodeId
|
||||
const originalPath = originalPathRef.current
|
||||
const [centerX, centerZ] = pathCenterXZ(originalPath)
|
||||
const r = runRadiusM(duct)
|
||||
const baseAabb = pathAabb(originalPath, r)
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let committed = false
|
||||
|
||||
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
|
||||
useScene.getState().nodes,
|
||||
nodeId,
|
||||
useViewer.getState().selection.levelId ?? node.parentId,
|
||||
)
|
||||
|
||||
// Moving an existing run: hide its 3D MESH imperatively (NOT the store
|
||||
// `visible` flag — the 2D floor plan skips `visible:false` nodes, so a
|
||||
// store hide makes the run vanish in 2D / split view). The ghost stands
|
||||
// in until commit; the real mesh is restored on cancel / unmount.
|
||||
const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId]
|
||||
const setMeshHidden = (hidden: boolean) => {
|
||||
const obj = sceneRegistry.nodes.get(nodeId)
|
||||
if (obj) obj.visible = !hidden
|
||||
}
|
||||
if (existedAtStart) setMeshHidden(true)
|
||||
|
||||
const setPreview = (path: Vec3[]) => {
|
||||
previewPathRef.current = path
|
||||
setPreviewPath(path)
|
||||
}
|
||||
|
||||
const onMove = (event: GridEvent) => {
|
||||
const bypass = event.nativeEvent?.shiftKey === true
|
||||
const snap = bypass ? (v: number) => v : snapToGridStep
|
||||
let dx = snap(event.localPosition[0] - centerX)
|
||||
let dz = snap(event.localPosition[2] - centerZ)
|
||||
|
||||
// Figma-style alignment: snap the run's footprint box edges onto
|
||||
// nearby geometry and publish the guides (Alt / Shift bypass).
|
||||
if (!bypass) {
|
||||
const proposed: Aabb2D = {
|
||||
minX: baseAabb.minX + dx,
|
||||
maxX: baseAabb.maxX + dx,
|
||||
minZ: baseAabb.minZ + dz,
|
||||
maxZ: baseAabb.maxZ + dz,
|
||||
}
|
||||
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
||||
dx += sdx
|
||||
dz += sdz
|
||||
useAlignmentGuides.getState().set(guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const cur: [number, number] = [centerX + dx, centerZ + dz]
|
||||
if (
|
||||
!bypass &&
|
||||
(!prevSnapRef.current ||
|
||||
prevSnapRef.current[0] !== cur[0] ||
|
||||
prevSnapRef.current[1] !== cur[1])
|
||||
) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
prevSnapRef.current = cur
|
||||
hasMovedRef.current = true
|
||||
setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3))
|
||||
}
|
||||
|
||||
const commit = (event: GridEvent) => {
|
||||
if (committed) return
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
if (!hasMovedRef.current) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
const finalPath = previewPathRef.current
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
let selectId = nodeId
|
||||
if (isNew && !useScene.getState().nodes[nodeId]) {
|
||||
const created = DuctSegmentNode.parse({
|
||||
...(node as Record<string, unknown>),
|
||||
path: finalPath,
|
||||
metadata: stripPlacementMetadataFlags(node.metadata),
|
||||
visible: true,
|
||||
})
|
||||
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
|
||||
selectId = created.id as AnyNodeId
|
||||
} else {
|
||||
useScene.getState().updateNode(nodeId, { path: finalPath } as Partial<AnyNode>)
|
||||
useScene.getState().markDirty(nodeId)
|
||||
}
|
||||
useScene.temporal.getState().pause()
|
||||
setMeshHidden(false)
|
||||
|
||||
useAlignmentGuides.getState().clear()
|
||||
triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [selectId] })
|
||||
useEditor.getState().setMovingNodeOrigin('3d')
|
||||
useEditor.getState().setMovingNode(null)
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (existedAtStart) {
|
||||
setMeshHidden(false)
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
}
|
||||
useAlignmentGuides.getState().clear()
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
useEditor.getState().setMovingNodeOrigin('3d')
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onMove)
|
||||
emitter.on('grid:click', commit)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
emitter.off('grid:click', commit)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (existedAtStart) setMeshHidden(false)
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [duct, isNew, node])
|
||||
|
||||
const segments: Array<{ a: Vec3; b: Vec3 }> = []
|
||||
for (let i = 0; i < previewPath.length - 1; i++) {
|
||||
segments.push({ a: previewPath[i]!, b: previewPath[i + 1]! })
|
||||
}
|
||||
|
||||
// Footprint box spanning the whole run (axis-aligned), drawn around the
|
||||
// ghost the same way items get one. Recomputed from the live preview path.
|
||||
const r = runRadiusM(duct)
|
||||
const box = pathAabb(previewPath, r)
|
||||
const boxY = previewPath[0]?.[1] ?? 0
|
||||
|
||||
return (
|
||||
<group>
|
||||
{segments.map((seg, i) => (
|
||||
<GhostSegment a={seg.a} b={seg.b} duct={duct} key={`ghost-${i}`} />
|
||||
))}
|
||||
<DragBoundingBox
|
||||
centerY={0}
|
||||
nodeId={node.id}
|
||||
position={[(box.minX + box.maxX) / 2, boxY, (box.minZ + box.maxZ) / 2]}
|
||||
size={[box.maxX - box.minX, runHeightM(duct), box.maxZ - box.minZ]}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
/** Translucent stand-in for one duct section — mirrors the draw tool's
|
||||
* `PreviewSegment` so the ghost matches what actually lands. */
|
||||
function GhostSegment({ a, b, duct }: { a: Vec3; b: Vec3; duct: DuctSegmentNode }) {
|
||||
const start = new Vector3(...a)
|
||||
const end = new Vector3(...b)
|
||||
const dir = new Vector3().subVectors(end, start)
|
||||
const length = dir.length()
|
||||
if (length < 1e-4) return null
|
||||
dir.normalize()
|
||||
const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5)
|
||||
|
||||
if (duct.shape !== 'round') {
|
||||
const w = duct.width * IN_TO_M
|
||||
const h = duct.height * IN_TO_M
|
||||
return (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
position={mid.toArray()}
|
||||
ref={(m) => {
|
||||
if (!m) return
|
||||
const { width: x, height: z } = rectSectionAxes(dir, duct.roll)
|
||||
m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))
|
||||
}}
|
||||
>
|
||||
<boxGeometry args={[w, length, h]} />
|
||||
<meshBasicMaterial
|
||||
color={GHOST_COLOR}
|
||||
depthTest={false}
|
||||
opacity={GHOST_OPACITY}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
const radius = (duct.diameter * IN_TO_M) / 2
|
||||
return (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
position={mid.toArray()}
|
||||
ref={(m) => {
|
||||
if (!m) return
|
||||
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
|
||||
}}
|
||||
>
|
||||
<cylinderGeometry args={[radius, radius, length, 24, 1, false]} />
|
||||
<meshBasicMaterial
|
||||
color={GHOST_COLOR}
|
||||
depthTest={false}
|
||||
opacity={GHOST_OPACITY}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
export default MoveDuctSegmentTool
|
||||
@@ -0,0 +1,173 @@
|
||||
import { type DuctFittingNode, type ParametricDescriptor, useScene } from '@pascal-app/core'
|
||||
import { Vector3 } from 'three'
|
||||
import { getDuctFittingPorts } from '../duct-fitting/ports'
|
||||
import { rollToContinueAcrossElbow } from './geometry'
|
||||
import type { DuctSegmentNode } from './schema'
|
||||
|
||||
/** A run endpoint sitting this close to a collar counts as mated. */
|
||||
const MATE_TOL_M = 0.03
|
||||
|
||||
function dist2(a: readonly [number, number, number], b: readonly [number, number, number]): number {
|
||||
const dx = a[0] - b[0]
|
||||
const dy = a[1] - b[1]
|
||||
const dz = a[2] - b[2]
|
||||
return dx * dx + dy * dy + dz * dz
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-section roll that keeps this run continuous through a fitting
|
||||
* mated at either endpoint — the same continuity the draw tool computes
|
||||
* for freshly drawn risers (`rollToContinueAcrossElbow`), recovered here
|
||||
* for runs whose shape is flipped to rect AFTER they were drawn. Without
|
||||
* it a riser falls back to the world-axis orientation and its profile
|
||||
* lands 90° off the elbow it rises from. Returns null when no fitting is
|
||||
* mated (roll 0 — the natural horizontal orientation — is correct).
|
||||
*/
|
||||
function rollFromMatedFitting(duct: DuctSegmentNode): number | null {
|
||||
if (duct.path.length < 2) return null
|
||||
const first = duct.path[0]!
|
||||
const last = duct.path[duct.path.length - 1]!
|
||||
const ends = [
|
||||
{ point: first, away: duct.path[1]! },
|
||||
{ point: last, away: duct.path[duct.path.length - 2]! },
|
||||
]
|
||||
const tol2 = MATE_TOL_M * MATE_TOL_M
|
||||
for (const node of Object.values(useScene.getState().nodes)) {
|
||||
if (node.type !== 'duct-fitting') continue
|
||||
const fitting = node as DuctFittingNode
|
||||
if (fitting.fittingType === 'reducer') continue
|
||||
const ports = getDuctFittingPorts(fitting)
|
||||
for (const end of ends) {
|
||||
const mated = ports.find((p) => dist2(end.point, p.position) <= tol2)
|
||||
if (!mated) continue
|
||||
// The leg on the far side of the junction is the source the
|
||||
// profile must stay continuous with: an elbow's other run leg, or
|
||||
// the tee's run when this duct is the branch.
|
||||
const source = ports.find((p) => p.id !== mated.id && p.id !== 'branch')
|
||||
if (!source) continue
|
||||
const srcDuct = Object.values(useScene.getState().nodes).find(
|
||||
(n) =>
|
||||
n.type === 'duct-segment' &&
|
||||
n.id !== duct.id &&
|
||||
((n as DuctSegmentNode).path.length >= 2
|
||||
? dist2((n as DuctSegmentNode).path[0]!, source.position) <= tol2 ||
|
||||
dist2(
|
||||
(n as DuctSegmentNode).path[(n as DuctSegmentNode).path.length - 1]!,
|
||||
source.position,
|
||||
) <= tol2
|
||||
: false),
|
||||
) as DuctSegmentNode | undefined
|
||||
const newDir = new Vector3(
|
||||
end.away[0] - end.point[0],
|
||||
end.away[1] - end.point[1],
|
||||
end.away[2] - end.point[2],
|
||||
)
|
||||
if (newDir.lengthSq() < 1e-10) continue
|
||||
newDir.normalize()
|
||||
// Only steep runs are ambiguous (world-axis fallback); a
|
||||
// horizontal run's roll-0 orientation is already canonical, and
|
||||
// re-deriving it from a possibly-stale riser roll would corrupt it.
|
||||
if (Math.abs(newDir.y) < Math.SQRT1_2) continue
|
||||
const srcRoll = srcDuct && srcDuct.shape !== 'round' ? srcDuct.roll : 0
|
||||
const srcDir = new Vector3(...source.direction)
|
||||
return rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const ductSegmentParametrics: ParametricDescriptor<DuctSegmentNode> = {
|
||||
// Flipping a drawn run to rect / oval recovers the cross-section roll
|
||||
// the draw tool would have computed — risers re-orient to stay
|
||||
// continuous through the elbow they turn off instead of snapping to
|
||||
// the world-axis fallback. Spiral is a round-only construction, so a
|
||||
// non-round run can never hold it: leaving round (or picking spiral on
|
||||
// a rect / oval run) falls back to plain sheet metal.
|
||||
derive: (next, patch) => {
|
||||
const out: Partial<DuctSegmentNode> = {}
|
||||
if (next.ductMaterial === 'spiral' && next.shape !== 'round') {
|
||||
out.ductMaterial = 'sheet-metal'
|
||||
}
|
||||
if ('shape' in patch && next.shape !== 'round') {
|
||||
const roll = rollFromMatedFitting(next)
|
||||
if (roll !== null) out.roll = roll
|
||||
}
|
||||
return out
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
label: 'Air',
|
||||
fields: [
|
||||
{
|
||||
key: 'system',
|
||||
kind: 'enum',
|
||||
options: ['supply', 'return'],
|
||||
display: 'segmented',
|
||||
},
|
||||
{
|
||||
key: 'shape',
|
||||
kind: 'enum',
|
||||
options: ['round', 'rect', 'oval'],
|
||||
display: 'segmented',
|
||||
},
|
||||
{
|
||||
key: 'diameter',
|
||||
kind: 'number',
|
||||
unit: 'in',
|
||||
min: 4,
|
||||
max: 24,
|
||||
step: 1,
|
||||
visibleIf: (n) => n.shape === 'round',
|
||||
},
|
||||
{
|
||||
key: 'width',
|
||||
kind: 'number',
|
||||
unit: 'in',
|
||||
min: 4,
|
||||
max: 60,
|
||||
step: 1,
|
||||
visibleIf: (n) => n.shape !== 'round',
|
||||
},
|
||||
{
|
||||
key: 'height',
|
||||
kind: 'number',
|
||||
unit: 'in',
|
||||
min: 3,
|
||||
max: 40,
|
||||
step: 1,
|
||||
visibleIf: (n) => n.shape !== 'round',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Construction',
|
||||
fields: [
|
||||
{
|
||||
key: 'ductMaterial',
|
||||
kind: 'enum',
|
||||
options: ['sheet-metal', 'spiral', 'flex', 'duct-board'],
|
||||
},
|
||||
{
|
||||
key: 'seamDetail',
|
||||
kind: 'boolean',
|
||||
// Only meaningful where a body detail exists: round spiral
|
||||
// (lock seam) and round flex (wire corrugation).
|
||||
visibleIf: (n) =>
|
||||
n.shape === 'round' && (n.ductMaterial === 'spiral' || n.ductMaterial === 'flex'),
|
||||
},
|
||||
{
|
||||
key: 'insulated',
|
||||
kind: 'boolean',
|
||||
},
|
||||
{
|
||||
key: 'insulationR',
|
||||
kind: 'number',
|
||||
min: 0,
|
||||
max: 8,
|
||||
step: 0.5,
|
||||
visibleIf: (n) => n.insulated,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DuctSegmentNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,371 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
analyzePortConnectivity,
|
||||
type DuctSegmentNode,
|
||||
type PortConnectivity,
|
||||
pauseSceneHistory,
|
||||
resolveConnectivityUpdates,
|
||||
resumeSceneHistory,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
||||
import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports'
|
||||
|
||||
/** Handle pip radius (meters). */
|
||||
const HANDLE_RADIUS = 0.09
|
||||
/** Port-snap radius for dragged run endpoints (meters, XZ). */
|
||||
const PORT_SNAP_RADIUS_M = 0.4
|
||||
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
/**
|
||||
* Selection-time editing for committed duct runs: one draggable handle
|
||||
* per path point.
|
||||
*
|
||||
* Handles are PORTALED into the duct's registered scene group so they
|
||||
* share its exact frame — path coords are node-local, and the level /
|
||||
* building transform above the group applies to the handles for free.
|
||||
* Drag raycasts run in world space and convert hits back into the
|
||||
* group's local frame before writing the path.
|
||||
*
|
||||
* Drag model: by default the point is CONSTRAINED to the axis the
|
||||
* segment was drawn along — a horizontal duct's endpoint slides along
|
||||
* its own length, a riser's endpoint slides vertically. Holding **Alt**
|
||||
* releases the constraint into free horizontal-plane movement (at the
|
||||
* point's height); in free mode dragged run endpoints (first / last
|
||||
* point) also snap onto nearby typed ports so a loose run can be mated
|
||||
* onto a fitting after the fact. Holding **Shift** bypasses grid
|
||||
* snapping in either mode for a perfectly smooth precision drag.
|
||||
*
|
||||
* History does the single-undo dance: paused during the drag (the live
|
||||
* `updateNode` ticks are untracked), then on release the path is
|
||||
* reverted, history resumed, and the final path applied as one tracked
|
||||
* change.
|
||||
*/
|
||||
const DuctSegmentSelectionAffordance = () => {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const duct = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return null
|
||||
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
||||
return node?.type === 'duct-segment' ? (node as DuctSegmentNode) : null
|
||||
})
|
||||
|
||||
// Portal target: the duct's registered group. Resolved with a rAF
|
||||
// retry because registration happens on the renderer's mount, which
|
||||
// can land a frame after selection.
|
||||
const ductId = duct?.id ?? null
|
||||
const [target, setTarget] = useState<Object3D | null>(null)
|
||||
useEffect(() => {
|
||||
if (!ductId) {
|
||||
setTarget(null)
|
||||
return
|
||||
}
|
||||
let frameId = 0
|
||||
const resolve = () => {
|
||||
const next = sceneRegistry.nodes.get(ductId as AnyNodeId) ?? null
|
||||
setTarget((cur) => (cur === next ? cur : next))
|
||||
if (!next) frameId = window.requestAnimationFrame(resolve)
|
||||
}
|
||||
resolve()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [ductId])
|
||||
|
||||
if (!duct || !target) return null
|
||||
return createPortal(<DuctPointHandles duct={duct} target={target} />, target, undefined)
|
||||
}
|
||||
|
||||
const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Object3D }) => {
|
||||
const { camera, gl } = useThree()
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
||||
// Set while a drag is live; null otherwise. Holds everything the window
|
||||
// pointer handlers need so they never read stale React state.
|
||||
const dragRef = useRef<{
|
||||
index: number
|
||||
initialPath: Point[]
|
||||
current: Point
|
||||
cleanup: () => void
|
||||
// Connectivity snapshot taken at pointer-down: which fittings / ducts are
|
||||
// mated to this run's endpoints, so they follow as the endpoint moves.
|
||||
connectivity: PortConnectivity | null
|
||||
} | null>(null)
|
||||
|
||||
const makeRay = (clientX: number, clientY: number) => {
|
||||
const rect = gl.domElement.getBoundingClientRect()
|
||||
const ndc = new Vector2(
|
||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
)
|
||||
const raycaster = new Raycaster()
|
||||
raycaster.setFromCamera(ndc, camera)
|
||||
return raycaster.ray
|
||||
}
|
||||
|
||||
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
|
||||
const hit = new Vector3()
|
||||
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed distance along `axisWorld` (unit, through `anchorWorld`) of the
|
||||
* point on that line closest to the cursor ray. Null when the ray runs
|
||||
* (near-)parallel to the axis and the projection is unstable.
|
||||
*/
|
||||
const projectOntoAxis = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
anchorWorld: Vector3,
|
||||
axisWorld: Vector3,
|
||||
): number | null => {
|
||||
const ray = makeRay(clientX, clientY)
|
||||
const w0 = new Vector3().subVectors(ray.origin, anchorWorld)
|
||||
const b = ray.direction.dot(axisWorld)
|
||||
const denom = 1 - b * b
|
||||
if (Math.abs(denom) < 1e-6) return null
|
||||
const d0 = ray.direction.dot(w0)
|
||||
const e0 = axisWorld.dot(w0)
|
||||
return (e0 - b * d0) / denom
|
||||
}
|
||||
|
||||
/** World-space position of a local path point. */
|
||||
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
|
||||
/** Convert a world-space hit back into the duct group's local frame. */
|
||||
const toLocal = (world: Vector3): Point => {
|
||||
const local = target.worldToLocal(world.clone())
|
||||
return [local.x, local.y, local.z]
|
||||
}
|
||||
|
||||
// Follow-updates for fittings / ducts mated to this run's endpoints, given
|
||||
// the run's live path. Endpoints whose position didn't change resolve to a
|
||||
// zero delta, so only the dragged endpoint's partner actually moves.
|
||||
const connectivityUpdatesForPath = (
|
||||
connectivity: PortConnectivity | null,
|
||||
path: Point[],
|
||||
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
|
||||
if (!connectivity) return []
|
||||
const preview = { ...(duct as Record<string, unknown>), path } as AnyNode
|
||||
return resolveConnectivityUpdates(connectivity, preview).filter(
|
||||
(u) => useScene.getState().nodes[u.id],
|
||||
)
|
||||
}
|
||||
|
||||
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation()
|
||||
const initialPath = duct.path.map((p) => [...p] as Point)
|
||||
const startPoint = initialPath[index]!
|
||||
const connectivity = analyzePortConnectivity(duct as AnyNode, useScene.getState().nodes)
|
||||
pauseSceneHistory(useScene)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
document.body.style.cursor = 'grabbing'
|
||||
setDraggingIndex(index)
|
||||
|
||||
const isEndpoint = index === 0 || index === initialPath.length - 1
|
||||
|
||||
// Axis the segment was drawn along, at this point: from the
|
||||
// neighbouring path point toward the dragged one. The default drag
|
||||
// is constrained to this line.
|
||||
const neighbor = initialPath[index === 0 ? 1 : index - 1]!
|
||||
const axisLocal = new Vector3(
|
||||
startPoint[0] - neighbor[0],
|
||||
startPoint[1] - neighbor[1],
|
||||
startPoint[2] - neighbor[2],
|
||||
)
|
||||
if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0)
|
||||
axisLocal.normalize()
|
||||
// World-space anchor + axis, derived once — the constraint line is
|
||||
// fixed for the whole drag regardless of where the point currently is.
|
||||
const anchorWorldStart = toWorld(startPoint)
|
||||
const axisWorld = toWorld([
|
||||
startPoint[0] + axisLocal.x,
|
||||
startPoint[1] + axisLocal.y,
|
||||
startPoint[2] + axisLocal.z,
|
||||
])
|
||||
.sub(anchorWorldStart)
|
||||
.normalize()
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
const current = drag.current
|
||||
// Shift = precision: bypass grid snapping for a perfectly smooth
|
||||
// drag (snap() is a no-op at step 0).
|
||||
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
||||
let next: Point | null = null
|
||||
if (event.altKey) {
|
||||
// Alt = freedom: slide on the horizontal plane at the point's
|
||||
// height. Endpoints can port-snap here to mate onto a fitting.
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current))
|
||||
const hit = intersect(event.clientX, event.clientY, plane)
|
||||
if (hit) {
|
||||
const local = toLocal(hit)
|
||||
next = [snap(local[0], step), current[1], snap(local[2], step)]
|
||||
if (isEndpoint) {
|
||||
const port = findNearestPortXZ(
|
||||
[local[0], current[1], local[2]],
|
||||
collectScenePorts({ excludeNodeId: duct.id, systems: DUCT_PORT_SYSTEMS }),
|
||||
PORT_SNAP_RADIUS_M,
|
||||
)
|
||||
if (port) next = [port.position[0], port.position[1], port.position[2]]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Default: constrained to the axis the segment was drawn along —
|
||||
// slide the point closer / further along its own line.
|
||||
const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld)
|
||||
if (t !== null) {
|
||||
const dist = snap(t, step)
|
||||
next = [
|
||||
startPoint[0] + axisLocal.x * dist,
|
||||
Math.max(0, startPoint[1] + axisLocal.y * dist),
|
||||
startPoint[2] + axisLocal.z * dist,
|
||||
]
|
||||
}
|
||||
}
|
||||
if (!next) return
|
||||
if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return
|
||||
drag.current = next
|
||||
const path = duct.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
|
||||
// Drag the run + any fittings mated to the moved endpoint as one batch.
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([
|
||||
{ id: duct.id as AnyNodeId, data: { path } },
|
||||
...connectivityUpdatesForPath(drag.connectivity, path),
|
||||
])
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
drag.cleanup()
|
||||
dragRef.current = null
|
||||
setDraggingIndex(null)
|
||||
// Single-undo dance: revert (still paused), resume, re-apply the
|
||||
// final path — plus any connected fitting moves — as one tracked batch.
|
||||
const finalPath = drag.initialPath.map((p, i) =>
|
||||
i === drag.index ? drag.current : p,
|
||||
) as Point[]
|
||||
const finalUpdates = connectivityUpdatesForPath(drag.connectivity, finalPath)
|
||||
// Revert the run AND the followers to their pre-drag state while paused
|
||||
// so history captures a clean before→after delta.
|
||||
const revertUpdates = (drag.connectivity?.connections ?? []).flatMap((conn) =>
|
||||
conn.kind === 'rigid-node'
|
||||
? [{ id: conn.nodeId, data: { position: conn.startPosition } as Partial<AnyNode> }]
|
||||
: [{ id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> }],
|
||||
)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([
|
||||
{ id: duct.id as AnyNodeId, data: { path: drag.initialPath } },
|
||||
...revertUpdates.filter((u) => useScene.getState().nodes[u.id]),
|
||||
])
|
||||
resumeSceneHistory(useScene)
|
||||
const moved = finalPath[drag.index]!.some(
|
||||
(v, axis) => v !== drag.initialPath[drag.index]![axis],
|
||||
)
|
||||
if (moved) {
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: duct.id as AnyNodeId, data: { path: finalPath } }, ...finalUpdates])
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
useViewer.getState().setInputDragging(false)
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
dragRef.current = { index, initialPath, current: startPoint, cleanup, connectivity }
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
{duct.path.map((p, i) => {
|
||||
const active = draggingIndex === i
|
||||
const hovered = hoverIndex === i
|
||||
return (
|
||||
<mesh
|
||||
key={`duct-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 &&
|
||||
duct.path[draggingIndex] &&
|
||||
(() => {
|
||||
// Same pill as the draw tool: signed per-axis deltas from the
|
||||
// drag-start position, dominant axis emphasised.
|
||||
const point = duct.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 DuctSegmentSelectionAffordance
|
||||
@@ -0,0 +1,989 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
DuctSegmentNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getLevelHeight,
|
||||
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, Matrix4, Vector3 } from 'three'
|
||||
import { getDuctFittingPorts } from '../duct-fitting/ports'
|
||||
import {
|
||||
planCrossAtRunBody,
|
||||
planElbowAtPort,
|
||||
planElbowRealign,
|
||||
planTeeAtRunBody,
|
||||
} from '../shared/auto-fitting'
|
||||
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
|
||||
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||||
import {
|
||||
collectScenePorts,
|
||||
DUCT_PORT_SYSTEMS,
|
||||
findNearestPortXZ,
|
||||
findNearestRunBodyXZ,
|
||||
findRunBodyCrossingXZ,
|
||||
type RunBodyHit,
|
||||
type ScenePort,
|
||||
} from '../shared/ports'
|
||||
import { ductSegmentDefinition } from './definition'
|
||||
import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
|
||||
|
||||
/**
|
||||
* One-segment-at-a-time placement tool for round duct segments.
|
||||
*
|
||||
* Mouse-driven model:
|
||||
* - **First click** anchors the segment start (port snap joins onto an
|
||||
* existing run / fitting collar).
|
||||
* - **Second click** commits a two-point duct immediately and re-arms
|
||||
* the tool — no polyline accumulation, no finish gesture. Chain runs
|
||||
* by clicking again near the end you just placed (port snap).
|
||||
* - **Auto-elbow**: when either end snapped onto another RUN's open
|
||||
* port at an angle (15–90°, vertical turns included), an elbow
|
||||
* fitting is minted at the joint and the duct pulls back to its
|
||||
* outlet collar — corners get real fittings instead of butt joints.
|
||||
* - **Tee tap**: starting OR ending on the SIDE of an existing run
|
||||
* (centerline snap) splits the trunk, mints a tee at the tap point,
|
||||
* and the branch leaves square from its collar.
|
||||
* - **Cross tap**: drawing a run straight THROUGH the side of an
|
||||
* existing run (interior crossing) splits the trunk, mints a 4-way
|
||||
* cross at the crossing, and the drawn run continues out the far
|
||||
* branch — both fittings inherit the trunk's / branch's profile.
|
||||
* - 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 the lock.
|
||||
* - Hold **Alt** → vertical mode. Cursor XZ locks to the start;
|
||||
* vertical mouse motion drives Y. Click commits the riser segment.
|
||||
* - **[ / ]** step the duct diameter through nominal US sizes; the
|
||||
* ghost preview and the committed node both use it.
|
||||
* - **C** toggles ceiling-level placement: the start point lands at
|
||||
* the level's ceiling height (duct top hugging the ceiling) instead
|
||||
* of the floor. Subsequent points inherit the start's Y as usual.
|
||||
* - Esc clears an anchored start point.
|
||||
*/
|
||||
const PREVIEW_OPACITY = 0.55
|
||||
/**
|
||||
* Nominal US round-duct sizes (inches): 4"–10" in 1" steps, 12"+ in 2"
|
||||
* steps — matches what flex and rigid round actually ship in.
|
||||
*/
|
||||
const DUCT_DIAMETERS_IN = [4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20] as const
|
||||
/** Snap radius (meters) for joining onto an existing duct's start/end. */
|
||||
const ENDPOINT_SNAP_RADIUS_M = 0.5
|
||||
/** Snap radius (meters) for tapping the SIDE of an existing run — a tee
|
||||
* is minted there. Tighter than the port radius so run ends keep
|
||||
* priority near their last stretch. */
|
||||
const BODY_SNAP_RADIUS_M = 0.35
|
||||
/** 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
|
||||
/** Bounds on Alt-driven Y so a wild fling doesn't fly off. */
|
||||
const ALT_Y_MIN_M = -3
|
||||
const ALT_Y_MAX_M = 10
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
function dist2(a: readonly [number, number, number], b: readonly [number, number, number]): number {
|
||||
const dx = a[0] - b[0]
|
||||
const dy = a[1] - b[1]
|
||||
const dz = a[2] - b[2]
|
||||
return dx * dx + dy * dy + dz * dz
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-section roll for a new rect run leaving `port` along `newDir`,
|
||||
* so its profile stays continuous with whatever it joined: a turn
|
||||
* re-derives the roll through the (future) elbow, a straight
|
||||
* continuation inherits the source's roll as-is. Sources: a rect run's
|
||||
* open end, or a rect fitting's open collar (continuity then comes from
|
||||
* the leg on the far side of the junction and the rect run mated
|
||||
* there). Null when the port doesn't carry a rect orientation. Shared
|
||||
* by the ghost preview and the commit so what you see is what lands.
|
||||
*/
|
||||
function continuityRollFrom(port: ScenePort | null, newDir: Vector3): number | null {
|
||||
if (!port) return null
|
||||
const nodes = useScene.getState().nodes
|
||||
const owner = nodes[port.nodeId]
|
||||
let srcDir: Vector3 | null = null
|
||||
let srcRoll = 0
|
||||
if (
|
||||
(owner?.type === 'hvac-equipment' || owner?.type === 'duct-terminal') &&
|
||||
port.shape &&
|
||||
port.shape !== 'round'
|
||||
) {
|
||||
// The collar mesh is built at the canonical `rectSectionAxes(dir, 0)`
|
||||
// basis, so it reads as a source run pointing out along the port with
|
||||
// roll 0 — the new leg rolls to continue that across its turn.
|
||||
srcDir = new Vector3(...port.direction)
|
||||
srcRoll = 0
|
||||
} else if (owner?.type === 'duct-segment' && owner.shape !== 'round') {
|
||||
srcDir = new Vector3(...port.direction)
|
||||
srcRoll = owner.roll
|
||||
} else if (
|
||||
owner?.type === 'duct-fitting' &&
|
||||
owner.shape !== 'round' &&
|
||||
owner.fittingType !== 'reducer' &&
|
||||
owner.fittingType !== 'transition'
|
||||
) {
|
||||
const source = getDuctFittingPorts(owner).find(
|
||||
(p) => p.id !== port.id && p.id !== 'branch' && p.id !== 'branch2',
|
||||
)
|
||||
if (source) {
|
||||
srcDir = new Vector3(...source.direction)
|
||||
const tol2 = 0.03 * 0.03
|
||||
for (const n of Object.values(nodes)) {
|
||||
if (n.type !== 'duct-segment' || n.shape === 'round' || n.path.length < 2) continue
|
||||
const ends = [n.path[0]!, n.path[n.path.length - 1]!]
|
||||
if (ends.some((e) => dist2(e, source.position) <= tol2)) {
|
||||
srcRoll = n.roll
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!srcDir) return null
|
||||
const cross = new Vector3().crossVectors(srcDir, newDir)
|
||||
if (cross.lengthSq() < 1e-8) return srcRoll
|
||||
return rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest typed port — duct run ends, fitting collars, anything whose
|
||||
* kind registers `def.ports` — within snap range of `point` on the XZ
|
||||
* plane. Y is ignored for the distance check (grid events ride the floor
|
||||
* while ports hang at duct height); the snap adopts the port's full 3D
|
||||
* position. The full port is returned so the commit knows what it joined
|
||||
* (auto-elbow insertion needs the port's direction and owner).
|
||||
*/
|
||||
function findNearbyPort(point: [number, number, number]): ScenePort | null {
|
||||
return findNearestPortXZ(
|
||||
point,
|
||||
collectScenePorts({ systems: DUCT_PORT_SYSTEMS }),
|
||||
ENDPOINT_SNAP_RADIUS_M,
|
||||
)
|
||||
}
|
||||
|
||||
function portPoint(port: ScenePort): [number, number, number] {
|
||||
return [port.position[0], port.position[1], port.position[2]]
|
||||
}
|
||||
|
||||
/** Cross-section the tool draws with (and commits onto the node). Oval
|
||||
* never comes from the Q toggle (round ↔ rect) — it enters by joining
|
||||
* an existing oval run / fitting collar and continuing its profile. */
|
||||
type DraftProfile = {
|
||||
shape: 'round' | 'rect' | 'oval'
|
||||
diameter: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile to inherit when the segment start snaps onto `port` — joining
|
||||
* means continuing that thing: a rect trunk end keeps its W×H, a round
|
||||
* run / fitting collar keeps its diameter. Equipment and terminal
|
||||
* collars are round at the port's advertised size.
|
||||
*/
|
||||
function inheritProfile(port: ScenePort): DraftProfile | null {
|
||||
const owner = useScene.getState().nodes[port.nodeId]
|
||||
if (!owner) return null
|
||||
if (owner.type === 'duct-segment' || owner.type === 'duct-fitting') {
|
||||
return {
|
||||
shape: owner.shape,
|
||||
diameter: Math.min(
|
||||
48,
|
||||
Math.max(2, owner.type === 'duct-segment' ? owner.diameter : port.diameter),
|
||||
),
|
||||
width: owner.width,
|
||||
height: owner.height,
|
||||
}
|
||||
}
|
||||
if (owner.type === 'hvac-equipment' || owner.type === 'duct-terminal') {
|
||||
const defaults = ductSegmentDefinition.defaults() as DraftProfile
|
||||
// Adopt the collar's cross-section so the run leaves a rect / oval
|
||||
// plenum as rect / oval (rolled to match in `continuityRollFrom`),
|
||||
// falling back to round at the advertised diameter.
|
||||
if (port.shape && port.shape !== 'round') {
|
||||
return {
|
||||
shape: port.shape,
|
||||
diameter: Math.min(48, Math.max(2, port.diameter)),
|
||||
width: port.width ?? defaults.width,
|
||||
height: port.height ?? defaults.height,
|
||||
}
|
||||
}
|
||||
return {
|
||||
shape: 'round',
|
||||
diameter: Math.min(48, Math.max(2, port.diameter)),
|
||||
width: defaults.width,
|
||||
height: defaults.height,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Project `raw` onto the nearest of the eight 45° rays emanating from
|
||||
* `from` in the XZ plane. Y is preserved from `from`. The projection
|
||||
* keeps the cursor's *distance* along the chosen ray so the user feels
|
||||
* the segment grow with their mouse motion rather than snap to a fixed
|
||||
* length.
|
||||
*/
|
||||
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
|
||||
// Distance along the chosen ray = projection of raw onto that direction.
|
||||
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 DuctSegmentTool = () => {
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const cursorRef = useRef<Group>(null)
|
||||
// Cross-section profile for the next committed segment. Q toggles
|
||||
// round/rect, [ / ] steps the round diameter, and snapping the start
|
||||
// onto an existing run / fitting INHERITS that node's profile — so
|
||||
// continuing a 14×8 trunk keeps drawing 14×8, and branching off a
|
||||
// round collar keeps its diameter. Seeded from `toolDefaults`.
|
||||
const [profile, setProfile] = useState<DraftProfile>(() => {
|
||||
const defaults = ductSegmentDefinition.defaults() as DraftProfile
|
||||
const seeded = useEditor.getState().toolDefaults['duct-segment'] as
|
||||
| Partial<DraftProfile>
|
||||
| undefined
|
||||
return {
|
||||
shape: seeded?.shape ?? defaults.shape,
|
||||
diameter: seeded?.diameter ?? defaults.diameter,
|
||||
width: seeded?.width ?? defaults.width,
|
||||
height: seeded?.height ?? defaults.height,
|
||||
}
|
||||
})
|
||||
const [draftPoints, setDraftPoints] = useState<Array<[number, number, number]>>([])
|
||||
const [cursorPos, setCursorPos] = useState<[number, number, number] | null>(null)
|
||||
// Ceiling mode (toggle with C): the first point lands at the level's
|
||||
// ceiling height (duct top hugging the ceiling) instead of the floor.
|
||||
const [ceilingMode, setCeilingMode] = useState(false)
|
||||
// When the cursor is within snap range of an existing duct's endpoint we
|
||||
// surface a brighter indicator and commit at the endpoint's exact coords.
|
||||
const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null)
|
||||
// True while Alt is held with a last point on the draft — drives the
|
||||
// vertical-cylinder ghost and the cursor HUD label.
|
||||
const [altActive, setAltActive] = useState(false)
|
||||
// Mirror into refs so emitter callbacks (closing over the first render's
|
||||
// setState) read the latest values without re-subscribing.
|
||||
const draftRef = useRef(draftPoints)
|
||||
draftRef.current = draftPoints
|
||||
const cursorPosRef = useRef(cursorPos)
|
||||
cursorPosRef.current = cursorPos
|
||||
const profileRef = useRef(profile)
|
||||
profileRef.current = profile
|
||||
const ceilingModeRef = useRef(ceilingMode)
|
||||
ceilingModeRef.current = ceilingMode
|
||||
// Port the anchored START point snapped onto (null = free placement).
|
||||
// Read at commit so a turn off an existing run mints an elbow there.
|
||||
const startPortRef = useRef<ScenePort | null>(null)
|
||||
// Centerline hit the anchored START point snapped onto (null = none).
|
||||
// Read at commit so a branch off a trunk's side mints a tee there.
|
||||
const startBodyRef = useRef<RunBodyHit | null>(null)
|
||||
// Anchor captured when Alt is pressed: screen Y at that moment and the
|
||||
// base elevation (= last point's Y). Cleared on Alt release.
|
||||
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
|
||||
// Latest mouse clientY from grid:move; used so the Alt anchor knows where
|
||||
// the cursor was at key-press time.
|
||||
const lastClientYRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
/**
|
||||
* Auto-elbow gate: only joints onto another RUN's open end get a
|
||||
* fitting minted. Ports on fittings / equipment / terminals are
|
||||
* already proper connections — a duct mates straight onto those.
|
||||
*
|
||||
* The elbow's junction sits ON the drawn corner, so the existing run
|
||||
* must trim back one leg to make room (`trim` update). Plans that
|
||||
* would trim the run to (or past) nothing are dropped — that corner
|
||||
* stays a plain butt joint. Guards against the snapped node having
|
||||
* been deleted between clicks.
|
||||
*/
|
||||
const elbowPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
||||
if (!port) return null
|
||||
const owner = useScene.getState().nodes[port.nodeId]
|
||||
if (owner?.type !== 'duct-segment') return null
|
||||
const plan = planElbowAtPort(port, awayDir, profileRef.current)
|
||||
if (!plan) return null
|
||||
|
||||
// Trim the run's snapped endpoint back to the elbow's inlet collar.
|
||||
const path = owner.path.map((p) => [...p] as [number, number, number])
|
||||
const index = port.id === 'start' ? 0 : path.length - 1
|
||||
const neighbor = path[index === 0 ? 1 : index - 1]!
|
||||
const remaining = Math.hypot(
|
||||
plan.trimmedPortPoint[0] - neighbor[0],
|
||||
plan.trimmedPortPoint[1] - neighbor[1],
|
||||
plan.trimmedPortPoint[2] - neighbor[2],
|
||||
)
|
||||
// The trim must leave a real piece of the existing run AND not flip
|
||||
// it (trimmed point past the neighbor) — otherwise skip the fitting.
|
||||
const original = path[index]!
|
||||
const originalLen = Math.hypot(
|
||||
original[0] - neighbor[0],
|
||||
original[1] - neighbor[1],
|
||||
original[2] - neighbor[2],
|
||||
)
|
||||
if (remaining < 0.08 || remaining >= originalLen) return null
|
||||
path[index] = plan.trimmedPortPoint
|
||||
return { ...plan, trim: { id: port.nodeId, data: { path } as Partial<AnyNode> } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Realign gate: the snapped port belongs to an existing ELBOW's open
|
||||
* collar — re-aim that elbow (junction + mated collar fixed, free
|
||||
* collar swings to the drawn direction). Null when the owner isn't
|
||||
* an elbow or the required turn leaves the 15–90° range.
|
||||
*/
|
||||
const realignPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
||||
if (!port) return null
|
||||
const owner = useScene.getState().nodes[port.nodeId]
|
||||
if (owner?.type !== 'duct-fitting') return null
|
||||
return planElbowRealign(owner, port.id, awayDir)
|
||||
}
|
||||
|
||||
// One segment per gesture: first click anchors the start, second
|
||||
// click commits a two-point duct immediately. No selection switch —
|
||||
// the tool stays armed so the next click starts the next segment
|
||||
// (port snap joins it onto the end just committed).
|
||||
//
|
||||
// When an end of the segment snapped onto another run's open port at
|
||||
// an angle, an elbow fitting is minted at that joint and the duct is
|
||||
// pulled back to the elbow's outlet collar — corners get real
|
||||
// fittings instead of butt joints.
|
||||
const commitSegment = (
|
||||
start: [number, number, number],
|
||||
end: [number, number, number],
|
||||
endPort: ScenePort | null = null,
|
||||
endBody: RunBodyHit | null = null,
|
||||
) => {
|
||||
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
|
||||
if (length < 1e-4) return
|
||||
const dir: [number, number, number] = [
|
||||
(end[0] - start[0]) / length,
|
||||
(end[1] - start[1]) / length,
|
||||
(end[2] - start[2]) / length,
|
||||
]
|
||||
|
||||
const startPlan = elbowPlanFor(startPortRef.current, dir)
|
||||
const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
||||
// Existing-fitting joints: re-aim the elbow whose collar was hit so
|
||||
// it faces the drawn run instead of leaving a mismatched butt joint.
|
||||
const startRealign = startPlan ? null : realignPlanFor(startPortRef.current, dir)
|
||||
const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
||||
// Tee tap: the start snapped onto a run's BODY (not an end port) —
|
||||
// split the trunk and branch from the tee's collar.
|
||||
const trunkBody = startPlan ? null : startBodyRef.current
|
||||
const trunkOwner = trunkBody ? useScene.getState().nodes[trunkBody.nodeId] : null
|
||||
const teePlan =
|
||||
trunkBody && trunkOwner?.type === 'duct-segment'
|
||||
? planTeeAtRunBody(trunkOwner, trunkBody, dir, profileRef.current)
|
||||
: null
|
||||
// End tee tap: the END landed on a run's BODY — split that trunk and
|
||||
// the new duct ends at the tee's branch collar. The branch leaves
|
||||
// toward the drawn run (back along -dir, since dir points start→end).
|
||||
const endTrunkBody = endPlan || endRealign ? null : endBody
|
||||
const endTrunkOwner = endTrunkBody ? useScene.getState().nodes[endTrunkBody.nodeId] : null
|
||||
const endTeePlan =
|
||||
endTrunkBody && endTrunkOwner?.type === 'duct-segment'
|
||||
? planTeeAtRunBody(
|
||||
endTrunkOwner,
|
||||
endTrunkBody,
|
||||
[-dir[0], -dir[1], -dir[2]],
|
||||
profileRef.current,
|
||||
)
|
||||
: null
|
||||
let ductStart =
|
||||
startPlan?.collarPoint ?? teePlan?.branchCollar ?? startRealign?.collarPoint ?? start
|
||||
let ductEnd =
|
||||
endPlan?.collarPoint ?? endTeePlan?.branchCollar ?? endRealign?.collarPoint ?? end
|
||||
// The collar pull-back must leave a real piece of duct between the
|
||||
// fittings; if not, fall back to the plain joint.
|
||||
const remaining = Math.hypot(
|
||||
ductEnd[0] - ductStart[0],
|
||||
ductEnd[1] - ductStart[1],
|
||||
ductEnd[2] - ductStart[2],
|
||||
)
|
||||
let plans = [startPlan, endPlan].filter((p) => p !== null)
|
||||
let tee = teePlan
|
||||
// Both ends tapping the SAME trunk would split one polyline twice in
|
||||
// a single change (conflicting updates + double tail) — drop the end
|
||||
// tee in that rare case and let the end butt-join instead.
|
||||
let endTee = endTeePlan && endTrunkBody?.nodeId === trunkBody?.nodeId ? null : endTeePlan
|
||||
if (!endTee && endTeePlan) ductEnd = endRealign?.collarPoint ?? end
|
||||
let realigns = [startRealign, endRealign].filter((p) => p !== null)
|
||||
|
||||
// Cross tap: the drawn run passes straight THROUGH a trunk's body
|
||||
// (interior crossing, not an end touch). Split that trunk and the
|
||||
// drawn duct into two halves meeting the cross's opposed branch
|
||||
// collars. Skip a run already tapped by a start / end tee so one
|
||||
// polyline isn't split twice in a single change.
|
||||
const crossHit = findRunBodyCrossingXZ(start, end, BODY_SNAP_RADIUS_M)
|
||||
const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null
|
||||
const crossTappedElsewhere =
|
||||
crossHit?.nodeId === trunkBody?.nodeId || crossHit?.nodeId === endTrunkBody?.nodeId
|
||||
let cross =
|
||||
crossHit && !crossTappedElsewhere && crossOwner?.type === 'duct-segment'
|
||||
? planCrossAtRunBody(crossOwner, crossHit, dir, profileRef.current)
|
||||
: null
|
||||
|
||||
if (remaining <= 0.08) {
|
||||
plans = []
|
||||
tee = null
|
||||
endTee = null
|
||||
realigns = []
|
||||
cross = null
|
||||
ductStart = start
|
||||
ductEnd = end
|
||||
}
|
||||
|
||||
// Rect / oval continuity: roll the new run's cross-section so its
|
||||
// profile stays continuous with whatever either end joined — run
|
||||
// end or fitting collar, turn or straight continuation (see
|
||||
// `continuityRollFrom`). The start joint wins if both ends join.
|
||||
let roll = 0
|
||||
if (profileRef.current.shape !== 'round') {
|
||||
const newDir = new Vector3(...dir)
|
||||
roll =
|
||||
continuityRollFrom(startPortRef.current, newDir) ??
|
||||
continuityRollFrom(endPort, newDir) ??
|
||||
0
|
||||
}
|
||||
|
||||
const defaults = ductSegmentDefinition.defaults()
|
||||
const toolDefaults = useEditor.getState().toolDefaults['duct-segment'] ?? {}
|
||||
const makeDuct = (from: [number, number, number], to: [number, number, number]) =>
|
||||
DuctSegmentNode.parse({
|
||||
...defaults,
|
||||
...toolDefaults,
|
||||
name: profileRef.current.shape === 'rect' ? 'Trunk' : 'Duct run',
|
||||
path: [from, to],
|
||||
shape: profileRef.current.shape,
|
||||
diameter: profileRef.current.diameter,
|
||||
width: profileRef.current.width,
|
||||
height: profileRef.current.height,
|
||||
roll,
|
||||
})
|
||||
// A cross splits the drawn run into two halves that meet its opposed
|
||||
// branch collars; otherwise it's one duct end-to-end. Degenerate
|
||||
// halves (the crossing too near an end) are dropped.
|
||||
const ducts = cross
|
||||
? [
|
||||
dist2(ductStart, cross.branchCollarNear) > 0.08 * 0.08
|
||||
? makeDuct(ductStart, cross.branchCollarNear)
|
||||
: null,
|
||||
dist2(cross.branchCollarFar, ductEnd) > 0.08 * 0.08
|
||||
? makeDuct(cross.branchCollarFar, ductEnd)
|
||||
: null,
|
||||
].filter((d) => d !== null)
|
||||
: [makeDuct(ductStart, ductEnd)]
|
||||
// One atomic change: trim / split the joined runs, create the
|
||||
// fittings + the new duct. Single undo step.
|
||||
useScene.getState().applyNodeChanges({
|
||||
create: [
|
||||
...plans.map((plan) => ({ node: plan.fitting, parentId: activeLevelId })),
|
||||
...(tee
|
||||
? [
|
||||
{ node: tee.fitting, parentId: activeLevelId },
|
||||
{ node: tee.trunkTail, parentId: activeLevelId },
|
||||
]
|
||||
: []),
|
||||
...(endTee
|
||||
? [
|
||||
{ node: endTee.fitting, parentId: activeLevelId },
|
||||
{ node: endTee.trunkTail, parentId: activeLevelId },
|
||||
]
|
||||
: []),
|
||||
...(cross
|
||||
? [
|
||||
{ node: cross.fitting, parentId: activeLevelId },
|
||||
{ node: cross.trunkTail, parentId: activeLevelId },
|
||||
]
|
||||
: []),
|
||||
...ducts.map((node) => ({ node, parentId: activeLevelId })),
|
||||
],
|
||||
update: [
|
||||
...plans.map((plan) => plan.trim),
|
||||
...(tee ? [tee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...(endTee ? [endTee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...(cross ? [cross.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...realigns.map((plan) => plan.update as { id: AnyNode['id']; data: Partial<AnyNode> }),
|
||||
],
|
||||
})
|
||||
triggerSFX('sfx:item-place')
|
||||
setDraftPoints([])
|
||||
setSnapTarget(null)
|
||||
startPortRef.current = null
|
||||
startBodyRef.current = null
|
||||
altAnchorRef.current = null
|
||||
setAltActive(false)
|
||||
}
|
||||
|
||||
// Base Y for a fresh run's first point: floor (0) by default, or just
|
||||
// below the level's ceiling in ceiling mode so the duct's top hugs the
|
||||
// ceiling (centerline = ceiling height − radius).
|
||||
const resolveBaseY = (): number => {
|
||||
if (!ceilingModeRef.current) return 0
|
||||
const ceiling = getLevelHeight(activeLevelId, useScene.getState().nodes)
|
||||
const p = profileRef.current
|
||||
const verticalIn = p.shape === 'round' ? p.diameter : p.height
|
||||
return Math.max(0, ceiling - (verticalIn * 0.0254) / 2)
|
||||
}
|
||||
|
||||
const resolveSnappedPoint = (
|
||||
event: GridEvent,
|
||||
): {
|
||||
point: [number, number, number]
|
||||
snapped: [number, number, number] | null
|
||||
port: ScenePort | null
|
||||
body: RunBodyHit | null
|
||||
} => {
|
||||
const last = draftRef.current.at(-1)
|
||||
// First point of the run: grid-snapped placement at the base Y (floor,
|
||||
// or ceiling height in ceiling mode). Endpoint snap can still join an
|
||||
// existing run.
|
||||
if (!last) {
|
||||
const baseY = resolveBaseY()
|
||||
const raw: [number, number, number] = [
|
||||
event.localPosition[0],
|
||||
baseY,
|
||||
event.localPosition[2],
|
||||
]
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
const shift = event.nativeEvent?.shiftKey === true
|
||||
if (event.nativeEvent?.altKey !== true) {
|
||||
const target = findNearbyPort(raw)
|
||||
if (target)
|
||||
return {
|
||||
point: portPoint(target),
|
||||
snapped: portPoint(target),
|
||||
port: target,
|
||||
body: null,
|
||||
}
|
||||
// No open end nearby — try the side of a run (tee tap). Probe
|
||||
// with a grid-snapped cursor so the tap steps along the duct
|
||||
// like every other placement; Shift frees it to ride smoothly.
|
||||
const probe: [number, number, number] = shift
|
||||
? raw
|
||||
: [snap(raw[0], step), baseY, snap(raw[2], step)]
|
||||
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
|
||||
if (body) return { point: body.point, snapped: body.point, port: null, body }
|
||||
}
|
||||
return {
|
||||
point: [snap(raw[0], step), baseY, snap(raw[2], step)],
|
||||
snapped: null,
|
||||
port: null,
|
||||
body: null,
|
||||
}
|
||||
}
|
||||
// Subsequent points: angle-locked to 45° from `last` (Shift releases).
|
||||
// Y stays at `last[1]` — depth changes come from Shift+click risers.
|
||||
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)
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
// Port snap (Alt bypass) — checked against the RAW cursor, not the
|
||||
// angle-locked projection, so a port slightly off the 45° ray can
|
||||
// still capture the cursor. Joining beats the lock.
|
||||
if (event.nativeEvent?.altKey !== true && !shift) {
|
||||
const target = findNearbyPort(rawXZ)
|
||||
if (target)
|
||||
return { point: portPoint(target), snapped: portPoint(target), port: target, body: null }
|
||||
// No open end nearby — landing on the side of a run taps a tee
|
||||
// there (mirror of the first-point tee tap). Probe with a
|
||||
// grid-snapped cursor so the tap steps along the duct instead of
|
||||
// sliding smoothly (Shift above frees it). Checked against the
|
||||
// cursor, not the 45° projection, so a slightly-off trunk captures.
|
||||
const probe: [number, number, number] = [
|
||||
snap(rawXZ[0], step),
|
||||
rawXZ[1],
|
||||
snap(rawXZ[2], step),
|
||||
]
|
||||
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
|
||||
if (body) return { point: body.point, snapped: body.point, port: null, body }
|
||||
}
|
||||
return {
|
||||
point: [snap(angled[0], step), angled[1], snap(angled[2], step)],
|
||||
snapped: null,
|
||||
port: null,
|
||||
body: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the Alt-mode cursor position: XZ locked to the last point,
|
||||
* Y driven by how far the mouse has moved vertically on screen since
|
||||
* Alt was pressed. Returns null if there's no anchor (Alt not active).
|
||||
*/
|
||||
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
|
||||
// Screen +Y points down, so subtract to map "drag up = raise Y".
|
||||
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 / body / grid / angle snap) and then
|
||||
// layer Figma-style alignment on top so a run lines up with other runs,
|
||||
// fittings, and items as it's drawn. Snap is applied for a free point
|
||||
// (first vertex, or Shift free-angle); an angle-locked continuation shows
|
||||
// the guide passively without leaving its 45° ray. A port / body snap or
|
||||
// Alt bypasses alignment entirely.
|
||||
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
|
||||
// Alt vertical mode wins over the XZ logic.
|
||||
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)
|
||||
// Vertical mode with a start anchored: the click commits the riser
|
||||
// segment right there. Never falls through to the XZ logic — a
|
||||
// no-op Alt click (height unchanged) must not place anything.
|
||||
if (altAnchorRef.current && start) {
|
||||
const clientY =
|
||||
(event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current
|
||||
if (typeof clientY === 'number') {
|
||||
const point = resolveAltVerticalPoint(clientY)
|
||||
if (point && Math.abs(point[1] - start[1]) >= 1e-4) {
|
||||
commitSegment(start, point)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
const { point, port, body } = resolveAlignedPoint(event)
|
||||
if (!start) {
|
||||
// First click: anchor the segment start, remembering the port or
|
||||
// run body it snapped to so the commit can mint an elbow / tee.
|
||||
// Joining a port INHERITS the source's cross-section — continuing
|
||||
// a rect trunk keeps drawing rect at its W×H, a round collar its
|
||||
// diameter. Body taps (tee branches) keep the tool's own profile.
|
||||
triggerSFX('sfx:grid-snap')
|
||||
startPortRef.current = port
|
||||
startBodyRef.current = port ? null : body
|
||||
if (port) {
|
||||
const inherited = inheritProfile(port)
|
||||
if (inherited) setProfile(inherited)
|
||||
}
|
||||
setDraftPoints([point])
|
||||
return
|
||||
}
|
||||
// Second click: commit the segment and re-arm. A body hit on the end
|
||||
// (no end port) taps a tee into that run's side.
|
||||
commitSegment(start, point, port, port ? null : body)
|
||||
}
|
||||
|
||||
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 stepDiameter = (step: 1 | -1) => {
|
||||
const sizes = DUCT_DIAMETERS_IN
|
||||
const current = profileRef.current.diameter
|
||||
// Nearest catalogue index, then step — handles seeded off-catalogue
|
||||
// values (e.g. a preset's 7.5") gracefully.
|
||||
let nearest = 0
|
||||
for (let i = 1; i < sizes.length; i++) {
|
||||
if (Math.abs(sizes[i]! - current) < Math.abs(sizes[nearest]! - current)) nearest = i
|
||||
}
|
||||
const next = sizes[Math.min(sizes.length - 1, Math.max(0, nearest + step))]!
|
||||
if (next === current) return
|
||||
setProfile((p) => ({ ...p, diameter: next }))
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement | null)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
if (e.key === 'Alt') {
|
||||
e.preventDefault()
|
||||
enterAltMode()
|
||||
} else if (e.key === '[') {
|
||||
e.preventDefault()
|
||||
stepDiameter(-1)
|
||||
} else if (e.key === ']') {
|
||||
e.preventDefault()
|
||||
stepDiameter(1)
|
||||
} else if (e.key === 'q' || e.key === 'Q') {
|
||||
e.preventDefault()
|
||||
setProfile((p) => ({ ...p, shape: p.shape === 'round' ? 'rect' : 'round' }))
|
||||
triggerSFX('sfx:grid-snap')
|
||||
} else if (e.key === 'c' || e.key === 'C') {
|
||||
// Toggle ceiling mode. Only the first point reads the base Y, so
|
||||
// toggling mid-run is a no-op until the next fresh segment — flip
|
||||
// it only while unanchored to keep the behaviour predictable.
|
||||
if (draftRef.current.length > 0) return
|
||||
e.preventDefault()
|
||||
setCeilingMode((m) => !m)
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
startPortRef.current = null
|
||||
startBodyRef.current = null
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onMove)
|
||||
emitter.on('grid:click', onClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
emitter.off('grid:click', onClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
altAnchorRef.current = null
|
||||
clearDrawAlignment()
|
||||
}
|
||||
}, [activeLevelId])
|
||||
|
||||
if (!activeLevelId) return null
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
// Wall-style dimension pill above the cursor: absolute world coords before
|
||||
// the first point, signed per-axis deltas from the last placed point while
|
||||
// a segment is in flight. The actively-driven axis is emphasised — Y in
|
||||
// Alt-vertical mode, otherwise whichever horizontal axis dominates. A
|
||||
// trailing Ø readout shows the diameter the next click commits ([ / ]).
|
||||
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,
|
||||
})),
|
||||
...(profile.shape === 'round'
|
||||
? [{ key: 'diameter', prefix: 'Ø', value: profile.diameter * 0.0254, signed: false }]
|
||||
: [
|
||||
{ key: 'trunk-w', prefix: 'W', value: profile.width * 0.0254, signed: false },
|
||||
{ key: 'trunk-h', prefix: 'H', value: profile.height * 0.0254, signed: false },
|
||||
]),
|
||||
]
|
||||
: 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 walls and items show while drawing (icon resolved from the
|
||||
active `duct-segment` structure-tools entry). The dimension pill
|
||||
rides just above the cursor. */}
|
||||
{cursorPos && (
|
||||
<>
|
||||
<CursorSphere position={cursorPos} ref={cursorRef} />
|
||||
{pillParts && (
|
||||
<group position={cursorPos}>
|
||||
<Html
|
||||
center
|
||||
position={[0, 0.35, 0]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
|
||||
{ceilingMode && !last && (
|
||||
<div className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-3 py-0.5 text-[10px] text-muted-foreground shadow-sm backdrop-blur">
|
||||
Ceiling · C to toggle
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Endpoint-snap halo — brighter ring around the target endpoint
|
||||
while the cursor is within snap range, so the user sees that the
|
||||
next click will join an existing duct rather than freeform-place. */}
|
||||
{snapTarget && (
|
||||
<mesh layers={EDITOR_LAYER} position={snapTarget}>
|
||||
<sphereGeometry args={[0.12, 24, 16]} />
|
||||
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
|
||||
</mesh>
|
||||
)}
|
||||
{/* Committed point pips */}
|
||||
{draftPoints.map((p, i) => (
|
||||
<mesh key={`pt-${i}`} layers={EDITOR_LAYER} position={p}>
|
||||
<sphereGeometry args={[0.07, 16, 12]} />
|
||||
<meshBasicMaterial color="#818cf8" depthTest={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* Preview sections */}
|
||||
{previewSegments.map((seg, i) => (
|
||||
<PreviewSegment
|
||||
a={seg.a}
|
||||
b={seg.b}
|
||||
key={`seg-${i}`}
|
||||
profile={profile}
|
||||
startPort={startPortRef.current}
|
||||
/>
|
||||
))}
|
||||
</LevelOffsetGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewSegment({
|
||||
a,
|
||||
b,
|
||||
profile,
|
||||
startPort,
|
||||
}: {
|
||||
a: [number, number, number]
|
||||
b: [number, number, number]
|
||||
profile: DraftProfile
|
||||
startPort: ScenePort | null
|
||||
}) {
|
||||
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)
|
||||
|
||||
// Rect AND oval ghost as a box — close enough for a translucent guide.
|
||||
if (profile.shape !== 'round') {
|
||||
const w = profile.width * 0.0254
|
||||
const h = profile.height * 0.0254
|
||||
return (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
position={mid.toArray()}
|
||||
ref={(m) => {
|
||||
if (!m) return
|
||||
// Same basis AND roll as the commit will use, so the ghost
|
||||
// shows the orientation that actually lands.
|
||||
const roll = continuityRollFrom(startPort, dir) ?? 0
|
||||
const { width: x, height: z } = rectSectionAxes(dir, roll)
|
||||
m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))
|
||||
}}
|
||||
>
|
||||
<boxGeometry args={[w, length, h]} />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
opacity={PREVIEW_OPACITY}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
const radius = (profile.diameter * 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, 24, 1, false]} />
|
||||
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={PREVIEW_OPACITY} transparent />
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
export default DuctSegmentTool
|
||||
Reference in New Issue
Block a user