feat: HVAC ductwork + DWV plumbing systems (#402)

Adds two new MEP node families (HVAC ductwork, DWV plumbing) built on a shared port-connectivity model. Co-authored by @sudhir9297.
This commit is contained in:
Sudhir Yadav
2026-06-16 15:30:39 -04:00
committed by GitHub
parent a0d3d9c701
commit 5551500d98
172 changed files with 17361 additions and 150 deletions
+1
View File
@@ -17,6 +17,7 @@ import type { BoxVentNode } from './schema'
* the cursor ray and starve the placement tool of `roof:move` events.
*/
const BoxVentPreview = ({ node, invalid }: { node: BoxVentNode; invalid?: boolean }) => {
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geometry = useMemo(
() => buildBoxVentGeometry(node),
[node.width, node.depth, node.height, node.hoodOverhang, node.style],
+1
View File
@@ -75,6 +75,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
// every parametric field, including the per-style ones. Listing them
// explicitly keeps the dep array tight (vs. `[node]` which would
// also fire on `name` / `visible` flips).
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geometry = useMemo(
() => buildBoxVentGeometry(node),
[
+1
View File
@@ -48,6 +48,7 @@ const ChimneyPreview = ({
const material = invalid ? invalidGhostMaterial : ghostMaterial
const effectiveSegment = segment ?? RoofSegmentSchema.parse({})
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geo = useMemo(
() => buildChimneyGeometry(node, effectiveSegment),
[
+10 -18
View File
@@ -77,24 +77,16 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
}, [node, segment])
// Segment brushes for the body trim. Building these is non-trivial
// (4 CSG-ready Brush instances per segment), so memoise by the shape
// fields that drive their geometry. A chimney slider drag changes
// `node.*` but not these, so the cached brushes survive the drag —
// previously each frame rebuilt all four.
const segmentBrushes = useMemo(
() => (segment ? getRoofSegmentBrushes(segment) : null),
[
segment?.roofType,
segment?.width,
segment?.depth,
segment?.wallHeight,
segment?.pitch,
segment?.wallThickness,
segment?.deckThickness,
segment?.overhang,
segment?.shingleThickness,
],
)
// (4 CSG-ready Brush instances per segment). `segment` comes from a
// `useScene` selector, so it only re-identifies when the segment's own
// data changes — depend on it directly (as the `geo` memo above does)
// and the brushes rebuild exactly when the host roof reshapes, incl.
// the gambrel / mansard / dutch-hip width-ratio fields that
// `getRoofSegmentBrushes` reads. A chimney slider drag changes `node`,
// not `segment`, so the cache still survives the drag. Enumerating
// individual fields here previously omitted those ratios and left the
// trim CSG-ing against a stale roof outline.
const segmentBrushes = useMemo(() => (segment ? getRoofSegmentBrushes(segment) : null), [segment])
useEffect(
() => () => {
if (segmentBrushes) {
+1
View File
@@ -13,6 +13,7 @@ import type { CupolaNode } from './schema'
* so the preview doesn't intercept the cursor ray feeding the tool.
*/
const CupolaPreview = ({ node, invalid }: { node: CupolaNode; invalid?: boolean }) => {
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geometry = useMemo(
() => buildCupolaGeometry(node),
[node.width, node.depth, node.height, node.roofStyle, node.finial],
+1
View File
@@ -53,6 +53,7 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => {
: undefined,
)
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geometry = useMemo(
() => buildCupolaGeometry(node),
[node.width, node.depth, node.height, node.roofStyle, node.finial],
@@ -325,7 +325,6 @@ export function generateDormerGeometry(
const dormerBrushes = getRoofSegmentBrushes(virtualSegment)
if (!dormerBrushes) {
// biome-ignore lint/suspicious/noConsole: keep diagnostic — fallback path.
console.warn('[dormer] getRoofSegmentBrushes returned null; using fallback silhouette.')
return buildDormerFallbackGeometry(dormer)
}
@@ -472,7 +471,6 @@ export function generateDormerGeometry(
remapRoofShellFaces(resultGeo, virtualSegment)
splitDormerGableMaterial(resultGeo, dormer.height, DORMER_GABLE_MATERIAL_INDEX)
} catch (e) {
// biome-ignore lint/suspicious/noConsole: dormer CSG can throw; keep diagnostic.
console.error('[dormer] CSG failed, falling back to silhouette:', e)
if (dormerSolid) {
try {
@@ -492,7 +490,6 @@ export function generateDormerGeometry(
// dormer is at least visible.
const triCount = resultGeo.getIndex()?.count ?? resultGeo.getAttribute('position')?.count ?? 0
if (triCount === 0) {
// biome-ignore lint/suspicious/noConsole: keep diagnostic — empty CSG.
console.warn('[dormer] CSG produced empty geometry; using fallback silhouette.')
return buildDormerFallbackGeometry(dormer)
}
@@ -41,6 +41,7 @@ export function DormerPositionSection({
const segmentId = segment?.id
const roofChildrenKey = (roof?.children ?? []).join(',')
// biome-ignore lint/correctness/useExhaustiveDependencies: roofChildrenKey is the stable signature of `roof.children`; intentionally omitting `roof` (object identity) in favor of the joined ids.
const worldXform = useMemo(() => {
const dormerObj = sceneRegistry.nodes.get(selectedId)
let worldX = 0
@@ -79,7 +80,6 @@ export function DormerPositionSection({
if (Number.isFinite(lo_x)) bounds = { minX: lo_x, maxX: hi_x, minZ: lo_z, maxZ: hi_z }
}
return { worldX, worldZ, worldRotation, bounds }
// biome-ignore lint/correctness/useExhaustiveDependencies: roofChildrenKey is the stable signature of `roof.children`; intentionally omitting `roof` (object identity) in favor of the joined ids.
}, [selectedId, px, py, pz, nodeRotation, segmentId, roofChildrenKey])
const worldX_now = worldXform.worldX
+1 -1
View File
@@ -107,7 +107,7 @@ export default function DormerPanel() {
}, [node, selectedId, setMovingNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!(node && node.roofSegmentId)) return
if (!node?.roofSegmentId) return
triggerSFX('sfx:item-pick')
// Deep clone and strip the id so the move tool's onClick branch
// (`isNew || !node.id`) takes the "create fresh" path. Setting
+1
View File
@@ -26,6 +26,7 @@ const invalidGhostMaterial = new THREE.MeshStandardMaterial({
const DormerPreview = ({ node, invalid }: { node: DormerNode; invalid?: boolean }) => {
const material = invalid ? invalidGhostMaterial : ghostMaterial
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geo = useMemo(
() => buildDormerGhostGeometry(node),
[node.width, node.depth, node.height, node.roofHeight, node.roofType, node.wallSkirtHeight],
+2
View File
@@ -59,6 +59,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
// shingle, 4=Gable wall. Walls take the 'wall' role, the deck side and
// shingle take 'roof'. When textures are off, every slot snaps to its
// role colour regardless of explicit paint (the render-modes invariant).
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const material = useMemo(() => {
const wallRole = () => createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
const roofRole = () => createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme)
@@ -111,6 +112,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
[colorPreset, sceneTheme],
)
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geometry = useMemo(() => {
if (!segment) return null
if (isLiveDrag) return buildDormerFallbackGeometry(node)
@@ -28,6 +28,7 @@ const DormerWindowAssembly = ({
frameMaterial: THREE.Material
glassMaterial: THREE.Material
}) => {
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const skirtWin = useMemo(
() => getDormerSkirtWindowDims(node),
[
@@ -45,6 +46,7 @@ const DormerWindowAssembly = ({
const winShape: DormerWindowShape = node.windowShape
const resolvedRadii: [number, number, number, number] = [...node.windowCornerRadii]
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const winGeo = useMemo(
() =>
buildDormerWindowGeometries(
@@ -101,6 +103,7 @@ const DormerWindowAssembly = ({
)
useEffect(() => () => sillGeo?.dispose(), [sillGeo])
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const exposed = useMemo(
() => getDormerExposedFaces(node, segment),
[
@@ -142,7 +145,6 @@ const DormerWindowAssembly = ({
{winGeo.glassPanes.map((pane, i) => (
<mesh
geometry={pane.geo}
// biome-ignore lint/suspicious/noArrayIndexKey: glass panes are derived from grid indices, no stable id.
key={`${keyPrefix}-glass-${i}`}
material={glassMaterial}
name={`dormer-glass-${keyPrefix}-${i}`}
@@ -153,7 +155,6 @@ const DormerWindowAssembly = ({
<mesh
castShadow
geometry={bar.geo}
// biome-ignore lint/suspicious/noArrayIndexKey: frame bars are derived from grid indices, no stable id.
key={`${keyPrefix}-bar-${i}`}
material={frameMaterial}
name={`dormer-frame-${keyPrefix}-${i}`}
+1
View File
@@ -26,6 +26,7 @@ const DownspoutPreview = ({
routing?: DownspoutRouting | null
invalid?: boolean
}) => {
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geometry = useMemo(
() => buildDownspoutGeometry(node, routing),
[
@@ -101,6 +101,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => {
// that actually move the jog or the collar bore, so the pipe geometry
// only rebuilds when one of those changes (not on every override-merge
// render). Resolves to null when the gutter has no outlet.
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const routing = useMemo(
() =>
effectiveGutter && effectiveSegment
@@ -117,6 +118,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => {
],
)
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geometry = useMemo(
() => buildDownspoutGeometry(node, routing),
[
@@ -0,0 +1,134 @@
import type { NodeDefinition } from '@pascal-app/core'
import { rotateFittingNode } from '../shared/fitting-rotation'
import { buildDuctFittingFloorplan } from './floorplan'
import { buildDuctFittingGeometry } from './geometry'
import { ductFittingParametrics } from './parametrics'
import { getDuctFittingPorts } from './ports'
import { DuctFittingNode } from './schema'
/**
* Phase 2 of the HVAC node system — duct fittings (elbow / tee / reducer)
* and the first kind to expose typed ports (`def.ports`).
*
* Composition: `def.geometry` only, same as duct-segment. Ports are the
* architectural payload: placement tools snap onto them, and a later
* slice walks them to build the supply/return system graph.
*/
export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
kind: 'duct-fitting',
schemaVersion: 1,
schema: DuctFittingNode,
category: 'utility',
distributionRole: 'fitting',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
fittingType: 'elbow',
shape: 'round',
width: 14,
height: 8,
shape2: 'round',
width2: 14,
height2: 8,
angle: 90,
branchAngle: 90,
diameter: 6,
diameter2: 6,
ductMaterial: 'sheet-metal',
system: 'supply',
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
// `cursorAttached`: a fitting is a small connector — an offset-
// preserving drag reads as the mesh trailing the mouse, so pin its
// origin to the cursor instead.
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
duplicable: true,
deletable: true,
},
parametrics: ductFittingParametrics,
geometry: buildDuctFittingGeometry,
geometryKey: (n) =>
JSON.stringify([
n.fittingType,
// The mitered elbow + flange profiles swap width/height roles based
// on where world-up sits in the local frame, so orientation is a
// geometry input.
n.rotation,
n.shape,
n.width,
n.height,
n.shape2,
n.width2,
n.height2,
n.angle,
n.branchAngle,
n.diameter,
n.diameter2,
n.ductMaterial,
n.system,
]),
ports: getDuctFittingPorts,
floorplan: buildDuctFittingFloorplan,
// R/T rotate a selected fitting ±45° around the shared active axis.
// The default editor rotate only knows Y; fittings need X/Z for
// risers, so this overrides it. Alt-cycling of the axis + the axis
// badge live in `./selection.tsx`.
keyboardActions: {
r: {
appliesTo: (node) => node.type === 'duct-fitting',
run: (node) => rotateFittingNode(node, 1),
},
t: {
appliesTo: (node) => node.type === 'duct-fitting',
run: (node) => rotateFittingNode(node, -1),
},
axisCycling: true,
},
// Alt-cycles the active rotation axis while a fitting is selected.
// Editor-only (drives `useEditor.rotationAxis`), so it mounts via the
// editor's SelectionAffordanceManager rather than `def.system`.
affordanceTools: {
selection: () => import('./selection'),
// Ghost-preview duplicate / move. Duplicate is pure drag-to-place: a
// translucent copy of the fitting (built from its real geometry, at its
// own rotation, so an elbow / riser stays properly aligned) follows the
// cursor and only lands on the commit click. Takes priority over
// `capabilities.movable` in the MoveTool dispatcher.
move: () => import('./move-tool'),
},
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Place fitting' },
{ key: 'Hover a duct end', label: 'Snap onto the run' },
{ key: 'R / T', label: 'Rotate ±45°' },
{ key: 'Alt', label: 'Switch rotation axis (Y → X → Z)' },
{ key: 'Esc', label: 'Exit' },
],
presentation: {
label: 'Duct Fitting',
description: 'Elbow, tee, reducer, or square-to-round transition connecting duct runs.',
icon: { kind: 'url', src: '/icons/duct-fitting.png' },
paletteSection: 'structure',
paletteOrder: 91,
},
mcp: {
description:
'A duct fitting (elbow, tee, reducer, or square-to-round transition) with typed connection ports. Position is level-local meters; rotation is an XYZ euler in radians.',
},
}
@@ -0,0 +1,69 @@
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import { getDuctFittingPorts } from './ports'
import type { DuctFittingNode } from './schema'
const SUPPLY_COLOR = '#d4825a'
const RETURN_COLOR = '#5a8ad4'
const BODY_COLOR = '#9ca3af'
/**
* Floor-plan symbol for a duct fitting: one stub line per port from the
* junction center out to the collar (drawn at each collar's real
* diameter), plus a junction circle. Ports are computed in level-local
* 3D and projected to plan, so a rotated or riser-turned fitting shows
* its true plan footprint; a vertical port collapses onto the junction
* circle, which is exactly how it should read from above.
*/
export function buildDuctFittingFloorplan(
node: DuctFittingNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const [cx, , cz] = node.position
const ports = getDuctFittingPorts(node)
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
const accent = node.system === 'supply' ? SUPPLY_COLOR : RETURN_COLOR
const bodyStroke = showSelectedChrome && palette ? palette.selectedStroke : BODY_COLOR
const children: FloorplanGeometry[] = []
for (const port of ports) {
const px = port.position[0]
const pz = port.position[2]
// Vertical port — projects onto the junction itself; skip the stub.
if (Math.hypot(px - cx, pz - cz) < 1e-4) continue
children.push({
kind: 'line',
x1: cx,
y1: cz,
x2: px,
y2: pz,
stroke: bodyStroke,
strokeWidth: port.diameter * INCHES_TO_METERS,
strokeLinecap: 'round',
opacity: showSelectedChrome ? 0.95 : 0.8,
})
}
children.push({
kind: 'circle',
cx,
cy: cz,
r: (node.diameter * INCHES_TO_METERS) / 2 + 0.015,
fill: bodyStroke,
stroke: accent,
strokeWidth: 1.5,
vectorEffect: 'non-scaling-stroke',
opacity: 0.95,
})
if (showSelectedChrome) {
children.push({
kind: 'move-handle',
point: [cx, cz],
})
}
return { kind: 'group', children }
}
+463
View File
@@ -0,0 +1,463 @@
import {
BufferGeometry,
CylinderGeometry,
DoubleSide,
Euler,
Float32BufferAttribute,
Group,
Mesh,
type MeshStandardMaterial,
SphereGeometry,
TorusGeometry,
Vector3,
} from 'three'
import {
buildOvalSection,
buildRectSection,
buildSection,
createDuctMaterial,
INCHES_TO_METERS,
} from '../duct-segment/geometry'
import { localFittingPorts } from './ports'
import type { DuctFittingNode } from './schema'
const RADIAL_SEGMENTS = 24
const UP = new Vector3(0, 1, 0)
/**
* Mitered rectangular elbow as ONE closed solid — the way sheet-metal
* square elbows are actually folded. The rect profile sweeps from the
* inlet face to the outlet face through a single miter ring lying on
* the corner's bisector plane (the classic 2D miter-join offset:
* join(u) = (wA + wB) · u / (1 + wA·wB)), so the two legs meet in a
* crisp seam instead of interpenetrating boxes.
*
* Local frame: legs in the XZ plane (ports convention) so the fold hinge
* is always local Y. `sweepM` is the profile dimension carried through the
* bend (in the XZ bend plane); `cheekM` is the dimension that stays
* constant along the hinge. Which physical dimension (width vs height)
* plays each role depends on the elbow's world orientation and is decided
* by the caller — a floor turn folds about vertical (cheek = height),
* a wall riser folds about horizontal (cheek = width).
*
* Non-indexed triangles → flat face normals for the folded-metal look;
* the closed solid renders double-sided so winding never makes a face
* vanish.
*/
/**
* Stadium (flat-oval) outline in profile (u, v) coordinates: u-extent
* `uM`, v-extent `vM`, semicircular caps of the smaller dimension. The
* caps land on whichever axis is longer, so a riser-rotated profile
* (swapped roles) stays a valid stadium.
*/
function stadiumOutline(uM: number, vM: number, samplesPerCap = 10): Array<[number, number]> {
const pts: Array<[number, number]> = []
const r = Math.min(uM, vM) / 2
const s = (Math.max(uM, vM) - Math.min(uM, vM)) / 2
const cap = (cu: number, cv: number, startA: number) => {
for (let i = 0; i <= samplesPerCap; i++) {
const a = startA + (Math.PI * i) / samplesPerCap
pts.push([cu + r * Math.cos(a), cv + r * Math.sin(a)])
}
}
if (uM >= vM) {
cap(s, 0, -Math.PI / 2)
cap(-s, 0, Math.PI / 2)
} else {
cap(0, s, 0)
cap(0, -s, Math.PI)
}
return pts
}
function buildMiteredElbow(
inletPos: Vector3,
outletPos: Vector3,
sweepM: number,
cheekM: number,
profileShape: 'rect' | 'oval',
material: MeshStandardMaterial,
): Mesh {
const travelIn = inletPos.clone().multiplyScalar(-1).normalize() // inlet → junction
const travelOut = outletPos.clone().normalize() // junction → outlet
const wA = new Vector3().crossVectors(UP, travelIn).normalize()
const wB = new Vector3().crossVectors(UP, travelOut).normalize()
// Elbow turns are ≤ 90°, so wA·wB ≥ 0 and the join never degenerates.
const miterScale = 1 / (1 + wA.dot(wB))
const wJoin = new Vector3().addVectors(wA, wB)
const hw = sweepM / 2
const hh = cheekM / 2
const corners: Array<[number, number]> =
profileShape === 'oval'
? stadiumOutline(sweepM, cheekM)
: [
[hw, hh],
[-hw, hh],
[-hw, -hh],
[hw, -hh],
]
const n = corners.length
const ring = (center: Vector3, uAxis: Vector3, scale = 1): Vector3[] =>
corners.map(([u, v]) =>
center
.clone()
.addScaledVector(uAxis, u * scale)
.addScaledVector(UP, v),
)
const inletRing = ring(inletPos, wA)
const miterRing = ring(new Vector3(0, 0, 0), wJoin, miterScale)
const outletRing = ring(outletPos, wB)
const positions: number[] = []
const tri = (a: Vector3, b: Vector3, c: Vector3) =>
positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z)
const quad = (a: Vector3, b: Vector3, c: Vector3, d: Vector3) => {
tri(a, b, c)
tri(a, c, d)
}
const skin = (from: Vector3[], to: Vector3[]) => {
for (let k = 0; k < n; k++) {
const k2 = (k + 1) % n
quad(from[k]!, to[k]!, to[k2]!, from[k2]!)
}
}
skin(inletRing, miterRing)
skin(miterRing, outletRing)
// End caps — triangle fans so any convex profile closes.
for (let k = 1; k < n - 1; k++) {
tri(inletRing[0]!, inletRing[k]!, inletRing[k + 1]!)
tri(outletRing[k + 1]!, outletRing[k]!, outletRing[0]!)
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.computeVertexNormals()
const solidMaterial = material.clone()
solidMaterial.side = DoubleSide
const mesh = new Mesh(geometry, solidMaterial)
mesh.name = `fitting-elbow-${profileShape}`
return mesh
}
/**
* Square-to-round loft between a rect ring at `xRect` and a round ring
* at `xRound`, both centered on the local X axis (the straight-through
* run). Profiles are sampled at matching polar angles — the rect point
* is the ray's intersection with the rectangle boundary — so the skin
* twists nowhere. Non-indexed triangles + computed normals give the
* faceted gore look of a real shop-made square-to-round.
*/
function buildRectToRoundLoft(
xRect: number,
xRound: number,
widthM: number,
heightM: number,
radius: number,
material: MeshStandardMaterial,
): Mesh {
const hw = widthM / 2
const hh = heightM / 2
const rectRing: Vector3[] = []
const roundRing: Vector3[] = []
for (let i = 0; i < RADIAL_SEGMENTS; i++) {
const theta = (2 * Math.PI * i) / RADIAL_SEGMENTS
const cz = Math.cos(theta)
const sy = Math.sin(theta)
// Scale the unit ray until it hits the rectangle boundary. Width
// spans local Z and height local Y — the same axes buildRectSection
// gives a +X run.
const t = 1 / Math.max(Math.abs(cz) / hw, Math.abs(sy) / hh)
rectRing.push(new Vector3(xRect, t * sy, t * cz))
roundRing.push(new Vector3(xRound, radius * sy, radius * cz))
}
const positions: number[] = []
const tri = (a: Vector3, b: Vector3, c: Vector3) =>
positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z)
for (let i = 0; i < RADIAL_SEGMENTS; i++) {
const j = (i + 1) % RADIAL_SEGMENTS
tri(rectRing[i]!, roundRing[i]!, roundRing[j]!)
tri(rectRing[i]!, roundRing[j]!, rectRing[j]!)
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.computeVertexNormals()
const solidMaterial = material.clone()
solidMaterial.side = DoubleSide
const mesh = new Mesh(geometry, solidMaterial)
mesh.name = 'fitting-transition-loft'
return mesh
}
/**
* Pure geometry builder for a duct fitting, in the fitting's LOCAL frame —
* `<ParametricNodeRenderer>` applies `node.position` / `node.rotation`.
*
* Strategy: one cylinder stub per port from the junction center outward
* (reusing the segment builder's `buildSection`), a sphere at the
* junction, and a slightly-oversized crimp collar ring at each port
* opening so fittings read as sheet-metal junctions rather than bare
* tube ends.
*
* The reducer is special-cased: instead of equal stubs + sphere it draws
* a short inlet stub, a tapered cone, and a short outlet stub inline.
*
* Non-round shapes (elbow / tee): run legs carry the fitting's
* width × height profile — rect prisms or flat-oval stadiums — matching
* the trunk they join; a tee's branch leg carries its own `shape2`
* profile (width2 × height2, or round at `diameter2`). The profile's
* height rides local +Y — for the horizontal-plane orientations trunks
* are drawn in, that's world-vertical.
*/
export function buildDuctFittingGeometry(node: DuctFittingNode): Group {
const group = new Group()
const material = createDuctMaterial(node)
const radiusMain = (node.diameter * INCHES_TO_METERS) / 2
const ports = localFittingPorts(node)
const widthM = node.width * INCHES_TO_METERS
const heightM = node.height * INCHES_TO_METERS
// The elbow folds about its local Y. Width spans the XZ bend plane and
// height rides the hinge ONLY when local Y is world-vertical (a floor
// turn). For a riser the node is rotated so local Y lands horizontal —
// then it's width that runs along the hinge, so the roles swap. Pick by
// where world-up sits in the fitting's local frame.
const hingeWorld = UP.clone().applyEuler(
new Euler(node.rotation[0], node.rotation[1], node.rotation[2]),
)
const hingeIsVertical = Math.abs(hingeWorld.y) >= Math.SQRT1_2
if (node.fittingType === 'reducer') {
const radiusOut = (node.diameter2 * INCHES_TO_METERS) / 2
const inlet = ports[0]!
const outlet = ports[1]!
const taperHalf = Math.abs(inlet.position.x) / 3
const stubA = buildSection(
inlet.position,
new Vector3(-taperHalf, 0, 0),
radiusMain,
material,
'fitting-stub-inlet',
)
if (stubA) group.add(stubA)
const cone = new Mesh(
new CylinderGeometry(radiusOut, radiusMain, taperHalf * 2, RADIAL_SEGMENTS, 1, false),
material,
)
cone.name = 'fitting-taper'
cone.quaternion.setFromUnitVectors(UP, new Vector3(1, 0, 0))
group.add(cone)
const stubB = buildSection(
new Vector3(taperHalf, 0, 0),
outlet.position,
radiusOut,
material,
'fitting-stub-outlet',
)
if (stubB) group.add(stubB)
} else if (node.fittingType === 'transition') {
// Square-to-round: rect stub on the inlet, lofted gore body through
// the junction, round stub on the outlet. Same inline layout as the
// reducer, with the taper replaced by the loft.
const radiusOut = (node.diameter2 * INCHES_TO_METERS) / 2
const inlet = ports[0]!
const outlet = ports[1]!
const taperHalf = Math.abs(inlet.position.x) / 3
const stubA = buildRectSection(
inlet.position,
new Vector3(-taperHalf, 0, 0),
widthM,
heightM,
material,
'fitting-stub-inlet',
)
if (stubA) group.add(stubA)
group.add(buildRectToRoundLoft(-taperHalf, taperHalf, widthM, heightM, radiusOut, material))
const stubB = buildSection(
new Vector3(taperHalf, 0, 0),
outlet.position,
radiusOut,
material,
'fitting-stub-outlet',
)
if (stubB) group.add(stubB)
} else if (node.shape !== 'round' && node.fittingType === 'elbow') {
// One mitered solid — no stubs, no junction blob. Oval profiles
// sweep the same way; the ring is a stadium instead of 4 corners.
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
group.add(
buildMiteredElbow(
inlet.position,
outlet.position,
hingeIsVertical ? widthM : heightM,
hingeIsVertical ? heightM : widthM,
node.shape,
material,
),
)
} else if (node.shape !== 'round' && node.fittingType === 'tee') {
// Straight rect / oval run inlet→outlet (one prism — nothing to
// miter) plus a branch leg tapping its side. The branch carries its
// own profile: rect or oval at width2 × height2, round at diameter2.
//
// Same orientation swap as the elbow: the run prism and branch stub
// are built on the `rectSectionAxes` basis, whose height rides local
// +Y. That's world-vertical only when the tee's local Y stays vertical
// (a flat tap off a horizontal trunk). When the tee is rotated so
// local Y lands horizontal, width and height roles swap so the
// physical height keeps reading as the vertical face — without this a
// tee drawn along the perpendicular axis looks squished.
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const branch = ports.find((p) => p.id === 'branch')!
const width2M = node.width2 * INCHES_TO_METERS
const height2M = node.height2 * INCHES_TO_METERS
const buildRunSection = node.shape === 'oval' ? buildOvalSection : buildRectSection
const run = buildRunSection(
inlet.position,
outlet.position,
hingeIsVertical ? widthM : heightM,
hingeIsVertical ? heightM : widthM,
material,
'fitting-run',
)
if (run) group.add(run)
const buildBranchSection = node.shape2 === 'oval' ? buildOvalSection : buildRectSection
const stub =
node.shape2 !== 'round'
? buildBranchSection(
new Vector3(0, 0, 0),
branch.position,
hingeIsVertical ? width2M : height2M,
hingeIsVertical ? height2M : width2M,
material,
'fitting-stub-branch',
)
: buildSection(
new Vector3(0, 0, 0),
branch.position,
(branch.diameter * INCHES_TO_METERS) / 2,
material,
'fitting-stub-branch',
)
if (stub) group.add(stub)
} else if (node.shape !== 'round' && node.fittingType === 'cross') {
// Straight rect / oval run inlet→outlet plus two opposed branch legs
// (±Z) carrying the branch profile — both halves of the run that
// passed through, same size at `width2 × height2` / `diameter2`. Same
// orientation swap as the tee / elbow so the cross stays upright when
// rotated so its local Y lands horizontal.
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const width2M = node.width2 * INCHES_TO_METERS
const height2M = node.height2 * INCHES_TO_METERS
const buildRunSection = node.shape === 'oval' ? buildOvalSection : buildRectSection
const run = buildRunSection(
inlet.position,
outlet.position,
hingeIsVertical ? widthM : heightM,
hingeIsVertical ? heightM : widthM,
material,
'fitting-run',
)
if (run) group.add(run)
const buildBranchSection = node.shape2 === 'oval' ? buildOvalSection : buildRectSection
for (const id of ['branch', 'branch2'] as const) {
const branch = ports.find((p) => p.id === id)!
const stub =
node.shape2 !== 'round'
? buildBranchSection(
new Vector3(0, 0, 0),
branch.position,
hingeIsVertical ? width2M : height2M,
hingeIsVertical ? height2M : width2M,
material,
`fitting-stub-${id}`,
)
: buildSection(
new Vector3(0, 0, 0),
branch.position,
(branch.diameter * INCHES_TO_METERS) / 2,
material,
`fitting-stub-${id}`,
)
if (stub) group.add(stub)
}
} else {
for (const port of ports) {
const stub = buildSection(
new Vector3(0, 0, 0),
port.position,
(port.diameter * INCHES_TO_METERS) / 2,
material,
`fitting-stub-${port.id}`,
)
if (stub) group.add(stub)
}
const junction = new Mesh(new SphereGeometry(radiusMain * 1.02, RADIAL_SEGMENTS, 12), material)
junction.name = 'fitting-junction'
group.add(junction)
}
// Joint trim at each opening. Round legs get a crimp-collar torus just
// proud of the stub; rect legs get a drive-cleat flange — the thin
// raised rim (TDC/S-cleat) real sheet-metal trunk joints wear where a
// section meets a fitting. The plate is centered on the collar plane so
// the rim reads as the seam between fitting and duct. Run legs
// (inlet/outlet) are rect when `shape` is rect; a rect tee's branch is
// rect when `shape2` is rect. Reducers ignore shape.
// Which profile a leg's opening carries: a transition's inlet is its
// rect end regardless of `shape`; reducers are always round; otherwise
// the run legs follow `shape` and a tee's branch follows `shape2`
// (only meaningful when the run itself is non-round).
const legShape = (portId: string): 'round' | 'rect' | 'oval' => {
if (node.fittingType === 'transition') return portId === 'inlet' ? 'rect' : 'round'
if (node.fittingType === 'reducer' || node.shape === 'round') return 'round'
return portId === 'branch' || portId === 'branch2' ? node.shape2 : node.shape
}
// The flange's profile must match the leg it caps: the branch carries
// its own width2 × height2; elbow legs swap width/height roles when the
// fold hinge lies horizontal (riser elbows) — same choice as the
// mitered solid above.
const rectLegProfile = (portId: string): [number, number] => {
if (portId === 'branch' || portId === 'branch2') {
const width2M = node.width2 * INCHES_TO_METERS
const height2M = node.height2 * INCHES_TO_METERS
return hingeIsVertical ? [width2M, height2M] : [height2M, width2M]
}
if (!hingeIsVertical) return [heightM, widthM]
return [widthM, heightM]
}
const FLANGE_LIP_M = 0.02
const FLANGE_THICK_M = 0.012
for (const port of ports) {
const profile = legShape(port.id)
if (profile !== 'round') {
const [w, h] = rectLegProfile(port.id)
const start = port.position.clone().addScaledVector(port.direction, -FLANGE_THICK_M / 2)
const end = port.position.clone().addScaledVector(port.direction, FLANGE_THICK_M / 2)
const buildFlange = profile === 'oval' ? buildOvalSection : buildRectSection
const flange = buildFlange(
start,
end,
w + FLANGE_LIP_M * 2,
h + FLANGE_LIP_M * 2,
material,
`fitting-flange-${port.id}`,
)
if (flange) group.add(flange)
continue
}
const radius = (port.diameter * INCHES_TO_METERS) / 2
const collar = new Mesh(new TorusGeometry(radius, radius * 0.12, 8, RADIAL_SEGMENTS), material)
collar.name = `fitting-collar-${port.id}`
collar.position.copy(port.position)
collar.quaternion.setFromUnitVectors(new Vector3(0, 0, 1), port.direction)
group.add(collar)
}
return group
}
+4
View File
@@ -0,0 +1,4 @@
export { ductFittingDefinition } from './definition'
export { buildDuctFittingGeometry } from './geometry'
export { getDuctFittingPorts } from './ports'
export { DuctFittingNode } from './schema'
@@ -0,0 +1,286 @@
'use client'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
DuctFittingNode,
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, useMemo, useState } from 'react'
import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three'
import {
type Aabb2D,
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
import { buildDuctFittingGeometry } from './geometry'
type Vec3 = [number, number, number]
const GHOST_COLOR = '#818cf8'
const GHOST_OPACITY = 0.5
/** 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
}
/** World-space size + centre offset of `box` after the fitting's euler
* rotation — the footprint box that wraps the oriented geometry. */
function rotatedBounds(box: Box3, rotation: Vec3): { size: Vec3; offset: Vec3 } {
const euler = new Euler(rotation[0], rotation[1], rotation[2])
const min = box.min
const max = box.max
const corners: Vec3[] = [
[min.x, min.y, min.z],
[max.x, min.y, min.z],
[min.x, max.y, min.z],
[min.x, min.y, max.z],
[max.x, max.y, min.z],
[max.x, min.y, max.z],
[min.x, max.y, max.z],
[max.x, max.y, max.z],
]
const lo: Vec3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]
const hi: Vec3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY]
const v = new Vector3()
for (const c of corners) {
v.set(c[0], c[1], c[2]).applyEuler(euler)
lo[0] = Math.min(lo[0], v.x)
lo[1] = Math.min(lo[1], v.y)
lo[2] = Math.min(lo[2], v.z)
hi[0] = Math.max(hi[0], v.x)
hi[1] = Math.max(hi[1], v.y)
hi[2] = Math.max(hi[2], v.z)
}
return {
size: [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]],
offset: [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2],
}
}
/**
* Ghost-preview duplicate / move tool for duct fittings (elbow / tee /
* reducer / transition).
*
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
* inserted into the scene until the commit click. A translucent copy of the
* fitting (built from its real geometry, at its own `rotation`, so an elbow
* / riser stays properly aligned) rides the cursor inside a footprint
* bounding box — the same affordance other items get — and Figma-style
* alignment guides snap the box edges to nearby geometry. The commit click
* calls `createNode`; Esc discards.
*
* **Move** (existing fitting): the real node is hidden while the ghost + box
* track the cursor; commit writes the new `position` and reveals it.
*
* Wired via `def.affordanceTools.move`.
*/
export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
const fitting = node as DuctFittingNode
const originalPosition = (fitting.position ?? [0, 0, 0]) as Vec3
const rotation = (fitting.rotation ?? [0, 0, 0]) as Vec3
const isNew =
typeof node.metadata === 'object' &&
node.metadata !== null &&
!Array.isArray(node.metadata) &&
(node.metadata as Record<string, unknown>).isNew === true
const [cursorPos, setCursorPos] = useState<Vec3>(originalPosition)
// Translucent stand-in built from the fitting's real geometry. Rotation is
// a geometry input (it decides the elbow's profile roles), so the ghost
// matches what lands. Rebuilt only if the source changes.
const ghost = useMemo(() => {
const group = buildDuctFittingGeometry(fitting)
group.traverse((obj) => {
const mesh = obj as Mesh
if ((mesh as { isMesh?: boolean }).isMesh) {
mesh.material = new MeshBasicMaterial({
color: GHOST_COLOR,
transparent: true,
opacity: GHOST_OPACITY,
depthTest: false,
})
mesh.renderOrder = 999
}
obj.layers.set(EDITOR_LAYER)
})
return group
}, [fitting])
// Footprint box that wraps the oriented geometry (size + centre offset),
// measured once from the ghost.
const bounds = useMemo(() => {
const box = new Box3().setFromObject(ghost)
if (box.isEmpty()) return { size: [0.3, 0.3, 0.3] as Vec3, offset: [0, 0, 0] as Vec3 }
return rotatedBounds(box, rotation)
}, [ghost, rotation])
useEffect(() => {
return () => {
ghost.traverse((obj) => {
const mesh = obj as Mesh
if ((mesh as { isMesh?: boolean }).isMesh) {
mesh.geometry?.dispose?.()
const mat = mesh.material as Material | Material[]
if (Array.isArray(mat)) for (const m of mat) m.dispose?.()
else mat?.dispose?.()
}
})
}
}, [ghost])
useEffect(() => {
const nodeId = node.id as AnyNodeId
const [hx, , hz] = [bounds.size[0] / 2, 0, bounds.size[2] / 2]
const [ox, , oz] = bounds.offset
useScene.temporal.getState().pause()
let committed = false
let hasMoved = false
const activatedAt = Date.now()
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
useScene.getState().nodes,
nodeId,
useViewer.getState().selection.levelId ?? node.parentId,
)
// Moving an existing fitting: hide its 3D MESH imperatively (NOT the
// store `visible` flag — the 2D floor plan skips `visible:false` nodes,
// so a store hide makes it 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)
let lastPos: Vec3 = originalPosition
const onMove = (event: GridEvent) => {
const bypass = event.nativeEvent?.shiftKey === true
const snap = bypass ? (v: number) => v : snapToGridStep
let x = snap(event.localPosition[0])
let z = snap(event.localPosition[2])
// Alignment: snap the footprint box edges onto nearby geometry and
// publish guides (Alt / Shift bypass).
if (!bypass) {
const proposed: Aabb2D = {
minX: x + ox - hx,
maxX: x + ox + hx,
minZ: z + oz - hz,
maxZ: z + oz + hz,
}
const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
x += dx
z += dz
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
}
const next: Vec3 = [x, originalPosition[1], z]
if (next[0] !== lastPos[0] || next[2] !== lastPos[2]) triggerSFX('sfx:grid-snap')
lastPos = next
hasMoved = true
setCursorPos(next)
}
const commit = (event: GridEvent) => {
if (committed) return
if (Date.now() - activatedAt < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
if (!hasMoved) {
event.nativeEvent?.stopPropagation?.()
return
}
committed = true
useScene.temporal.getState().resume()
let selectId = nodeId
if (isNew && !useScene.getState().nodes[nodeId]) {
const created = DuctFittingNode.parse({
...(node as Record<string, unknown>),
position: lastPos,
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, { position: lastPos } 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()
}
}, [bounds, isNew, node, originalPosition])
return (
<group>
<primitive object={ghost} position={cursorPos} rotation={rotation} />
<DragBoundingBox
centerY={bounds.offset[1]}
nodeId={node.id}
position={[cursorPos[0] + bounds.offset[0], cursorPos[1], cursorPos[2] + bounds.offset[2]]}
size={bounds.size}
/>
</group>
)
}
export default MoveDuctFittingTool
@@ -0,0 +1,293 @@
import {
type AnyNode,
type AnyNodeId,
type DuctSegmentNode,
type ParametricDescriptor,
useScene,
} from '@pascal-app/core'
import { Vector3 } from 'three'
import {
ductPortDiameterIn,
equivalentDiameterIn,
ovalEquivalentDiameterIn,
rollToContinueAcrossElbow,
} from '../duct-segment/geometry'
import { getDuctFittingPorts } from './ports'
import type { DuctFittingNode } from './schema'
/** Schema bounds for `diameter` / `diameter2`. */
const clampDiameter = (d: number) => Math.min(48, Math.max(2, d))
/** A duct endpoint sitting this close to a collar counts as mated. */
const MATE_TOL_M = 0.03
type DuctMate = { duct: DuctSegmentNode; endIndex: number }
/**
* Ducts whose endpoint sits ON one of the fitting's collars, keyed by
* port id. Auto-minted joints place duct ends exactly on the collar, so
* a tight distance check is enough — no connectivity graph yet.
*/
function matedDucts(fitting: DuctFittingNode): Map<string, DuctMate> {
const mates = new Map<string, DuctMate>()
const ports = getDuctFittingPorts(fitting)
for (const node of Object.values(useScene.getState().nodes)) {
if (node.type !== 'duct-segment') continue
const duct = node as DuctSegmentNode
for (const endIndex of [0, duct.path.length - 1]) {
const p = duct.path[endIndex]
if (!p) continue
for (const port of ports) {
if (mates.has(port.id)) continue
const dx = p[0] - port.position[0]
const dy = p[1] - port.position[1]
const dz = p[2] - port.position[2]
if (dx * dx + dy * dy + dz * dz <= MATE_TOL_M * MATE_TOL_M) {
mates.set(port.id, { duct, endIndex })
}
}
}
}
return mates
}
export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
// Switching the run legs round↔rect flips the whole fitting and sizes
// the new profile off the ducts actually mated to its collars, so the
// fitting lands flush instead of at schema defaults. The tee branch
// follows its own mated duct (or the run shape when nothing is mated);
// `shape2` stays editable afterwards for mixed taps. Rect profiles
// also write their area-equivalent round size back into `diameter` /
// `diameter2`, which drive leg lengths + advertised ports — without
// this the legs keep the stale round size.
derive: (next, patch) => {
const out: Partial<DuctFittingNode> = {}
if ('shape' in patch && next.fittingType !== 'reducer') {
// `next` still carries the pre-edit diameters, so its ports sit
// where the mated ducts end — size off the actual neighbours.
const mates = matedDucts(next)
const run = (mates.get('inlet') ?? mates.get('outlet'))?.duct
if (next.shape !== 'round' && run?.shape === next.shape) {
out.width = run.width
out.height = run.height
} else if (next.shape === 'round' && run && run.shape !== 'rect') {
// Oval runs present their area-equivalent round size.
out.diameter = clampDiameter(ductPortDiameterIn(run))
}
if (next.fittingType === 'tee' || next.fittingType === 'cross') {
// A cross's two branches share one profile — size off whichever
// branch leg has a duct mated (both halves are the same run).
const branchDuct = (mates.get('branch') ?? mates.get('branch2'))?.duct
out.shape2 = branchDuct?.shape ?? next.shape
if (branchDuct && branchDuct.shape !== 'round') {
out.width2 = branchDuct.width
out.height2 = branchDuct.height
} else if (branchDuct) {
out.diameter2 = clampDiameter(ductPortDiameterIn(branchDuct))
}
}
}
// Non-round legs write their area-equivalent round size back into the
// diameters (leg lengths + advertised ports). A transition's inlet is
// always the rect end regardless of `shape`.
const runShape = next.fittingType === 'transition' ? 'rect' : next.shape
if (runShape !== 'round' && next.fittingType !== 'reducer') {
const equivalent = runShape === 'oval' ? ovalEquivalentDiameterIn : equivalentDiameterIn
out.diameter = clampDiameter(equivalent(out.width ?? next.width, out.height ?? next.height))
}
const shape2 = out.shape2 ?? next.shape2
if ((next.fittingType === 'tee' || next.fittingType === 'cross') && shape2 !== 'round') {
const equivalent2 = shape2 === 'oval' ? ovalEquivalentDiameterIn : equivalentDiameterIn
out.diameter2 = clampDiameter(
equivalent2(out.width2 ?? next.width2, out.height2 ?? next.height2),
)
}
return out
},
// Resizing a fitting moves its collars (leg lengths follow the
// diameters) — re-trim each mated duct's endpoint onto the collar's
// new position so metal keeps meeting metal instead of overlapping
// one neighbour and gapping off another.
reconcile: (prev, next) => {
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
const newPorts = new Map(getDuctFittingPorts(next).map((p) => [p.id, p]))
const mates = matedDucts(prev)
for (const [portId, mate] of mates) {
const target = newPorts.get(portId)
if (!target) continue
const end = mate.duct.path[mate.endIndex]
if (!end) continue
const data: Partial<DuctSegmentNode> = {}
const dx = end[0] - target.position[0]
const dy = end[1] - target.position[1]
const dz = end[2] - target.position[2]
if (dx * dx + dy * dy + dz * dz >= 1e-12) {
const path = mate.duct.path.map((p) => [...p] as [number, number, number])
path[mate.endIndex] = [...target.position]
data.path = path
}
// Steep rect / oval runs also re-derive their cross-section roll
// so a riser's profile stays continuous through the fitting (same
// continuity the draw tool computes; runs flipped to rect after
// drawing never got it). Horizontal runs are left alone — their
// roll-0 orientation is canonical and re-deriving it from a
// possibly-stale riser roll would corrupt it.
if (next.shape !== 'round' && mate.duct.shape !== 'round') {
const away = mate.duct.path[mate.endIndex === 0 ? 1 : mate.duct.path.length - 2]
const source = getDuctFittingPorts(next).find(
(p) => p.id !== portId && p.id !== 'branch' && p.id !== 'branch2',
)
if (away && source) {
const newDir = new Vector3(away[0] - end[0], away[1] - end[1], away[2] - end[2])
if (newDir.lengthSq() >= 1e-10) {
newDir.normalize()
if (Math.abs(newDir.y) >= Math.SQRT1_2) {
const srcMate = mates.get(source.id)
const srcRoll = srcMate && srcMate.duct.shape !== 'round' ? srcMate.duct.roll : 0
const srcDir = new Vector3(...source.direction)
const roll = rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
if (Math.abs(roll - mate.duct.roll) > 1e-6) data.roll = roll
}
}
}
}
if (Object.keys(data).length > 0) updates.push({ id: mate.duct.id, data })
}
return updates
},
groups: [
{
label: 'Fitting',
fields: [
{
key: 'fittingType',
kind: 'enum',
options: ['elbow', 'tee', 'cross', 'reducer', 'transition'],
display: 'segmented',
},
{
key: 'angle',
kind: 'number',
unit: '°',
min: 15,
max: 90,
step: 15,
visibleIf: (n) => n.fittingType === 'elbow',
},
{
key: 'branchAngle',
kind: 'number',
unit: '°',
min: 45,
max: 135,
step: 15,
visibleIf: (n) => n.fittingType === 'tee',
},
{
key: 'system',
kind: 'enum',
options: ['supply', 'return'],
display: 'segmented',
},
],
},
{
label: 'Connections',
fields: [
{
key: 'shape',
kind: 'enum',
options: ['round', 'rect', 'oval'],
display: 'segmented',
// Reducers are always round; a transition's ends are fixed
// (rect inlet, round outlet) so there's nothing to pick.
visibleIf: (n) => n.fittingType !== 'reducer' && n.fittingType !== 'transition',
},
{
key: 'diameter',
kind: 'number',
unit: 'in',
min: 4,
max: 24,
step: 1,
// Hidden when the run legs are rect / oval (transition's inlet
// always is) — `diameter` is then derived as the area equivalent.
visibleIf: (n) =>
n.fittingType === 'reducer' || (n.fittingType !== 'transition' && n.shape === 'round'),
},
{
key: 'width',
kind: 'number',
unit: 'in',
min: 4,
max: 60,
step: 1,
visibleIf: (n) =>
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
},
{
key: 'height',
kind: 'number',
unit: 'in',
min: 3,
max: 40,
step: 1,
visibleIf: (n) =>
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
},
{
key: 'shape2',
kind: 'enum',
options: ['round', 'rect', 'oval'],
display: 'segmented',
visibleIf: (n) => n.fittingType === 'tee' || n.fittingType === 'cross',
},
{
key: 'diameter2',
kind: 'number',
unit: 'in',
min: 4,
max: 24,
step: 1,
visibleIf: (n) =>
n.fittingType !== 'elbow' &&
(n.fittingType !== 'tee' || n.shape2 === 'round') &&
(n.fittingType !== 'cross' || n.shape2 === 'round'),
},
{
key: 'width2',
kind: 'number',
unit: 'in',
min: 4,
max: 60,
step: 1,
visibleIf: (n) =>
(n.fittingType === 'tee' || n.fittingType === 'cross') && n.shape2 !== 'round',
},
{
key: 'height2',
kind: 'number',
unit: 'in',
min: 3,
max: 40,
step: 1,
visibleIf: (n) =>
(n.fittingType === 'tee' || n.fittingType === 'cross') && n.shape2 !== 'round',
},
{
key: 'ductMaterial',
kind: 'enum',
options: ['sheet-metal', 'flex', 'duct-board'],
},
],
},
{
label: 'Placement',
fields: [
{ key: 'position', kind: 'vec3' },
{ key: 'rotation', kind: 'vec3' },
],
},
],
}
+147
View File
@@ -0,0 +1,147 @@
import type { NodePort } from '@pascal-app/core'
import { Euler, Vector3 } from 'three'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import type { DuctFittingNode } from './schema'
/**
* Collar stub length in meters — how far each port sticks out from the
* fitting's junction center. Scales with the duct so big trunks get
* proportionally longer collars, with a floor so 4" fittings stay
* grabbable.
*/
export function fittingLegLength(diameterInches: number): number {
const radius = (diameterInches * INCHES_TO_METERS) / 2
return Math.max(0.14, radius * 2.5)
}
type LocalPort = { id: string; position: Vector3; direction: Vector3; diameter: number }
/**
* Ports in the fitting's LOCAL frame (origin at the junction center,
* before `position`/`rotation`). Shared by `def.ports` (which transforms
* them to level-local) and the geometry builder (which draws a stub per
* port).
*
* Conventions documented on the schema: elbow inlet -X / outlet turned
* `angle`° in XZ; tee run along X with the branch at `branchAngle`° off
* the +X outlet axis (90° → +Z square tee, 45° → downstream lateral,
* 135° → upstream lateral); reducer -X → +X.
*/
export function localFittingPorts(node: DuctFittingNode): LocalPort[] {
const main = fittingLegLength(node.diameter)
if (node.fittingType === 'elbow') {
const theta = (node.angle * Math.PI) / 180
const outDir = new Vector3(Math.cos(theta), 0, Math.sin(theta))
return [
{
id: 'inlet',
position: new Vector3(-main, 0, 0),
direction: new Vector3(-1, 0, 0),
diameter: node.diameter,
},
{
id: 'outlet',
position: outDir.clone().multiplyScalar(main),
direction: outDir,
diameter: node.diameter,
},
]
}
if (node.fittingType === 'tee') {
const branch = fittingLegLength(node.diameter2)
// Branch leans `branchAngle`° off the +X outlet axis in XZ: 90° is a
// square tap (+Z), shallower angles sweep the branch downstream
// toward the outlet so the lateral merges with the run's flow, and
// angles past 90° lean it upstream toward the inlet (cos goes
// negative, swinging the collar to -X).
const phi = (node.branchAngle * Math.PI) / 180
const branchDir = new Vector3(Math.cos(phi), 0, Math.sin(phi))
return [
{
id: 'inlet',
position: new Vector3(-main, 0, 0),
direction: new Vector3(-1, 0, 0),
diameter: node.diameter,
},
{
id: 'outlet',
position: new Vector3(main, 0, 0),
direction: new Vector3(1, 0, 0),
diameter: node.diameter,
},
{
id: 'branch',
position: branchDir.clone().multiplyScalar(branch),
direction: branchDir,
diameter: node.diameter2,
},
]
}
if (node.fittingType === 'cross') {
// Four-way junction: run inlet -X / outlet +X at the run profile,
// two opposed branches square to the run along ±Z at the branch
// profile. Both branches share `diameter2` (one drawn run passes
// straight through, so its two halves are the same size).
const branch = fittingLegLength(node.diameter2)
return [
{
id: 'inlet',
position: new Vector3(-main, 0, 0),
direction: new Vector3(-1, 0, 0),
diameter: node.diameter,
},
{
id: 'outlet',
position: new Vector3(main, 0, 0),
direction: new Vector3(1, 0, 0),
diameter: node.diameter,
},
{
id: 'branch',
position: new Vector3(0, 0, branch),
direction: new Vector3(0, 0, 1),
diameter: node.diameter2,
},
{
id: 'branch2',
position: new Vector3(0, 0, -branch),
direction: new Vector3(0, 0, -1),
diameter: node.diameter2,
},
]
}
// reducer / transition: straight-through, inlet at `diameter` (the
// transition's rect end advertises its area-equivalent round size),
// outlet at `diameter2`.
return [
{
id: 'inlet',
position: new Vector3(-main, 0, 0),
direction: new Vector3(-1, 0, 0),
diameter: node.diameter,
},
{
id: 'outlet',
position: new Vector3(main, 0, 0),
direction: new Vector3(1, 0, 0),
diameter: node.diameter2,
},
]
}
/** `def.ports` — local ports transformed into level-local space. */
export function getDuctFittingPorts(node: DuctFittingNode): NodePort[] {
const euler = new Euler(node.rotation[0], node.rotation[1], node.rotation[2])
const offset = new Vector3(node.position[0], node.position[1], node.position[2])
return localFittingPorts(node).map((port) => {
const position = port.position.clone().applyEuler(euler).add(offset)
const direction = port.direction.clone().applyEuler(euler).normalize()
return {
id: port.id,
position: [position.x, position.y, position.z] as const,
direction: [direction.x, direction.y, direction.z] as const,
diameter: port.diameter,
system: node.system,
}
})
}
@@ -0,0 +1 @@
export { DuctFittingNode } from '@pascal-app/core'
@@ -0,0 +1,43 @@
'use client'
import { type AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { cycleRotationAxis } from '../shared/fitting-rotation'
/**
* Selection-time rotation support for placed fittings, mounted by the
* editor's SelectionAffordanceManager (`def.affordanceTools.selection`).
* The R/T rotation itself lives in `def.keyboardActions` (the editor's
* keyboard hook dispatches it); this contributes the piece that hook
* can't: **Alt cycles the active rotation axis** while a single fitting
* is selected. The axis lives on `useEditor.rotationAxis`, which the
* floating action menu reads to show the axis pill above the selected
* fitting — so this component renders nothing.
*/
const DuctFittingSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const hasSelectedFitting = useScene((s) => {
if (selectedIds.length !== 1) return false
return s.nodes[selectedIds[0] as AnyNodeId]?.type === 'duct-fitting'
})
useEffect(() => {
if (!hasSelectedFitting) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Alt' || e.repeat) return
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
e.preventDefault()
cycleRotationAxis()
}
// Bubble phase — when the placement tool is active its capture-phase
// handler stops propagation, so the two never double-cycle.
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [hasSelectedFitting])
return null
}
export default DuctFittingSelectionAffordance
+253
View File
@@ -0,0 +1,253 @@
'use client'
import { DuctFittingNode, emitter, type GridEvent, useScene } from '@pascal-app/core'
import { CursorSphere, EDITOR_LAYER, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Euler, Quaternion, Vector3 } from 'three'
import {
AXIS_VECTORS,
cycleRotationAxis,
getRotationAxis,
ROTATE_STEP_RAD,
} from '../shared/fitting-rotation'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import {
collectScenePorts,
DUCT_PORT_SYSTEMS,
findNearestPortXZ,
type ScenePort,
} from '../shared/ports'
import { ductFittingDefinition } from './definition'
import { buildDuctFittingGeometry } from './geometry'
import { localFittingPorts } from './ports'
/** Snap radius (meters, XZ) for mating onto an existing port. */
const PORT_SNAP_RADIUS_M = 0.5
const PREVIEW_OPACITY = 0.55
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
type Placement = {
position: [number, number, number]
rotation: [number, number, number]
snapPort: ScenePort | null
}
/**
* Resolve where the fitting would land for a cursor at `raw`:
* - Near an existing port → mate: orientation aligns the inlet onto
* the port (plus the user's manual R/T rotation, pivoting around
* the inlet collar so it stays on the port while the body sweeps).
* - Otherwise → grid-snapped free placement on the floor, manual
* rotation only.
*/
function resolvePlacement(
raw: [number, number, number],
previewNode: DuctFittingNode,
gridStep: number,
manualQuat: Quaternion,
): Placement {
const port = findNearestPortXZ(
raw,
collectScenePorts({ systems: DUCT_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (port) {
const direction = new Vector3(...port.direction).normalize()
// Local +X must map onto the port's outward direction so the inlet
// (local -X) faces back into the run it's joining. Manual rotation
// composes in the world frame on top of the mate orientation.
const mate = new Quaternion().setFromUnitVectors(new Vector3(1, 0, 0), direction)
const final = manualQuat.clone().multiply(mate)
const inlet = localFittingPorts(previewNode)[0]!
const inletWorldOffset = inlet.position.clone().applyQuaternion(final)
const position = new Vector3(...port.position).sub(inletWorldOffset)
const euler = new Euler().setFromQuaternion(final)
return {
position: [position.x, position.y, position.z],
rotation: [euler.x, euler.y, euler.z],
snapPort: port,
}
}
const euler = new Euler().setFromQuaternion(manualQuat)
return {
position: [snap(raw[0], gridStep), 0, snap(raw[2], gridStep)],
rotation: [euler.x, euler.y, euler.z],
snapPort: null,
}
}
/**
* Click-place tool for duct fittings (elbow / tee / reducer).
*
* A translucent ghost of the fitting follows the cursor. Within snap
* range of any scene port (duct run ends, other fittings' collars) the
* ghost jumps onto the port — position AND orientation — so one click
* mates the fitting onto the run.
*
* Rotation while placing: **R / T** turn the ghost ±45° around the
* active world axis; **Alt** cycles the axis (Y → X → Z). The HUD badge
* above the ghost shows the current axis. When snapped to a port the
* rotation pivots around the inlet collar so the joint stays mated.
* Handlers run in the capture phase so R doesn't also spin whatever
* node happens to be selected.
*/
const DuctFittingTool = () => {
const activeLevelId = useViewer((s) => s.selection.levelId)
const [placement, setPlacement] = useState<Placement | null>(null)
const axis = useEditor((s) => s.rotationAxis)
// Accumulated manual rotation from R/T presses. Ref (not state) so the
// emitter callbacks always read the latest without re-subscribing; a
// placement recompute is triggered explicitly after each change.
const manualQuatRef = useRef(new Quaternion())
// Last raw cursor position so a key press can recompute the placement
// without waiting for the next mouse move.
const lastRawRef = useRef<[number, number, number] | null>(null)
// Ghost matches exactly what a click creates (the kind's defaults).
const previewNode = useMemo(
() => DuctFittingNode.parse({ ...ductFittingDefinition.defaults(), name: 'Duct fitting' }),
[],
)
const ghost = useMemo(() => {
const group = buildDuctFittingGeometry(previewNode)
group.traverse((child) => {
// Overlay layer keeps the placement ghost out of the ink / SSGI
// buffers and the thumbnail export, like every other tool preview.
child.layers.set(EDITOR_LAYER)
const mesh = child as { material?: { transparent: boolean; opacity: number } }
if (mesh.material) {
mesh.material.transparent = true
mesh.material.opacity = PREVIEW_OPACITY
}
})
return group
}, [previewNode])
useEffect(() => {
if (!activeLevelId) return
const recompute = () => {
const raw = lastRawRef.current
if (!raw) return
setPlacement(
resolvePlacement(
raw,
previewNode,
useEditor.getState().gridSnapStep,
manualQuatRef.current,
),
)
}
const onMove = (event: GridEvent) => {
lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]]
recompute()
}
const onClick = (event: GridEvent) => {
lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]]
const { position, rotation } = resolvePlacement(
lastRawRef.current,
previewNode,
useEditor.getState().gridSnapStep,
manualQuatRef.current,
)
const fitting = DuctFittingNode.parse({
...ductFittingDefinition.defaults(),
name: 'Duct fitting',
position,
rotation,
})
useScene.getState().createNode(fitting, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [fitting.id] })
triggerSFX('sfx:item-place')
}
const onKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
const key = e.key
if (key === 'r' || key === 'R' || key === 't' || key === 'T') {
// Capture-phase + stopPropagation so the editor's selection-rotate
// R handler doesn't also fire while the placement tool owns R.
e.preventDefault()
e.stopPropagation()
const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1
const turn = new Quaternion().setFromAxisAngle(
AXIS_VECTORS[getRotationAxis()],
steps * ROTATE_STEP_RAD,
)
manualQuatRef.current = turn.multiply(manualQuatRef.current)
triggerSFX('sfx:item-rotate')
recompute()
} else if (key === 'Alt' && !e.repeat) {
e.preventDefault()
e.stopPropagation()
cycleRotationAxis()
}
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', onClick)
window.addEventListener('keydown', onKeyDown, true)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', onClick)
window.removeEventListener('keydown', onKeyDown, true)
}
}, [activeLevelId, previewNode])
if (!activeLevelId || !placement) return null
return (
<LevelOffsetGroup>
{/* Same ground ring + vertical line + tool-icon badge the duct draw
tool shows in 3D (icon resolved from the active `duct-fitting`
structure-tools entry). In 2D the floorplan overlay draws this for
every tool; in 3D each tool renders its own. */}
<CursorSphere position={placement.position} />
<group position={placement.position} rotation={placement.rotation}>
<primitive object={ghost} />
</group>
{/* Rotation HUD — active axis + key hints, pinned above the ghost. */}
<Html
center
position={[placement.position[0], placement.position[1] + 0.5, placement.position[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
{/* Same pill shell as DimensionPill so the placement HUD matches
the drawing / dragging readouts. */}
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
<span className="font-medium text-foreground">Axis {axis.toUpperCase()}</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground">R/T rotate</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground"> axis</span>
</div>
</Html>
{/* Port-snap halo so the user sees the click will mate, not free-place. */}
{placement.snapPort && (
<mesh
layers={EDITOR_LAYER}
position={placement.snapPort.position as [number, number, number]}
>
<sphereGeometry args={[0.18, 24, 16]} />
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
</mesh>
)}
</LevelOffsetGroup>
)
}
export default DuctFittingTool
@@ -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 }
}
+466
View File
@@ -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
}
+3
View File
@@ -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
+989
View File
@@ -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 (1590°, 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 1590° 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
@@ -0,0 +1,101 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildDuctTerminalFloorplan } from './floorplan'
import { buildDuctTerminalGeometry } from './geometry'
import { ductTerminalParametrics } from './parametrics'
import { getDuctTerminalPorts } from './ports'
import { DuctTerminalNode } from './schema'
/**
* Phase 3 of the HVAC node system — duct terminals: supply registers,
* ceiling diffusers, return grilles. The end of the air loop. One typed
* port at the collar (mount-aware direction) so duct runs end onto a
* terminal like any other port.
*
* Composition: `def.geometry` only. Yaw-only rotation — the editor's
* default R-rotate works on a selected terminal.
*/
export const ductTerminalDefinition: NodeDefinition<typeof DuctTerminalNode> = {
kind: 'duct-terminal',
schemaVersion: 1,
schema: DuctTerminalNode,
category: 'utility',
distributionRole: 'terminal',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: 0,
terminalType: 'supply-register',
mount: 'floor',
width: 0.3,
depth: 0.15,
collarShape: 'round',
collarDiameter: 6,
collarWidth: 10,
collarHeight: 6,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
movable: { axes: ['x', 'z'], gridSnap: true, portSnap: { systems: ['supply', 'return'] } },
rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] },
duplicable: true,
deletable: true,
// A floor register rests on top of whatever slab is under it — the
// generic FloorElevationSystem lifts its mesh Y by the slab's elevation
// so the face sits on the slab surface instead of sinking into it.
// Ceiling / wall mounts derive their Y elsewhere, so `applies` skips them.
floorPlaced: {
footprint: (node) => {
const t = node as DuctTerminalNode
return { dimensions: [t.width, 0, t.depth], rotation: [0, t.rotation, 0] }
},
applies: (node) => (node as DuctTerminalNode).mount === 'floor',
},
},
parametrics: ductTerminalParametrics,
geometry: buildDuctTerminalGeometry,
geometryKey: (n) =>
JSON.stringify([
n.terminalType,
n.mount,
n.width,
n.depth,
n.collarShape,
n.collarDiameter,
n.collarWidth,
n.collarHeight,
]),
ports: getDuctTerminalPorts,
floorplan: buildDuctTerminalFloorplan,
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Place register' },
{ key: 'M', label: 'Mount: floor / ceiling / wall' },
{ key: 'R / T', label: 'Rotate ±45° (floor / ceiling)' },
{ key: 'Shift', label: 'Smooth (no grid snap)' },
{ key: 'Esc', label: 'Exit' },
],
presentation: {
label: 'Register',
description:
'Duct terminal — supply register, ceiling diffuser, or return grille. Duct runs end at its collar.',
icon: { kind: 'url', src: '/icons/registers.png' },
paletteSection: 'structure',
paletteOrder: 93,
},
mcp: {
description:
'A duct terminal (supply register, ceiling diffuser, or return grille) with a single collar port. Mount (floor/ceiling/wall) drives the face orientation and collar direction.',
},
}
@@ -0,0 +1,73 @@
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
import { terminalSystem } from './ports'
import type { DuctTerminalNode } from './schema'
const SUPPLY_COLOR = '#d4825a'
const RETURN_COLOR = '#5a8ad4'
const FRAME_STROKE = '#6b7280'
const FACE_FILL = '#e5e7eb'
/**
* Floor-plan symbol for a duct terminal: the face rectangle (rotated by
* yaw) with the conventional register cross-slats hinted as a single
* mid-line, tinted by system. Wall mounts render the same footprint —
* the face projects to a thin strip, which is close enough for plan
* reading at this stage.
*/
export function buildDuctTerminalFloorplan(
node: DuctTerminalNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const [cx, , cz] = node.position
const cos = Math.cos(node.rotation)
const sin = Math.sin(node.rotation)
const hw = node.width / 2
const hd = (node.mount === 'wall' ? 0.06 : node.depth) / 2
const corner = (lx: number, lz: number): FloorplanPoint => [
cx + lx * cos + lz * sin,
cz - lx * sin + lz * cos,
]
const points: FloorplanPoint[] = [
corner(-hw, -hd),
corner(hw, -hd),
corner(hw, hd),
corner(-hw, hd),
]
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
const accent = terminalSystem(node) === 'supply' ? SUPPLY_COLOR : RETURN_COLOR
const stroke = showSelectedChrome && palette ? palette.selectedStroke : FRAME_STROKE
const mid1 = corner(-hw * 0.8, 0)
const mid2 = corner(hw * 0.8, 0)
const children: FloorplanGeometry[] = [
{
kind: 'polygon',
points,
fill: FACE_FILL,
stroke,
strokeWidth: showSelectedChrome ? 0.025 : 0.015,
opacity: 0.92,
},
{
kind: 'line',
x1: mid1[0],
y1: mid1[1],
x2: mid2[0],
y2: mid2[1],
stroke: accent,
strokeWidth: 1.5,
vectorEffect: 'non-scaling-stroke',
opacity: 0.9,
},
]
if (showSelectedChrome) {
children.push({ kind: 'move-handle', point: [cx, cz] })
}
return { kind: 'group', children }
}
@@ -0,0 +1,105 @@
import {
BoxGeometry,
type BufferGeometry,
CylinderGeometry,
Group,
Mesh,
MeshStandardMaterial,
Vector3,
} from 'three'
import { createOvalSectionGeometry, INCHES_TO_METERS } from '../duct-segment/geometry'
import { COLLAR_LENGTH, mountQuaternion, terminalSystem } from './ports'
import type { DuctTerminalNode } from './schema'
const RADIAL_SEGMENTS = 20
/** Radial clearance (meters) the collar sleeve carries over the duct's
* nominal cross-section, so a run leaving at the advertised size nests
* inside the sleeve instead of z-fighting its faces. ~5 mm ≈ a slip joint. */
const COLLAR_CLEARANCE_M = 0.005
const FRAME_COLOR = '#e3e5e8'
const SLAT_SUPPLY_COLOR = '#cdd1d6'
const SLAT_RETURN_COLOR = '#aeb4bb'
const COLLAR_COLOR = '#c2c2c2'
/**
* Pure geometry builder for a duct terminal, in the node's LOCAL frame —
* `<ParametricNodeRenderer>` applies `position` + yaw, and the builder
* applies the mount orientation itself.
*
* Canonical (floor) frame before the mount rotation: face plate lying
* in XZ at y=0 with its normal +Y, louver slats just above it, collar
* cylinder going -Y toward the duct side. Ceiling mounts flip it; wall
* mounts stand it up facing +Z.
*/
export function buildDuctTerminalGeometry(node: DuctTerminalNode): Group {
const group = new Group()
const oriented = new Group()
oriented.quaternion.copy(mountQuaternion(node.mount))
group.add(oriented)
const frameMaterial = new MeshStandardMaterial({
color: FRAME_COLOR,
metalness: 0.4,
roughness: 0.5,
})
const slatMaterial = new MeshStandardMaterial({
color: terminalSystem(node) === 'return' ? SLAT_RETURN_COLOR : SLAT_SUPPLY_COLOR,
metalness: 0.45,
roughness: 0.55,
})
const frameThickness = 0.018
const frame = new Mesh(new BoxGeometry(node.width, frameThickness, node.depth), frameMaterial)
frame.name = 'terminal-frame'
frame.position.set(0, frameThickness / 2, 0)
oriented.add(frame)
// Louver slats across the face. Return grilles read denser; diffusers
// get concentric-ish wide slats via the same simple pattern.
const slatCount = node.terminalType === 'return-grille' ? 7 : 4
const innerDepth = node.depth * 0.82
const slatDepth = (innerDepth / slatCount) * 0.55
for (let i = 0; i < slatCount; i++) {
const slat = new Mesh(new BoxGeometry(node.width * 0.86, 0.006, slatDepth), slatMaterial)
slat.name = `terminal-slat-${i}`
const z = -innerDepth / 2 + (innerDepth / slatCount) * (i + 0.5)
slat.position.set(0, frameThickness + 0.002, z)
slat.rotation.x = node.terminalType === 'diffuser' ? 0 : -0.5
oriented.add(slat)
}
// Collar runs along -Y from the face toward the duct. Round is a
// cylinder; rect a box; oval the flat-oval prism (its extrude basis
// already puts the run length on Y, matching the collar axis). The
// sleeve is grown one clearance on every side so a duct run leaving at
// the advertised size nests inside it instead of z-fighting its faces.
const grow = 2 * COLLAR_CLEARANCE_M
let collarGeom: BufferGeometry
if (node.collarShape === 'rect') {
collarGeom = new BoxGeometry(
node.collarWidth * INCHES_TO_METERS + grow,
COLLAR_LENGTH,
node.collarHeight * INCHES_TO_METERS + grow,
)
} else if (node.collarShape === 'oval') {
collarGeom = createOvalSectionGeometry(
node.collarWidth * INCHES_TO_METERS + grow,
node.collarHeight * INCHES_TO_METERS + grow,
COLLAR_LENGTH,
)
} else {
const radius = (node.collarDiameter * INCHES_TO_METERS + grow) / 2
collarGeom = new CylinderGeometry(radius, radius, COLLAR_LENGTH, RADIAL_SEGMENTS, 1, false)
}
const collar = new Mesh(
collarGeom,
new MeshStandardMaterial({ color: COLLAR_COLOR, metalness: 0.6, roughness: 0.4 }),
)
collar.name = 'terminal-collar'
collar.position.copy(new Vector3(0, -COLLAR_LENGTH / 2, 0))
oriented.add(collar)
return group
}
@@ -0,0 +1,4 @@
export { ductTerminalDefinition } from './definition'
export { buildDuctTerminalGeometry } from './geometry'
export { getDuctTerminalPorts } from './ports'
export { DuctTerminalNode } from './schema'
@@ -0,0 +1,72 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { DuctTerminalNode } from './schema'
export const ductTerminalParametrics: ParametricDescriptor<DuctTerminalNode> = {
groups: [
{
label: 'Terminal',
fields: [
{
key: 'terminalType',
kind: 'enum',
options: ['supply-register', 'diffuser', 'return-grille'],
},
{
key: 'mount',
kind: 'enum',
options: ['floor', 'ceiling', 'wall'],
display: 'segmented',
},
],
},
{
label: 'Face',
fields: [
{ key: 'width', kind: 'number', unit: 'm', min: 0.1, max: 1.5, step: 0.05 },
{ key: 'depth', kind: 'number', unit: 'm', min: 0.05, max: 1.5, step: 0.05 },
],
},
{
label: 'Collar',
fields: [
{
key: 'collarShape',
kind: 'enum',
options: ['round', 'rect', 'oval'],
display: 'segmented',
},
{
key: 'collarDiameter',
kind: 'number',
unit: 'in',
min: 4,
max: 20,
step: 1,
visibleIf: (n) => n.collarShape === 'round',
},
{
key: 'collarWidth',
kind: 'number',
unit: 'in',
min: 4,
max: 20,
step: 1,
visibleIf: (n) => n.collarShape !== 'round',
},
{
key: 'collarHeight',
kind: 'number',
unit: 'in',
min: 3,
max: 20,
step: 1,
visibleIf: (n) => n.collarShape !== 'round',
},
],
},
{
label: 'Placement',
fields: [{ key: 'position', kind: 'vec3' }],
},
],
}
+64
View File
@@ -0,0 +1,64 @@
import type { NodePort } from '@pascal-app/core'
import { Euler, Quaternion, Vector3 } from 'three'
import { equivalentDiameterIn, ovalEquivalentDiameterIn } from '../duct-segment/geometry'
import type { DuctTerminalNode } from './schema'
/** Collar stub length in meters behind the face. */
export const COLLAR_LENGTH = 0.12
/**
* Mount orientation: rotation applied to the canonical floor frame
* (face normal +Y, collar pointing -Y). Ceiling flips it; wall stands
* it up so the face looks along +Z and the collar points -Z (into the
* wall). Yaw is applied on top by the renderer / port transform.
*/
export function mountQuaternion(mount: DuctTerminalNode['mount']): Quaternion {
if (mount === 'ceiling') return new Quaternion().setFromEuler(new Euler(Math.PI, 0, 0))
if (mount === 'wall') return new Quaternion().setFromEuler(new Euler(Math.PI / 2, 0, 0))
return new Quaternion()
}
export function terminalSystem(node: DuctTerminalNode): 'supply' | 'return' {
return node.terminalType === 'return-grille' ? 'return' : 'supply'
}
/**
* Diameter (inches) the collar advertises at its port. Rect / oval
* collars report the area-equivalent round diameter so round runs mate
* at a sensible size — the same convention duct segments use.
*/
export function collarPortDiameterIn(node: DuctTerminalNode): number {
if (node.collarShape === 'rect') return equivalentDiameterIn(node.collarWidth, node.collarHeight)
if (node.collarShape === 'oval') {
return ovalEquivalentDiameterIn(node.collarWidth, node.collarHeight)
}
return node.collarDiameter
}
/**
* `def.ports` — the single collar port in level-local space. Canonical
* frame: collar tip at (0, -COLLAR_LENGTH, 0) pointing -Y (away from the
* face); mount + yaw + position transform it. Direction points OUT of
* the terminal — i.e. toward the duct that should connect.
*/
export function getDuctTerminalPorts(node: DuctTerminalNode): NodePort[] {
const transform = new Quaternion()
.setFromEuler(new Euler(0, node.rotation, 0))
.multiply(mountQuaternion(node.mount))
const position = new Vector3(0, -COLLAR_LENGTH, 0)
.applyQuaternion(transform)
.add(new Vector3(node.position[0], node.position[1], node.position[2]))
const direction = new Vector3(0, -1, 0).applyQuaternion(transform).normalize()
return [
{
id: 'collar',
position: [position.x, position.y, position.z] as const,
direction: [direction.x, direction.y, direction.z] as const,
diameter: collarPortDiameterIn(node),
system: terminalSystem(node),
shape: node.collarShape,
width: node.collarWidth,
height: node.collarHeight,
},
]
}
@@ -0,0 +1 @@
export { DuctTerminalNode } from '@pascal-app/core'
+443
View File
@@ -0,0 +1,443 @@
'use client'
import {
type AnyNodeId,
DuctTerminalNode,
emitter,
pointInPolygon,
resolveLevelId,
sceneRegistry,
useScene,
type WallEvent,
} from '@pascal-app/core'
import {
CursorSphere,
getFloorStackPreviewPosition,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Euler, Matrix3, Matrix4, Plane, Quaternion, Raycaster, Vector2, Vector3 } from 'three'
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports'
import { ductTerminalDefinition } from './definition'
import { buildDuctTerminalGeometry } from './geometry'
import { COLLAR_LENGTH, mountQuaternion } from './ports'
const PREVIEW_OPACITY = 0.55
/** R/T yaw step — 45°. */
const ROTATE_STEP_RAD = Math.PI / 4
/** Fallback height (meters) for a ceiling node that carries no `height`. */
const DEFAULT_CEILING_HEIGHT = 2.5
/** Snap radius (meters) for mating the collar onto a nearby duct port. */
const PORT_SNAP_RADIUS_M = 0.5
type Mount = DuctTerminalNode['mount']
const MOUNT_CYCLE: Mount[] = ['floor', 'ceiling', 'wall']
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
/**
* Collar-port offset from the node origin for a given mount + yaw, in
* level-local meters — the same transform `def.ports` applies, so the
* placement tool can predict where the collar lands and shift the whole
* terminal to mate it onto a duct port.
*/
function collarOffset(mount: Mount, yaw: number): Vector3 {
const transform = new Quaternion()
.setFromEuler(new Euler(0, yaw, 0))
.multiply(mountQuaternion(mount))
return new Vector3(0, -COLLAR_LENGTH, 0).applyQuaternion(transform)
}
/** The active level's mesh, or null. Carries the building transform plus the
* level's stacked elevation — the frame terminals are stored and parented in,
* so cursor hits resolve to true level-local coords on every floor. */
function activeLevelMesh() {
const levelId = useViewer.getState().selection.levelId
return levelId ? (sceneRegistry.nodes.get(levelId as AnyNodeId) ?? null) : null
}
type Placement = {
position: [number, number, number]
/** Yaw radians applied to the ghost / committed node. */
yaw: number
/** Mount the ghost / committed node uses — inferred from the mated port
* when snapped, else the user's manual M selection. */
mount: Mount
/** True when the collar mated onto a nearby duct port (magnetic snap). */
snapped?: boolean
}
/** Direction is "vertical" when its Y component dominates this much. */
const VERTICAL_DOT = 0.7
/**
* Pick the mount that makes a collar mate onto a duct port pointing
* `dir` (the port's outward direction). The collar leaves the face along
* Y in the canonical frame, so the mount rotation must turn Y to face
* *into* the port (i.e. opposite `dir`):
* - port pointing up (a riser top) → collar must point down → **floor**
* - port pointing down (a ceiling drop) → collar points up → **ceiling**
* - port horizontal (a wall stub) → **wall**, yawed so the collar runs
* back along the port. `lockYaw` is set only for wall (floor / ceiling
* yaw is free — the user keeps spinning the face with R/T).
*/
function inferMountFromPort(dir: readonly [number, number, number]): {
mount: Mount
lockYaw: number | null
} {
const v = new Vector3(dir[0], dir[1], dir[2])
if (v.lengthSq() < 1e-8) return { mount: 'floor', lockYaw: null }
v.normalize()
if (v.y > VERTICAL_DOT) return { mount: 'floor', lockYaw: null }
if (v.y < -VERTICAL_DOT) return { mount: 'ceiling', lockYaw: null }
// Wall collar dir after mount + yaw is (sin yaw, 0, cos yaw); set it
// opposite the port so the collar runs back into the wall stub.
return { mount: 'wall', lockYaw: Math.atan2(v.x, v.z) }
}
/**
* If a duct port is within snap range of `position` (XZ — ports hang at
* duct height, the grid hit rides the floor), mate the register onto it:
* the port's direction *picks the mount* (floor / ceiling / wall) and, for
* walls, the yaw; the whole terminal then hops so its collar lands exactly
* on the port. Null when nothing is in range. `fallbackYaw` keeps the
* user's R/T face orientation for floor / ceiling mounts.
*/
function resolvePortSnap(
position: [number, number, number],
fallbackYaw: number,
): { position: [number, number, number]; mount: Mount; yaw: number } | null {
const port = findNearestPortXZ(
position,
collectScenePorts({ systems: DUCT_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (!port) return null
const { mount, lockYaw } = inferMountFromPort(port.direction)
const yaw = lockYaw ?? fallbackYaw
const offset = collarOffset(mount, yaw)
return {
position: [
port.position[0] - offset.x,
port.position[1] - offset.y,
port.position[2] - offset.z,
],
mount,
yaw,
}
}
/**
* Click-place tool for duct terminals (registers / diffusers / grilles).
*
* **Mount drives the target surface** (cycle with **M**): a floor register
* snaps to the floor grid, a ceiling diffuser snaps to a horizontal plane at
* ceiling height (derived from the level's ceilings/walls), and a wall
* register snaps flush onto whichever wall the cursor is over, its face
* oriented along the wall's outward normal. **R / T** rotate the floor/ceiling
* yaw ±45°; wall yaw is fixed by the wall it mates to.
*/
const DuctTerminalTool = () => {
const { camera, gl } = useThree()
const activeLevelId = useViewer((s) => s.selection.levelId)
const [mount, setMount] = useState<Mount>('floor')
const [placement, setPlacement] = useState<Placement | null>(null)
const mountRef = useRef<Mount>('floor')
const yawRef = useRef(0)
const raycaster = useRef(new Raycaster())
const pointer = useRef(new Vector2())
// The ghost mirrors whatever mount will actually be committed: a snap can
// override the manual M selection (port direction picks floor / ceiling /
// wall), so the preview must show the inferred mount, not the toolbar one.
const effectiveMount = placement?.mount ?? mount
const previewNode = useMemo(
() =>
DuctTerminalNode.parse({
...ductTerminalDefinition.defaults(),
name: 'Register',
mount: effectiveMount,
}),
[effectiveMount],
)
const ghost = useMemo(() => {
const group = buildDuctTerminalGeometry(previewNode)
group.traverse((child) => {
const mesh = child as { material?: { transparent: boolean; opacity: number } }
if (mesh.material) {
mesh.material.transparent = true
mesh.material.opacity = PREVIEW_OPACITY
}
})
return group
}, [previewNode])
useEffect(() => {
if (!activeLevelId) return
const canvas = gl.domElement
/**
* Intersect the cursor ray with a level-local horizontal plane at `y`.
* The ray is transformed into level-local space first (building transform
* plus the floor's stacked elevation), so the hit is already in the frame
* terminals are stored and parented in — accurate on every floor.
*/
const hitLocalPlane = (nativeEvent: PointerEvent | MouseEvent, y: number): Vector3 | null => {
const rect = canvas.getBoundingClientRect()
pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1
pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1
raycaster.current.setFromCamera(pointer.current, camera)
const level = activeLevelMesh()
const ray = raycaster.current.ray.clone()
if (level) {
const inv = new Matrix4().copy(level.matrixWorld).invert()
ray.applyMatrix4(inv)
}
const plane = new Plane(new Vector3(0, 1, 0), -y)
const hit = new Vector3()
return ray.intersectPlane(plane, hit) ? hit : null
}
/**
* Ceiling mount only lands where the cursor ray actually hits a real
* ceiling. Walk the active level's ceiling nodes, raycast each against a
* plane at its own height, and keep the lowest one whose polygon (minus
* holes) contains the hit — the surface you'd see looking up. Null when
* the ray misses every ceiling, so a ceiling register never drops onto a
* fixed virtual plane; the height comes from the ceiling itself.
*/
const resolveCeilingHit = (
nativeEvent: PointerEvent | MouseEvent,
): { hit: Vector3; height: number } | null => {
const nodes = useScene.getState().nodes
let best: { hit: Vector3; height: number } | null = null
for (const node of Object.values(nodes)) {
if (!node || node.type !== 'ceiling') continue
if (resolveLevelId(node, nodes) !== activeLevelId) continue
const ceiling = node as {
height?: number
polygon: Array<[number, number]>
holes?: Array<Array<[number, number]>>
}
const height = ceiling.height ?? DEFAULT_CEILING_HEIGHT
const hit = hitLocalPlane(nativeEvent, height)
if (!hit) continue
if (!pointInPolygon(hit.x, hit.z, ceiling.polygon)) continue
if (ceiling.holes?.some((h) => h.length >= 3 && pointInPolygon(hit.x, hit.z, h))) continue
if (!best || height < best.height) best = { hit, height }
}
return best
}
const resolvePlanar = (nativeEvent: PointerEvent | MouseEvent): Placement | null => {
// Floor sits on the grid (y=0; the slab lift is applied to the committed
// mesh by FloorElevationSystem). Ceiling resolves the real ceiling the
// ray hits and takes that surface's height — no fixed fallback plane.
let hit: Vector3 | null
let y: number
if (mountRef.current === 'ceiling') {
const ceiling = resolveCeilingHit(nativeEvent)
if (!ceiling) return null
hit = ceiling.hit
y = ceiling.height
} else {
y = 0
hit = hitLocalPlane(nativeEvent, y)
}
if (!hit) return null
const step = nativeEvent.shiftKey ? 0 : useEditor.getState().gridSnapStep
// Grid-snap, then layer Figma-style alignment so a floor / ceiling
// register lines up with ducts, equipment, and items (Shift = free).
const position = alignDrawPoint([snap(hit.x, step), y, snap(hit.z, step)], {
applySnap: true,
bypass: nativeEvent.shiftKey === true,
})
// Magnetic port snap: if a duct run end / fitting collar is in range,
// the port's direction picks the mount (floor / ceiling / wall) and
// hops the whole register so its collar mates exactly onto it. Takes
// precedence over grid / alignment and the manual M mount; Shift
// bypasses.
if (!nativeEvent.shiftKey) {
const mated = resolvePortSnap(position, yawRef.current)
if (mated) {
return { position: mated.position, yaw: mated.yaw, mount: mated.mount, snapped: true }
}
}
return { position, yaw: yawRef.current, mount: mountRef.current }
}
const commit = (p: Placement) => {
const terminal = DuctTerminalNode.parse({
...ductTerminalDefinition.defaults(),
name: 'Register',
mount: p.mount,
position: p.position,
rotation: p.yaw,
})
useScene.getState().createNode(terminal, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [terminal.id] })
triggerSFX('sfx:item-place')
}
// ---- Floor / ceiling: own raycast against a horizontal plane ----
const onPointerMove = (e: PointerEvent) => {
if (mountRef.current === 'wall') return
setPlacement(resolvePlanar(e))
}
const onCanvasClick = (e: MouseEvent) => {
if (mountRef.current === 'wall') return
if (useViewer.getState().cameraDragging) return
if ((e as PointerEvent).button !== undefined && (e as PointerEvent).button !== 0) return
const p = resolvePlanar(e)
if (p) commit(p)
}
// ---- Wall: consume wall hover/click events, orient to the wall ----
const resolveWall = (event: WallEvent): Placement | null => {
if (!event.normal) return null
// Wall faces are the ±Z faces in wall-local space; skip the thin
// top / end caps so the terminal only mounts onto a real face.
if (Math.abs(event.normal[2]) <= 0.7) return null
const worldNormal = new Vector3(event.normal[0], event.normal[1], event.normal[2])
.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld))
.normalize()
// Face normal after the wall mount + yaw is (sin yaw, 0, cos yaw);
// align it with the wall's outward world normal.
const yaw = Math.atan2(worldNormal.x, worldNormal.z)
const world = new Vector3(event.position[0], event.position[1], event.position[2])
const level = activeLevelMesh()
const local = level ? level.worldToLocal(world.clone()) : world
return { position: [local.x, local.y, local.z], yaw, mount: 'wall' }
}
const onWallMove = (event: WallEvent) => {
if (mountRef.current !== 'wall') return
// Wall-mounted terminals snap flush to the wall — no plan alignment.
clearDrawAlignment()
const p = resolveWall(event)
if (p) setPlacement(p)
}
const onWallClick = (event: WallEvent) => {
if (mountRef.current !== 'wall') return
if (useViewer.getState().cameraDragging) return
const p = resolveWall(event)
if (p) commit(p)
}
const onKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
const key = e.key
if (key === 'm' || key === 'M') {
e.preventDefault()
e.stopPropagation()
const next = MOUNT_CYCLE[(MOUNT_CYCLE.indexOf(mountRef.current) + 1) % MOUNT_CYCLE.length]!
mountRef.current = next
setMount(next)
// Wall placement only resolves over a wall; clear the stale ghost.
if (next === 'wall') setPlacement(null)
triggerSFX('sfx:item-rotate')
return
}
if (key !== 'r' && key !== 'R' && key !== 't' && key !== 'T') return
// Wall yaw is dictated by the wall, so R/T only apply to planar mounts.
if (mountRef.current === 'wall') return
e.preventDefault()
e.stopPropagation()
const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1
yawRef.current += steps * ROTATE_STEP_RAD
setPlacement((prev) => (prev ? { ...prev, yaw: yawRef.current } : prev))
triggerSFX('sfx:item-rotate')
}
canvas.addEventListener('pointermove', onPointerMove)
canvas.addEventListener('click', onCanvasClick)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
window.addEventListener('keydown', onKeyDown, true)
return () => {
canvas.removeEventListener('pointermove', onPointerMove)
canvas.removeEventListener('click', onCanvasClick)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
window.removeEventListener('keydown', onKeyDown, true)
clearDrawAlignment()
}
}, [activeLevelId, camera, gl])
if (!activeLevelId || !placement) return null
const mountLabel = effectiveMount.charAt(0).toUpperCase() + effectiveMount.slice(1)
// The committed mesh's slab lift is applied by FloorElevationSystem, but the
// ghost renders here directly — preview it on the slab top too so a floor
// register doesn't appear to sink in before the click.
const previewPosition =
effectiveMount === 'floor'
? getFloorStackPreviewPosition({
node: previewNode,
position: placement.position,
rotation: placement.yaw,
levelId: activeLevelId,
})
: placement.position
return (
<LevelOffsetGroup>
{/* Same ground ring + vertical line + tool-icon badge the duct draw
tool shows in 3D (icon resolved from the active `duct-terminal`
structure-tools entry). In 2D the floorplan overlay draws this for
every tool; in 3D each tool renders its own. */}
<CursorSphere position={previewPosition} />
<group position={previewPosition} rotation={[0, placement.yaw, 0]}>
<primitive object={ghost} />
</group>
<Html
center
position={[previewPosition[0], previewPosition[1] + 0.45, previewPosition[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
{placement.snapped && (
<>
<span className="font-medium text-primary">Snapped to duct</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
</>
)}
<span className="font-medium text-foreground">Mount {mountLabel}</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground">M surface</span>
{effectiveMount !== 'wall' && (
<>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground">R/T rotate</span>
</>
)}
</div>
</Html>
</LevelOffsetGroup>
)
}
export default DuctTerminalTool
@@ -13,6 +13,7 @@ import type { EyebrowVentNode } from './schema'
* the preview doesn't intercept the cursor ray feeding the tool.
*/
const EyebrowVentPreview = ({ node, invalid }: { node: EyebrowVentNode; invalid?: boolean }) => {
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geometry = useMemo(
() => buildEyebrowVentGeometry(node),
[node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio],
@@ -55,6 +55,7 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) =>
: undefined,
)
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geometry = useMemo(
() => buildEyebrowVentGeometry(node),
[node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio],
+1
View File
@@ -22,6 +22,7 @@ import type { GutterNode } from './schema'
* placed gutter.
*/
const GutterPreview = ({ node, invalid }: { node: GutterNode; invalid?: boolean }) => {
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geometry = useMemo(
() => buildGutterGeometry(node),
[
+2
View File
@@ -117,6 +117,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => {
// the FULL host segment (the alignment needs wallHeight / overhang /
// pitch / roofType to derive each eave Y), which is a superset of what
// the mitre detector reads — so one list feeds both.
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const { mitres, sharedEaveY } = useMemo(() => {
if (!effectiveSegment) return { mitres: NO_MITRES, sharedEaveY: undefined }
const segById = new Map<string, RoofSegmentNode>()
@@ -158,6 +159,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => {
mitreNodes,
])
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geometry = useMemo(
() => buildGutterGeometry(node, mitres),
[
@@ -0,0 +1,106 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildHvacEquipmentFloorplan } from './floorplan'
import { buildHvacEquipmentGeometry } from './geometry'
import { hvacEquipmentParametrics } from './parametrics'
import { getHvacEquipmentPorts } from './ports'
import { HvacEquipmentNode } from './schema'
/**
* Phase 3 of the HVAC node system — equipment cabinets (furnace /
* air handler / condenser). Furnaces and air handlers expose supply +
* return ports, giving duct runs a real origin: the duct and fitting
* tools snap onto these collars like any other port.
*
* Composition: `def.geometry` only. Yaw-only rotation, so the editor's
* default R-rotate works on a selected unit without custom actions.
*/
export const hvacEquipmentDefinition: NodeDefinition<typeof HvacEquipmentNode> = {
kind: 'hvac-equipment',
schemaVersion: 1,
schema: HvacEquipmentNode,
category: 'utility',
distributionRole: 'equipment',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: 0,
equipmentType: 'furnace',
width: 0.56,
depth: 0.71,
height: 1.1,
supplyShape: 'round',
returnShape: 'round',
supplyDiameter: 8,
returnDiameter: 8,
supplyWidth: 12,
supplyHeight: 8,
returnWidth: 14,
returnHeight: 8,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
movable: { axes: ['x', 'z'], gridSnap: true },
rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] },
duplicable: true,
deletable: true,
floorPlaced: {
footprint: (node) => {
const n = node as HvacEquipmentNode
return {
dimensions: [n.width, n.height, n.depth],
rotation: [0, n.rotation, 0],
}
},
},
},
parametrics: hvacEquipmentParametrics,
geometry: buildHvacEquipmentGeometry,
geometryKey: (n) =>
JSON.stringify([
n.equipmentType,
n.width,
n.depth,
n.height,
n.supplyShape,
n.returnShape,
n.supplyDiameter,
n.returnDiameter,
n.supplyWidth,
n.supplyHeight,
n.returnWidth,
n.returnHeight,
]),
ports: getHvacEquipmentPorts,
floorplan: buildHvacEquipmentFloorplan,
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Place unit' },
{ key: 'R / T', label: 'Rotate ±45°' },
{ key: 'Shift', label: 'Smooth (no grid snap)' },
{ key: 'Esc', label: 'Exit' },
],
presentation: {
label: 'HVAC Unit',
description:
'Furnace, air handler, or condenser — duct runs connect to its supply/return collars.',
icon: { kind: 'url', src: '/icons/HVAC.png' },
paletteSection: 'structure',
paletteOrder: 92,
},
mcp: {
description:
'HVAC equipment cabinet (furnace, air handler, or condenser). Furnaces and air handlers have supply/return duct ports; every unit also has a refrigerant service port that a lineset run connects to. Position is level-local meters; rotation is yaw radians.',
},
}
@@ -0,0 +1,83 @@
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import { getHvacEquipmentPorts } from './ports'
import type { HvacEquipmentNode } from './schema'
const BODY_FILL = '#c7cbd1'
const BODY_STROKE = '#6b7280'
const SUPPLY_COLOR = '#d4825a'
const RETURN_COLOR = '#5a8ad4'
/**
* Floor-plan footprint for HVAC equipment: the cabinet rectangle
* (rotated by yaw) with a diagonal so it reads as an equipment symbol,
* plus a supply/return collar dot per duct port. Selected → themed
* stroke + move handle.
*/
export function buildHvacEquipmentFloorplan(
node: HvacEquipmentNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const [cx, , cz] = node.position
const cos = Math.cos(node.rotation)
const sin = Math.sin(node.rotation)
const hw = node.width / 2
const hd = node.depth / 2
// Local corner → plan, applying yaw. Plan x = world x, plan y = world z;
// a +yaw about world Y maps local (x, z) to (x cos + z sin, -x sin + z cos).
const corner = (lx: number, lz: number): FloorplanPoint => [
cx + lx * cos + lz * sin,
cz - lx * sin + lz * cos,
]
const points: FloorplanPoint[] = [
corner(-hw, -hd),
corner(hw, -hd),
corner(hw, hd),
corner(-hw, hd),
]
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
const stroke = showSelectedChrome && palette ? palette.selectedStroke : BODY_STROKE
const children: FloorplanGeometry[] = [
{
kind: 'polygon',
points,
fill: BODY_FILL,
stroke,
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
opacity: 0.92,
},
// Diagonal — the conventional "mechanical equipment" plan mark.
{
kind: 'line',
x1: points[0]![0],
y1: points[0]![1],
x2: points[2]![0],
y2: points[2]![1],
stroke,
strokeWidth: 1,
vectorEffect: 'non-scaling-stroke',
opacity: 0.7,
},
]
for (const port of getHvacEquipmentPorts(node)) {
children.push({
kind: 'circle',
cx: port.position[0],
cy: port.position[2],
r: (port.diameter * INCHES_TO_METERS) / 2,
fill: port.system === 'supply' ? SUPPLY_COLOR : RETURN_COLOR,
opacity: 0.85,
})
}
if (showSelectedChrome) {
children.push({ kind: 'move-handle', point: [cx, cz] })
}
return { kind: 'group', children }
}
@@ -0,0 +1,862 @@
import {
BoxGeometry,
type BufferGeometry,
CylinderGeometry,
ExtrudeGeometry,
Group,
Matrix4,
Mesh,
MeshStandardMaterial,
Path,
Shape,
TorusGeometry,
Vector3,
} from 'three'
import {
createOvalSectionGeometry,
INCHES_TO_METERS,
rectSectionAxes,
} from '../duct-segment/geometry'
import { localEquipmentPorts, localRefrigerantPorts } from './ports'
import type { HvacEquipmentNode } from './schema'
const RADIAL_SEGMENTS = 24
const SMALL_SEGMENTS = 16
// Shared cabinet white used by every equipment body (furnace, air handler,
// condenser) so the units read as one product family.
const EQUIPMENT_WHITE = '#eef0f2'
const EQUIPMENT_TRIM = '#cfd3d8'
const CABINET_COLOR = EQUIPMENT_WHITE
const INTERIOR_COLOR = '#9aa1a8'
const PANEL_COLOR = EQUIPMENT_TRIM
const CONTROL_COLOR = '#3f4549'
const CONDENSER_COLOR = EQUIPMENT_WHITE
const CONDENSER_FRAME_COLOR = EQUIPMENT_TRIM
const CONDENSER_FIN_COLOR = '#9aa1a8'
const FAN_COLOR = '#3f4549'
const BLOWER_COLOR = '#2f6fb0'
const BLOWER_BLADE_COLOR = '#274f7d'
const BURNER_COLOR = '#d9772e'
const GAS_PIPE_COLOR = '#d2691e'
const AIR_HANDLER_COLOR = EQUIPMENT_WHITE
const AIR_HANDLER_TRIM = EQUIPMENT_TRIM
const FAN_GRILLE_COLOR = '#3a3f44'
const FAN_BLADE_COLOR = '#d7dade'
const COIL_FIN_COLOR = '#9aa1a8'
const COPPER_COLOR = '#b06b3f'
const SERVICE_VALVE_COLOR = '#7a8086'
const UP = new Vector3(0, 1, 0)
/**
* Pure geometry builder for an HVAC equipment cabinet, in the node's
* LOCAL frame (origin at base center, +Z front, +X right) —
* `<ParametricNodeRenderer>` applies `position` + yaw.
*
* Furnace / air handler: the cabinet is built from individual sheet-metal
* walls (not a solid box) so the lower front can be left OPEN — a real
* cut that exposes the squirrel-cage circulating fan and, on a furnace,
* the orange burner manifold and gas valve. Furnaces also get the
* combustion train from the reference drawing: a draft hood + vent
* connector elbow on top and a gas pipe with drip leg down the front-left.
*
* Air handler: tall white cabinet with two stacked guarded axial fans on
* the front and finned coil bands down the sides (vertical fan-coil look).
* Condenser: squat cabinet with a fan ring and hub on top.
*/
export function buildHvacEquipmentGeometry(node: HvacEquipmentNode): Group {
const group = new Group()
if (node.equipmentType === 'condenser') return buildCondenser(node, group)
if (node.equipmentType === 'air-handler') return buildAirHandler(node, group)
const W = node.width
const H = node.height
const D = node.depth
const hw = W / 2
const hd = D / 2
const t = Math.min(0.02, W * 0.04, D * 0.04)
// Single-sided. Each wall is a thin slab whose interior-facing face is an
// outward face of its own box, so the cut still shows metal inside — and
// single-sided culling means coplanar butt joints can't z-fight.
const cabinet = new MeshStandardMaterial({
color: CABINET_COLOR,
metalness: 0.55,
roughness: 0.45,
})
const interior = new MeshStandardMaterial({
color: INTERIOR_COLOR,
metalness: 0.4,
roughness: 0.6,
})
const addBox = (
w: number,
h: number,
dd: number,
mat: MeshStandardMaterial,
x: number,
y: number,
z: number,
name: string,
) => {
const mesh = new Mesh(new BoxGeometry(w, h, dd), mat)
mesh.name = name
mesh.position.set(x, y, z)
group.add(mesh)
return mesh
}
const ports = localEquipmentPorts(node)
const supplyPort = ports.find((p) => p.id === 'supply')
const returnPort = ports.find((p) => p.id === 'return')
// ── Cabinet shell as butt-jointed sheet-metal plates. Top + bottom span
// the full footprint; the four walls sit *between* them (height innerH),
// and back / front pieces sit *between* the side walls (width W - 2t). No
// two same-facing surfaces are ever coplanar, which is what was z-fighting
// when these were full-size overlapping boxes; single-sided materials
// (above) finish the job. Left wall carries the return hole, top the supply.
const innerH = H - 2 * t
const midY = H / 2
const frontZ = hd - t / 2
addBox(W, t, D, cabinet, 0, t / 2, 0, 'equipment-bottom')
addBox(t, innerH, D, interior, hw - t / 2, midY, 0, 'equipment-right')
addBox(W - 2 * t, innerH, t, interior, 0, midY, -hd + t / 2, 'equipment-back')
// Top plate, flat, with the supply hole at the cabinet center. Built
// centered in its own XY plane (x→W, y→D); rotate.x = -90° lays it flat.
const top = buildHolePlate(W, D, t, supplyPort, 0, 0, cabinet)
top.name = 'equipment-top'
top.rotation.x = -Math.PI / 2
top.position.set(0, H - t / 2, 0)
group.add(top)
// Left wall with the return hole. After rotate.y = -90° the plate's x→world
// -z and y→world height; centered at midY with the return port at world
// y = H*0.35, so the hole sits at plate-y (H*0.35 - midY).
const left = buildHolePlate(D, innerH, t, returnPort, 0, H * 0.35 - midY, interior)
left.name = 'equipment-left'
left.rotation.y = -Math.PI / 2
left.position.set(-hw + t / 2, midY, 0)
group.add(left)
// Front opening: framed sill, jambs and an upper control panel, all inset
// to (W - 2t) so they tuck between the side walls. The gap between sill
// and panel (and inside the jambs) is the visible cut.
const openBottom = H * 0.1
const openTop = H * 0.58
const jamb = W * 0.08
const frontW = W - 2 * t
const frontHalf = frontW / 2
const panelMat = new MeshStandardMaterial({
color: PANEL_COLOR,
metalness: 0.5,
roughness: 0.5,
})
addBox(frontW, openBottom - t, t, cabinet, 0, (t + openBottom) / 2, frontZ, 'equipment-sill')
addBox(frontW, H - t - openTop, t, panelMat, 0, (openTop + H - t) / 2, frontZ, 'equipment-panel')
addBox(
jamb,
openTop - openBottom,
t,
cabinet,
-frontHalf + jamb / 2,
(openBottom + openTop) / 2,
frontZ,
'equipment-jamb-l',
)
addBox(
jamb,
openTop - openBottom,
t,
cabinet,
frontHalf - jamb / 2,
(openBottom + openTop) / 2,
frontZ,
'equipment-jamb-r',
)
// ── Control area on the upper front panel (fan-limit switch + cover).
const ctrlMat = new MeshStandardMaterial({
color: CONTROL_COLOR,
metalness: 0.4,
roughness: 0.6,
})
addBox(
W * 0.34,
(H - openTop) * 0.5,
0.012,
ctrlMat,
W * 0.18,
(openTop + H) / 2,
frontZ + 0.008,
'equipment-control',
)
addBox(
W * 0.1,
(H - openTop) * 0.3,
0.02,
ctrlMat,
-W * 0.22,
(openTop + H) / 2,
frontZ + 0.012,
'equipment-switch',
)
// ── Squirrel-cage circulating fan, seated in the open lower cavity. The
// round scroll housing faces front (+Z) so it shows through the cut.
const rB = Math.min(W * 0.34, (openTop - openBottom) * 0.42)
const housingD = D * 0.42
const cy = openBottom + rB + 0.01
const zc = hd - t - housingD / 2 - 0.01
const blowerMat = new MeshStandardMaterial({
color: BLOWER_COLOR,
metalness: 0.3,
roughness: 0.6,
})
const bladeMat = new MeshStandardMaterial({
color: BLOWER_BLADE_COLOR,
metalness: 0.2,
roughness: 0.75,
})
const housing = new Mesh(new CylinderGeometry(rB, rB, housingD, RADIAL_SEGMENTS), blowerMat)
housing.name = 'blower-housing'
housing.rotation.x = Math.PI / 2 // axis Y → axis Z (round face toward front)
housing.position.set(0, cy, zc)
group.add(housing)
const intake = new Mesh(new TorusGeometry(rB * 0.7, rB * 0.12, 10, RADIAL_SEGMENTS), blowerMat)
intake.name = 'blower-intake'
intake.position.set(0, cy, hd - t - 0.005)
group.add(intake)
const hub = new Mesh(
new CylinderGeometry(rB * 0.18, rB * 0.18, housingD * 0.9, SMALL_SEGMENTS),
bladeMat,
)
hub.name = 'blower-hub'
hub.rotation.x = Math.PI / 2
hub.position.set(0, cy, zc)
group.add(hub)
// Radial cage blades around the hub axis (Z).
const BLADES = 14
for (let i = 0; i < BLADES; i++) {
const a = (i / BLADES) * Math.PI * 2
const blade = new Mesh(new BoxGeometry(0.006, rB * 0.62, housingD * 0.82), bladeMat)
blade.name = `blower-blade-${i}`
blade.position.set(Math.cos(a) * rB * 0.5, cy + Math.sin(a) * rB * 0.5, zc)
blade.rotation.z = a
group.add(blade)
}
buildCombustionTrain(node, group, { hw, hd, H, openTop, frontZ })
buildGasLine(node, group, { hw, hd, H })
buildCollars(node, group)
buildServiceValves(node, group)
return group
}
/** Orange burner manifold + gas valve above the blower (furnace only). */
function buildCombustionTrain(
node: HvacEquipmentNode,
group: Group,
dims: { hw: number; hd: number; H: number; openTop: number; frontZ: number },
): void {
const { hw, hd, H, openTop } = dims
const burnerMat = new MeshStandardMaterial({
color: BURNER_COLOR,
metalness: 0.35,
roughness: 0.55,
emissive: BURNER_COLOR,
emissiveIntensity: 0.12,
})
const y = openTop - 0.12
const z = hd - node.depth * 0.32
// Manifold pipe running across the unit (axis X), feeding the burners.
const manifold = new Mesh(
new CylinderGeometry(0.018, 0.018, node.width * 0.66, SMALL_SEGMENTS),
burnerMat,
)
manifold.name = 'burner-manifold'
manifold.rotation.z = Math.PI / 2
manifold.position.set(-node.width * 0.05, y, z)
group.add(manifold)
// 4 burner tubes shooting back into the heat exchanger (axis Z).
const tubes = 4
for (let i = 0; i < tubes; i++) {
const x = (-(tubes - 1) / 2 + i) * (node.width * 0.16)
const tube = new Mesh(
new CylinderGeometry(0.022, 0.022, node.depth * 0.34, SMALL_SEGMENTS),
burnerMat,
)
tube.name = `burner-tube-${i}`
tube.rotation.x = Math.PI / 2
tube.position.set(x, y, z - node.depth * 0.17)
group.add(tube)
}
// Gas valve block at the right end of the manifold.
const valve = new Mesh(new BoxGeometry(0.08, 0.07, 0.09), burnerMat)
valve.name = 'gas-valve'
valve.position.set(hw - 0.07, y, z + 0.02)
group.add(valve)
}
/** Gas supply pipe with a capped drip leg, down the front-left (furnace). */
function buildGasLine(
node: HvacEquipmentNode,
group: Group,
dims: { hw: number; hd: number; H: number },
): void {
const { hw, hd, H } = dims
const gasMat = new MeshStandardMaterial({
color: GAS_PIPE_COLOR,
metalness: 0.4,
roughness: 0.5,
})
const r = 0.014
const x = -hw + 0.06
const z = hd + 0.03
const teeY = H * 0.34
// Vertical main running down the front-left face.
const mainTop = H * 0.92
const mainLen = mainTop - teeY
const main = new Mesh(new CylinderGeometry(r, r, mainLen, SMALL_SEGMENTS), gasMat)
main.name = 'gas-main'
main.position.set(x, teeY + mainLen / 2, z)
group.add(main)
// Tee into the cabinet toward the gas valve (axis X, +).
const tee = new Mesh(new CylinderGeometry(r, r, 0.12, SMALL_SEGMENTS), gasMat)
tee.name = 'gas-tee'
tee.rotation.z = Math.PI / 2
tee.position.set(x + 0.06, teeY, z)
group.add(tee)
// Drip leg: short capped vertical pipe below the tee to catch sediment.
const legLen = H * 0.14
const leg = new Mesh(new CylinderGeometry(r, r, legLen, SMALL_SEGMENTS), gasMat)
leg.name = 'gas-drip-leg'
leg.position.set(x, teeY - legLen / 2, z)
group.add(leg)
const cap = new Mesh(new CylinderGeometry(r * 1.4, r * 1.4, 0.02, SMALL_SEGMENTS), gasMat)
cap.name = 'gas-drip-cap'
cap.position.set(x, teeY - legLen, z)
group.add(cap)
}
type LocalPort = ReturnType<typeof localEquipmentPorts>[number]
type CollarSection = { shape: 'round' | 'rect' | 'oval'; widthM: number; heightM: number }
/**
* Radial clearance (meters) the collar sleeve carries over the duct's
* nominal cross-section. A duct run leaves the port at the advertised size;
* the collar is built one clearance larger on every side so it reads as a
* sheet-metal sleeve wrapping the duct — and so their faces never coincide
* (no z-fighting where the run overlaps the stub). ~5 mm ≈ a real slip joint.
*/
const COLLAR_CLEARANCE_M = 0.005
/**
* Collar cross-section in meters, already grown by `COLLAR_CLEARANCE_M` so
* the sleeve sits over the duct. Round collapses to a single diameter on
* both axes; rect / oval carry the explicit width × height (width is the
* horizontal face, height the vertical). For round the port's `diameter`
* is the true round size; for rect / oval it is the area-equivalent value
* the port advertises, so the mesh uses width / height instead.
*/
function collarSection(port: LocalPort): CollarSection {
const shape = port.shape ?? 'round'
const grow = 2 * COLLAR_CLEARANCE_M
if (shape === 'round') {
const d = port.diameter * INCHES_TO_METERS + grow
return { shape, widthM: d, heightM: d }
}
return {
shape,
widthM: (port.width ?? port.diameter) * INCHES_TO_METERS + grow,
heightM: (port.height ?? port.diameter) * INCHES_TO_METERS + grow,
}
}
/** Collar sleeve geometry with the run length on local Y and the
* cross-section on local X (width) × Z (height) — the basis the caller
* orients with `rectSectionAxes`. Round stays open-ended so you can see
* straight through into the hole. */
function collarGeometry(section: CollarSection, length: number): BufferGeometry {
if (section.shape === 'rect') return new BoxGeometry(section.widthM, length, section.heightM)
if (section.shape === 'oval') {
return createOvalSectionGeometry(section.widthM, section.heightM, length)
}
const r = section.widthM / 2
return new CylinderGeometry(r, r, length, RADIAL_SEGMENTS, 1, true)
}
/**
* Hole `Path` in the plate's local XY (width → X, height → Y), centered at
* (`hx`, `hy`) and clamped to keep it inside the plate. Three.js corrects
* hole winding when extruding, so the path direction here is irrelevant.
*/
function collarHolePath(
section: CollarSection,
hx: number,
hy: number,
maxHalfW: number,
maxHalfH: number,
): Path | null {
if (section.shape === 'rect') {
const hw = Math.min(section.widthM / 2, maxHalfW)
const hh = Math.min(section.heightM / 2, maxHalfH)
if (hw <= 0 || hh <= 0) return null
return new Path()
.moveTo(hx - hw, hy - hh)
.lineTo(hx + hw, hy - hh)
.lineTo(hx + hw, hy + hh)
.lineTo(hx - hw, hy + hh)
.closePath()
}
if (section.shape === 'oval') {
const w = Math.min(section.widthM, maxHalfW * 2)
const h = Math.min(section.heightM, maxHalfH * 2)
const r = Math.min(w, h) / 2
const straight = Math.max(0, w - h) / 2
if (r <= 0) return null
const path = new Path()
path.absarc(hx + straight, hy, r, -Math.PI / 2, Math.PI / 2, false)
path.absarc(hx - straight, hy, r, Math.PI / 2, (3 * Math.PI) / 2, false)
path.closePath()
return path
}
const r = Math.min(section.widthM / 2, maxHalfW, maxHalfH)
if (r <= 0) return null
const path = new Path()
path.absarc(hx, hy, r, 0, Math.PI * 2, true)
return path
}
/**
* Flat rectangular plate of `thickness`, centered on the origin in its own
* XY plane (width → X, height → Y) and centered through the thickness on Z,
* with the duct opening for `port` punched at (`hx`, `hy`). Callers rotate /
* position it into a wall; the hole takes the collar's round / rect / oval
* cross-section.
*/
function buildHolePlate(
width: number,
height: number,
thickness: number,
port: LocalPort | undefined,
hx: number,
hy: number,
material: MeshStandardMaterial,
): Mesh {
const hw = width / 2
const hh = height / 2
const shape = new Shape()
.moveTo(-hw, -hh)
.lineTo(hw, -hh)
.lineTo(hw, hh)
.lineTo(-hw, hh)
.lineTo(-hw, -hh)
const hole = port ? collarHolePath(collarSection(port), hx, hy, hw * 0.95, hh * 0.95) : null
if (hole) shape.holes.push(hole)
const geom = new ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false })
geom.translate(0, 0, -thickness / 2)
geom.computeVertexNormals()
return new Mesh(geom, material)
}
/**
* Sheet-metal sleeves at the supply/return ports. Each collar straddles the
* wall hole — part inside the cabinet, part outside — so a duct run slides
* through the opening instead of dead-ending on a panel. The collar takes
* the port's round / rect / oval cross-section, oriented with the same
* width-horizontal / height-vertical basis as the hole it sits in.
*/
function buildCollars(node: HvacEquipmentNode, group: Group): void {
const collarMaterial = new MeshStandardMaterial({
color: '#c2c2c2',
metalness: 0.6,
roughness: 0.4,
side: 2,
})
const OUT = 0.12 // sleeve length outside the cabinet
const IN = 0.05 // sleeve length reaching inside past the hole
const length = OUT + IN
for (const port of localEquipmentPorts(node)) {
const dir = port.direction.clone().normalize()
const sleeve = new Mesh(collarGeometry(collarSection(port), length), collarMaterial)
sleeve.name = `equipment-collar-${port.id}`
const { width: wAxis, height: hAxis } = rectSectionAxes(dir)
sleeve.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(wAxis, dir, hAxis))
sleeve.position.copy(port.position).addScaledVector(dir, (OUT - IN) / 2)
group.add(sleeve)
}
}
// Default lineset line radii (meters) — must mirror the lineset kind's
// defaults so the two service stubs sit exactly where its suction/liquid
// pipes run. See `lineset/geometry.ts` (suction 7/8", liquid 3/8", 3/8"
// foam jacket) and its symmetric ±offset about the path centerline.
const LINESET_SUCTION_R = (0.875 * INCHES_TO_METERS) / 2
const LINESET_LIQUID_R = (0.375 * INCHES_TO_METERS) / 2
const LINESET_JACKET_R = LINESET_SUCTION_R + 0.01
const LINESET_PAIR_OFFSET = LINESET_JACKET_R + LINESET_LIQUID_R
/**
* Refrigerant service valves at the lineset port — a brass-grey valve body
* with two copper stubs the lineset run mates onto. Built on every
* equipment type so a split system can be piped from condenser to coil.
*
* A lineset is a parallel pair (insulated suction + bare liquid) offset
* symmetrically about its path centerline. The snap point is that
* centerline, so a single stub would sit in the empty gap between the two
* pipes. Instead we emit two stubs at exactly the lineset's ±offset along
* the port's horizontal perpendicular: the suction pipe lands on the wide
* stub, the liquid pipe on the narrow one, when the run leaves the face.
*/
function buildServiceValves(node: HvacEquipmentNode, group: Group): void {
const valveMat = new MeshStandardMaterial({
color: SERVICE_VALVE_COLOR,
metalness: 0.7,
roughness: 0.35,
})
const copperMat = new MeshStandardMaterial({
color: COPPER_COLOR,
metalness: 0.8,
roughness: 0.3,
})
for (const port of localRefrigerantPorts(node)) {
const dir = port.direction.clone().normalize()
// Horizontal perpendicular to the port — matches the lineset geometry's
// `horizontal.cross(UP)`, so the stub offsets track its pipe offsets.
const perp = dir.clone().cross(UP).normalize()
// Brass-grey valve body bolted to the cabinet face, spanning the pair.
const bodyWidth = 2 * LINESET_PAIR_OFFSET + 2 * LINESET_JACKET_R
const body = new Mesh(new BoxGeometry(0.05, 0.08, bodyWidth), valveMat)
body.name = 'service-valve-body'
body.position.copy(port.position).addScaledVector(dir, 0.025)
body.quaternion.setFromUnitVectors(UP, dir)
group.add(body)
const stubLen = 0.07
const addStub = (sign: number, radius: number, id: string) => {
const stub = new Mesh(
new CylinderGeometry(radius, radius, stubLen, SMALL_SEGMENTS),
copperMat,
)
stub.name = `service-valve-stub-${id}`
stub.position
.copy(port.position)
.addScaledVector(perp, sign * LINESET_PAIR_OFFSET)
.addScaledVector(dir, 0.05 + stubLen / 2)
stub.quaternion.setFromUnitVectors(UP, dir)
group.add(stub)
}
// Suction pipe is the lineset's -offset line; liquid is +offset.
addStub(-1, LINESET_SUCTION_R, 'suction')
addStub(1, LINESET_LIQUID_R, 'liquid')
}
}
/**
* Residential split-system condenser, matching the reference photos: a
* greenish-grey body wrapped in vertical louvered coil fins on all four
* sides, a dark base and dark top frame, and a top-mounted fan with a
* radial wire guard (concentric rings + spokes) over a recessed throat.
*/
function buildCondenser(node: HvacEquipmentNode, group: Group): Group {
const W = node.width
const H = node.height
const D = node.depth
const hw = W / 2
const hd = D / 2
const bodyMat = new MeshStandardMaterial({
color: CONDENSER_COLOR,
metalness: 0.5,
roughness: 0.5,
})
const frameMat = new MeshStandardMaterial({
color: CONDENSER_FRAME_COLOR,
metalness: 0.4,
roughness: 0.6,
})
const finMat = new MeshStandardMaterial({
color: CONDENSER_FIN_COLOR,
metalness: 0.65,
roughness: 0.4,
})
const frameH = Math.min(0.07, H * 0.09)
const post = Math.min(0.04, W * 0.07)
// Inner body the fins wrap around (inset so corner posts read proud).
const body = new Mesh(new BoxGeometry(W - post, H - 2 * frameH, D - post), bodyMat)
body.name = 'equipment-body'
body.position.set(0, H / 2, 0)
group.add(body)
// Dark base + top frame rings.
const base = new Mesh(new BoxGeometry(W, frameH, D), frameMat)
base.name = 'condenser-base'
base.position.set(0, frameH / 2, 0)
group.add(base)
const topFrame = new Mesh(new BoxGeometry(W, frameH, D), frameMat)
topFrame.name = 'condenser-top-frame'
topFrame.position.set(0, H - frameH / 2, 0)
group.add(topFrame)
// Corner posts.
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
const p = new Mesh(new BoxGeometry(post, H, post), frameMat)
p.name = `condenser-post-${sx > 0 ? 'r' : 'l'}${sz > 0 ? 'f' : 'b'}`
p.position.set(sx * (hw - post / 2), H / 2, sz * (hd - post / 2))
group.add(p)
}
}
// Vertical louvered coil fins on all four faces. Each fin is a thin
// vertical slat standing slightly proud of the body; the gaps between
// them read as the coil louvers.
const finY = H / 2
const finH = H - 2 * frameH
const addFins = (count: number, span: number, fixed: number, axis: 'x' | 'z', sign: number) => {
for (let i = 0; i < count; i++) {
const t = (i + 0.5) / count
const c = -span / 2 + t * span
const fin =
axis === 'x'
? new Mesh(new BoxGeometry(0.006, finH, 0.018), finMat)
: new Mesh(new BoxGeometry(0.018, finH, 0.006), finMat)
fin.name = `condenser-fin-${axis}${sign > 0 ? '+' : '-'}-${i}`
if (axis === 'x') fin.position.set(c, finY, sign * fixed)
else fin.position.set(sign * fixed, finY, c)
group.add(fin)
}
}
const finsAlongW = Math.max(10, Math.round(W / 0.025))
const finsAlongD = Math.max(10, Math.round(D / 0.025))
addFins(finsAlongW, W - post, hd - post / 2 + 0.004, 'x', 1) // front
addFins(finsAlongW, W - post, hd - post / 2 + 0.004, 'x', -1) // back
addFins(finsAlongD, D - post, hw - post / 2 + 0.004, 'z', 1) // right
addFins(finsAlongD, D - post, hw - post / 2 + 0.004, 'z', -1) // left
buildCondenserFanGuard(group, W, H, D)
buildServiceValves(node, group)
return group
}
/** Top fan: recessed throat + hub/blades under a radial wire guard. */
function buildCondenserFanGuard(group: Group, W: number, H: number, D: number): void {
const fanMat = new MeshStandardMaterial({
color: FAN_COLOR,
metalness: 0.3,
roughness: 0.7,
})
const guardMat = new MeshStandardMaterial({
color: CONDENSER_FRAME_COLOR,
metalness: 0.4,
roughness: 0.6,
})
const r = Math.min(W, D) * 0.4
const deckY = H
// Recessed throat dropping below the top deck so the fan reads as an
// opening, not a disc sitting on the lid.
const throat = new Mesh(new CylinderGeometry(r, r, H * 0.12, RADIAL_SEGMENTS, 1, true), fanMat)
throat.name = 'condenser-fan-throat'
throat.position.set(0, deckY - H * 0.06, 0)
group.add(throat)
// Hub + swept blades just below the deck.
const bladeMat = new MeshStandardMaterial({
color: '#5a6066',
metalness: 0.3,
roughness: 0.6,
})
const hub = new Mesh(new CylinderGeometry(r * 0.16, r * 0.16, 0.04, SMALL_SEGMENTS), bladeMat)
hub.name = 'condenser-fan-hub'
hub.position.set(0, deckY - 0.02, 0)
group.add(hub)
const BLADES = 6
for (let i = 0; i < BLADES; i++) {
const a = (i / BLADES) * Math.PI * 2
const blade = new Mesh(new BoxGeometry(r * 0.7, 0.006, r * 0.28), bladeMat)
blade.name = `condenser-fan-blade-${i}`
blade.position.set(Math.cos(a) * r * 0.45, deckY - 0.02, Math.sin(a) * r * 0.45)
blade.rotation.y = a
blade.rotation.x = 0.35
group.add(blade)
}
// Radial wire guard: concentric rings + spokes, slightly domed above deck.
const guardY = deckY + 0.012
for (let k = 1; k <= 5; k++) {
const rr = (r * k) / 5
const ring = new Mesh(new TorusGeometry(rr, 0.004, 6, RADIAL_SEGMENTS), guardMat)
ring.name = `condenser-guard-ring-${k}`
ring.rotation.x = Math.PI / 2
ring.position.set(0, guardY, 0)
group.add(ring)
}
const SPOKES = 8
for (let i = 0; i < SPOKES; i++) {
const a = (i / SPOKES) * Math.PI
const spoke = new Mesh(new BoxGeometry(r * 2, 0.004, 0.004), guardMat)
spoke.name = `condenser-guard-spoke-${i}`
spoke.position.set(0, guardY, 0)
spoke.rotation.y = a
group.add(spoke)
}
}
/**
* Guarded axial fan on the front (+Z) face: a recessed dark throat, a
* spider hub with swept blades, and a concentric wire grille — the look of
* the units in the air-handler reference. Centered at (`x`, `y`) on the
* cabinet front at `frontZ`, radius `r`.
*/
function buildAxialFan(
group: Group,
x: number,
y: number,
frontZ: number,
r: number,
index: number,
): void {
const grilleMat = new MeshStandardMaterial({
color: FAN_GRILLE_COLOR,
metalness: 0.4,
roughness: 0.6,
})
const bladeMat = new MeshStandardMaterial({
color: FAN_BLADE_COLOR,
metalness: 0.3,
roughness: 0.5,
})
// Recessed throat behind the blades so the fan reads as an opening.
const throat = new Mesh(new CylinderGeometry(r, r, 0.04, RADIAL_SEGMENTS), grilleMat)
throat.name = `fan-${index}-throat`
throat.rotation.x = Math.PI / 2
throat.position.set(x, y, frontZ - 0.02)
group.add(throat)
// Hub + swept blades, sitting just proud of the throat.
const hub = new Mesh(new CylinderGeometry(r * 0.18, r * 0.18, 0.03, SMALL_SEGMENTS), bladeMat)
hub.name = `fan-${index}-hub`
hub.rotation.x = Math.PI / 2
hub.position.set(x, y, frontZ + 0.005)
group.add(hub)
const BLADES = 5
for (let i = 0; i < BLADES; i++) {
const a = (i / BLADES) * Math.PI * 2
const blade = new Mesh(new BoxGeometry(r * 0.34, 0.006, r * 0.78), bladeMat)
blade.name = `fan-${index}-blade-${i}`
// Position blade outward from hub, then tilt for an airfoil sweep.
const br = r * 0.5
blade.position.set(x + Math.cos(a) * br, y + Math.sin(a) * br, frontZ + 0.005)
blade.rotation.z = a
blade.rotation.y = 0.5
group.add(blade)
}
// Concentric wire grille (rings) over the front of the fan.
const ringMat = new MeshStandardMaterial({
color: AIR_HANDLER_TRIM,
metalness: 0.5,
roughness: 0.4,
})
for (let k = 1; k <= 3; k++) {
const rr = (r * k) / 3
const ring = new Mesh(new TorusGeometry(rr, 0.004, 6, RADIAL_SEGMENTS), ringMat)
ring.name = `fan-${index}-grille-${k}`
ring.position.set(x, y, frontZ + 0.02)
group.add(ring)
}
}
/**
* Air handler / vertical fan-coil: a tall white cabinet with two stacked
* guarded axial fans on the front and finned coil bands down both sides —
* the unit in the reference photo. Keeps the supply/return collars (built
* by the shared `buildCollars`) so duct runs still connect.
*/
function buildAirHandler(node: HvacEquipmentNode, group: Group): Group {
const W = node.width
const H = node.height
const D = node.depth
const hw = W / 2
const hd = D / 2
const cabinetMat = new MeshStandardMaterial({
color: AIR_HANDLER_COLOR,
metalness: 0.3,
roughness: 0.55,
})
const trimMat = new MeshStandardMaterial({
color: AIR_HANDLER_TRIM,
metalness: 0.4,
roughness: 0.5,
})
const finMat = new MeshStandardMaterial({
color: COIL_FIN_COLOR,
metalness: 0.6,
roughness: 0.45,
})
// Cabinet body + top/bottom trim caps.
const body = new Mesh(new BoxGeometry(W, H, D), cabinetMat)
body.name = 'equipment-body'
body.position.set(0, H / 2, 0)
group.add(body)
// Trim caps straddle the cabinet's top / bottom edges (centered on
// y = H and y = 0) so the body's end faces fall inside the cap volume.
// Sitting them flush instead (top face at y = H) leaves two coplanar
// full-footprint faces that z-fight.
const capH = Math.min(0.05, H * 0.06)
const topCap = new Mesh(new BoxGeometry(W * 1.04, capH, D * 1.04), trimMat)
topCap.name = 'air-handler-top-cap'
topCap.position.set(0, H, 0)
group.add(topCap)
const botCap = new Mesh(new BoxGeometry(W * 1.04, capH, D * 1.04), trimMat)
botCap.name = 'air-handler-bottom-cap'
botCap.position.set(0, 0, 0)
group.add(botCap)
// Two stacked axial fans on the front face, sized to the cabinet width.
const frontZ = hd + 0.001
const fanR = Math.min(W * 0.4, H * 0.22)
const margin = capH + fanR + H * 0.04
buildAxialFan(group, 0, H - margin, frontZ, fanR, 0)
buildAxialFan(group, 0, margin, frontZ, fanR, 1)
// Finned coil bands down both sides (horizontal slats = condenser fins).
const fins = Math.max(6, Math.floor(H / 0.06))
for (let side = -1; side <= 1; side += 2) {
for (let i = 0; i < fins; i++) {
const fy = capH + ((i + 0.5) / fins) * (H - 2 * capH)
const fin = new Mesh(new BoxGeometry(0.004, 0.012, D * 0.82), finMat)
fin.name = `coil-fin-${side > 0 ? 'r' : 'l'}-${i}`
fin.position.set(side * (hw + 0.002), fy, 0)
group.add(fin)
}
}
buildCollars(node, group)
buildServiceValves(node, group)
return group
}
@@ -0,0 +1,4 @@
export { hvacEquipmentDefinition } from './definition'
export { buildHvacEquipmentGeometry } from './geometry'
export { getHvacEquipmentPorts } from './ports'
export { HvacEquipmentNode } from './schema'
@@ -0,0 +1,104 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { HvacEquipmentNode } from './schema'
export const hvacEquipmentParametrics: ParametricDescriptor<HvacEquipmentNode> = {
groups: [
{
label: 'Equipment',
fields: [
{
key: 'equipmentType',
kind: 'enum',
options: ['furnace', 'air-handler', 'condenser'],
display: 'segmented',
},
],
},
{
label: 'Cabinet',
fields: [
{ key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 2, step: 0.05 },
{ key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 2, step: 0.05 },
{ key: 'height', kind: 'number', unit: 'm', min: 0.4, max: 2.5, step: 0.05 },
],
},
{
label: 'Supply',
fields: [
{
key: 'supplyShape',
kind: 'enum',
options: ['round', 'rect', 'oval'],
display: 'segmented',
visibleIf: (n) => n.equipmentType !== 'condenser',
},
{
key: 'supplyDiameter',
kind: 'number',
unit: 'in',
min: 6,
max: 30,
step: 1,
visibleIf: (n) => n.equipmentType !== 'condenser' && n.supplyShape === 'round',
},
{
key: 'supplyWidth',
kind: 'number',
unit: 'in',
min: 6,
max: 30,
step: 1,
visibleIf: (n) => n.equipmentType !== 'condenser' && n.supplyShape !== 'round',
},
{
key: 'supplyHeight',
kind: 'number',
unit: 'in',
min: 6,
max: 30,
step: 1,
visibleIf: (n) => n.equipmentType !== 'condenser' && n.supplyShape !== 'round',
},
],
},
{
label: 'Return',
fields: [
{
key: 'returnShape',
kind: 'enum',
options: ['round', 'rect', 'oval'],
display: 'segmented',
visibleIf: (n) => n.equipmentType !== 'condenser',
},
{
key: 'returnDiameter',
kind: 'number',
unit: 'in',
min: 6,
max: 30,
step: 1,
visibleIf: (n) => n.equipmentType !== 'condenser' && n.returnShape === 'round',
},
{
key: 'returnWidth',
kind: 'number',
unit: 'in',
min: 6,
max: 30,
step: 1,
visibleIf: (n) => n.equipmentType !== 'condenser' && n.returnShape !== 'round',
},
{
key: 'returnHeight',
kind: 'number',
unit: 'in',
min: 6,
max: 30,
step: 1,
visibleIf: (n) => n.equipmentType !== 'condenser' && n.returnShape !== 'round',
},
],
},
],
}
+122
View File
@@ -0,0 +1,122 @@
import type { NodePort } from '@pascal-app/core'
import { Vector3 } from 'three'
import { equivalentDiameterIn, ovalEquivalentDiameterIn } from '../duct-segment/geometry'
import type { HvacEquipmentNode } from './schema'
type CollarShape = 'round' | 'rect' | 'oval'
type LocalPort = {
id: string
position: Vector3
direction: Vector3
diameter: number
system: 'supply' | 'return' | 'refrigerant'
// Duct collars only — the cross-section the collar mesh and wall hole
// take. `diameter` above is the area-equivalent round size the port
// advertises so round runs mate at a sensible size. Refrigerant ports
// are always round and omit these.
shape?: CollarShape
width?: number
height?: number
}
/** Area-equivalent round diameter (inches) a shaped collar advertises. */
function collarDiameterIn(shape: CollarShape, diameter: number, width: number, height: number) {
if (shape === 'rect') return equivalentDiameterIn(width, height)
if (shape === 'oval') return ovalEquivalentDiameterIn(width, height)
return diameter
}
/** Nominal suction-line OD (inches) the refrigerant service connection
* advertises — matches the lineset kind's default suction diameter so a
* lineset run mates cleanly onto the valve. */
const REFRIGERANT_PORT_DIAMETER_IN = 0.875
/**
* Duct ports in the cabinet's LOCAL frame (origin at the base center,
* before yaw / position). Matches a typical upflow furnace / vertical air
* handler: supply plenum collar on top, return drop on the -X side near
* the bottom third. Condensers carry no duct ports — their connection is
* the refrigerant lineset (see `localRefrigerantPorts`).
*/
export function localEquipmentPorts(node: HvacEquipmentNode): LocalPort[] {
if (node.equipmentType === 'condenser') return []
return [
{
id: 'supply',
position: new Vector3(0, node.height, 0),
direction: new Vector3(0, 1, 0),
diameter: collarDiameterIn(
node.supplyShape,
node.supplyDiameter,
node.supplyWidth,
node.supplyHeight,
),
system: 'supply',
shape: node.supplyShape,
width: node.supplyWidth,
height: node.supplyHeight,
},
{
id: 'return',
position: new Vector3(-node.width / 2, node.height * 0.35, 0),
direction: new Vector3(-1, 0, 0),
diameter: collarDiameterIn(
node.returnShape,
node.returnDiameter,
node.returnWidth,
node.returnHeight,
),
system: 'return',
shape: node.returnShape,
width: node.returnWidth,
height: node.returnHeight,
},
]
}
/**
* Refrigerant service connection in the cabinet's LOCAL frame — the point
* a lineset run leaves from (condenser) or arrives at (indoor coil on a
* furnace / air handler). Every equipment type exposes exactly one, on the
* +X service-valve face: a condenser/air-handler near the bottom third, a
* furnace near the top where the cased A-coil sits above the heat
* exchanger.
*/
export function localRefrigerantPorts(node: HvacEquipmentNode): LocalPort[] {
const y = node.equipmentType === 'furnace' ? node.height * 0.8 : node.height * 0.3
return [
{
id: 'lineset',
position: new Vector3(node.width / 2, y, 0),
direction: new Vector3(1, 0, 0),
diameter: REFRIGERANT_PORT_DIAMETER_IN,
system: 'refrigerant',
},
]
}
/** `def.ports` — duct + refrigerant ports transformed into level-local
* space (yaw + position). */
export function getHvacEquipmentPorts(node: HvacEquipmentNode): NodePort[] {
const offset = new Vector3(node.position[0], node.position[1], node.position[2])
const local = [...localEquipmentPorts(node), ...localRefrigerantPorts(node)]
return local.map((port) => {
const position = port.position.clone().applyAxisAngle(new Vector3(0, 1, 0), node.rotation)
position.add(offset)
const direction = port.direction
.clone()
.applyAxisAngle(new Vector3(0, 1, 0), node.rotation)
.normalize()
return {
id: port.id,
position: [position.x, position.y, position.z] as const,
direction: [direction.x, direction.y, direction.z] as const,
diameter: port.diameter,
system: port.system,
shape: port.shape,
width: port.width,
height: port.height,
}
})
}
@@ -0,0 +1 @@
export { HvacEquipmentNode } from '@pascal-app/core'
+135
View File
@@ -0,0 +1,135 @@
'use client'
import { emitter, type GridEvent, HvacEquipmentNode, useScene } from '@pascal-app/core'
import { triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useMemo, useRef, useState } from 'react'
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import { hvacEquipmentDefinition } from './definition'
import { buildHvacEquipmentGeometry } from './geometry'
const PREVIEW_OPACITY = 0.55
/** R/T yaw step — 45°, matching the editor's default rotate. */
const ROTATE_STEP_RAD = Math.PI / 4
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
/**
* Click-place tool for HVAC equipment (furnace / air handler /
* condenser). A translucent cabinet ghost follows the cursor on the
* floor with grid snap; **R / T** rotate the ghost ±45° around Y. Click
* places the unit — its supply/return collars become ports the duct
* tools snap onto. Equipment type and cabinet size are edited in the
* inspector after placement.
*/
const HvacEquipmentTool = () => {
const activeLevelId = useViewer((s) => s.selection.levelId)
const [cursor, setCursor] = useState<[number, number, number] | null>(null)
const [yaw, setYaw] = useState(0)
const yawRef = useRef(0)
const previewNode = useMemo(
() => HvacEquipmentNode.parse({ ...hvacEquipmentDefinition.defaults(), name: 'Furnace' }),
[],
)
const ghost = useMemo(() => {
const group = buildHvacEquipmentGeometry(previewNode)
group.traverse((child) => {
const mesh = child as { material?: { transparent: boolean; opacity: number } }
if (mesh.material) {
mesh.material.transparent = true
mesh.material.opacity = PREVIEW_OPACITY
}
})
return group
}, [previewNode])
useEffect(() => {
if (!activeLevelId) return
const resolve = (event: GridEvent): [number, number, number] => {
const step = event.nativeEvent?.shiftKey === true ? 0 : useEditor.getState().gridSnapStep
return [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)]
}
// Grid-snap the cursor, then layer Figma-style alignment so the unit lines
// up with ducts, other equipment, and items as it's placed (Shift = free,
// no snap + no guides).
const resolveAligned = (event: GridEvent): [number, number, number] =>
alignDrawPoint(resolve(event), {
applySnap: true,
bypass: event.nativeEvent?.shiftKey === true,
})
const onMove = (event: GridEvent) => setCursor(resolveAligned(event))
const onClick = (event: GridEvent) => {
const position = resolveAligned(event)
const unit = HvacEquipmentNode.parse({
...hvacEquipmentDefinition.defaults(),
name: 'Furnace',
position,
rotation: yawRef.current,
})
useScene.getState().createNode(unit, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [unit.id] })
triggerSFX('sfx:item-place')
}
const onKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
const key = e.key
if (key !== 'r' && key !== 'R' && key !== 't' && key !== 'T') return
// Capture-phase + stopPropagation so the editor's selection-rotate
// handler doesn't also spin the previously placed unit.
e.preventDefault()
e.stopPropagation()
const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1
yawRef.current += steps * ROTATE_STEP_RAD
setYaw(yawRef.current)
triggerSFX('sfx:item-rotate')
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', onClick)
window.addEventListener('keydown', onKeyDown, true)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', onClick)
window.removeEventListener('keydown', onKeyDown, true)
clearDrawAlignment()
}
}, [activeLevelId])
if (!activeLevelId || !cursor) return null
return (
<LevelOffsetGroup>
<group position={cursor} rotation={[0, yaw, 0]}>
<primitive object={ghost} />
</group>
<Html
center
position={[cursor[0], cursor[1] + previewNode.height + 0.4, cursor[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
<span className="font-medium text-foreground">R/T rotate</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground"> smooth</span>
</div>
</Html>
</LevelOffsetGroup>
)
}
export default HvacEquipmentTool
+29
View File
@@ -8,13 +8,22 @@ import { cupolaDefinition } from './cupola'
import { doorDefinition } from './door'
import { dormerDefinition } from './dormer'
import { downspoutDefinition } from './downspout'
import { ductFittingDefinition } from './duct-fitting'
import { ductSegmentDefinition } from './duct-segment'
import { ductTerminalDefinition } from './duct-terminal'
import { elevatorDefinition } from './elevator'
import { eyebrowVentDefinition } from './eyebrow-vent'
import { fenceDefinition } from './fence'
import { guideDefinition } from './guide'
import { gutterDefinition } from './gutter'
import { hvacEquipmentDefinition } from './hvac-equipment'
import { itemDefinition } from './item'
import { levelDefinition } from './level'
import { linesetDefinition } from './lineset'
import { liquidLineDefinition } from './liquid-line'
import { pipeFittingDefinition } from './pipe-fitting'
import { pipeSegmentDefinition } from './pipe-segment'
import { pipeTrapDefinition } from './pipe-trap'
import { ridgeVentDefinition } from './ridge-vent'
import { roofDefinition } from './roof'
import { roofSegmentDefinition } from './roof-segment'
@@ -88,6 +97,17 @@ export const builtinPlugin: Plugin = {
dormerDefinition as unknown as AnyNodeDefinition,
gutterDefinition as unknown as AnyNodeDefinition,
downspoutDefinition as unknown as AnyNodeDefinition,
// HVAC — Phase 1: round duct segment polyline. Phase 2: fittings + ports.
ductSegmentDefinition as unknown as AnyNodeDefinition,
ductFittingDefinition as unknown as AnyNodeDefinition,
ductTerminalDefinition as unknown as AnyNodeDefinition,
hvacEquipmentDefinition as unknown as AnyNodeDefinition,
linesetDefinition as unknown as AnyNodeDefinition,
liquidLineDefinition as unknown as AnyNodeDefinition,
// DWV plumbing — Phase 2 of the research doc's plan.
pipeSegmentDefinition as unknown as AnyNodeDefinition,
pipeFittingDefinition as unknown as AnyNodeDefinition,
pipeTrapDefinition as unknown as AnyNodeDefinition,
],
}
@@ -100,13 +120,22 @@ export { cupolaDefinition } from './cupola'
export { doorDefinition } from './door'
export { dormerDefinition } from './dormer'
export { downspoutDefinition } from './downspout'
export { ductFittingDefinition } from './duct-fitting'
export { ductSegmentDefinition } from './duct-segment'
export { ductTerminalDefinition } from './duct-terminal'
export { elevatorDefinition } from './elevator'
export { eyebrowVentDefinition } from './eyebrow-vent'
export { fenceDefinition } from './fence'
export { guideDefinition } from './guide'
export { gutterDefinition } from './gutter'
export { hvacEquipmentDefinition } from './hvac-equipment'
export { itemDefinition } from './item'
export { levelDefinition } from './level'
export { linesetDefinition } from './lineset'
export { liquidLineDefinition, useLiquidLineToolOptions } from './liquid-line'
export { pipeFittingDefinition } from './pipe-fitting'
export { pipeSegmentDefinition } from './pipe-segment'
export { pipeTrapDefinition } from './pipe-trap'
export { ridgeVentDefinition } from './ridge-vent'
export { roofDefinition } from './roof'
export { roofSegmentDefinition } from './roof-segment'
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test'
import { planLinesetConnect } from './connect'
import type { LinesetNode } from './schema'
type Point = [number, number, number]
/** Minimal stand-in — the planner only reads `id` and `path`. */
function line(id: string, path: Point[]): LinesetNode {
return { id, path } as unknown as LinesetNode
}
describe('planLinesetConnect', () => {
test('no shared endpoint → create', () => {
const plan = planLinesetConnect(
[
line('a', [
[0, 0, 0],
[1, 0, 0],
]),
],
[5, 0, 0],
[6, 0, 0],
)
expect(plan).toEqual({
kind: 'create',
path: [
[5, 0, 0],
[6, 0, 0],
],
})
})
test('new start meets run end → extend, old end becomes interior', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const plan = planLinesetConnect([a], [1, 0, 0], [1, 0, 2])
expect(plan).toEqual({
kind: 'extend',
id: 'a',
path: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 2],
],
})
})
test('new start meets run start → extend, run reversed so join is interior', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const plan = planLinesetConnect([a], [0, 0, 0], [0, 0, 2])
expect(plan).toEqual({
kind: 'extend',
id: 'a',
path: [
[1, 0, 0],
[0, 0, 0],
[0, 0, 2],
],
})
})
test('new end meets a run → extend, new segment leads', () => {
const a = line('a', [
[1, 0, 0],
[2, 0, 0],
])
const plan = planLinesetConnect([a], [1, 0, 3], [1, 0, 0])
expect(plan).toEqual({
kind: 'extend',
id: 'a',
path: [
[1, 0, 3],
[1, 0, 0],
[2, 0, 0],
],
})
})
test('both ends meet distinct runs → bridge, second run absorbed', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const b = line('b', [
[1, 0, 5],
[2, 0, 5],
])
const plan = planLinesetConnect([a, b], [1, 0, 0], [1, 0, 5])
expect(plan).toEqual({
kind: 'bridge',
id: 'a',
deleteId: 'b',
path: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 5],
[2, 0, 5],
],
})
})
test('both ends meet the SAME run → not a bridge (extends at start)', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const plan = planLinesetConnect([a], [0, 0, 0], [1, 0, 0])
expect(plan.kind).toBe('extend')
})
test('float drift within tolerance still coincides', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const plan = planLinesetConnect([a], [1.0000001, 0, 0], [1, 0, 2])
expect(plan.kind).toBe('extend')
})
})
+98
View File
@@ -0,0 +1,98 @@
import type { LinesetNode } from './schema'
type Point = [number, number, number]
type LinesetId = LinesetNode['id']
/** Coincidence tolerance (meters) for folding endpoints into one run. The
* draw tool snaps onto an existing run's endpoint exactly, so this only
* needs to absorb float drift, not user aim. */
const COINCIDENT_EPS_M = 1e-3
function samePoint(a: Point, b: Point): boolean {
return (
Math.abs(a[0] - b[0]) < COINCIDENT_EPS_M &&
Math.abs(a[1] - b[1]) < COINCIDENT_EPS_M &&
Math.abs(a[2] - b[2]) < COINCIDENT_EPS_M
)
}
/** Which terminal of `line` coincides with `p`, if either. */
function matchEnd(line: LinesetNode, p: Point): 'start' | 'end' | null {
const path = line.path as Point[]
if (samePoint(path[0]!, p)) return 'start'
if (samePoint(path[path.length - 1]!, p)) return 'end'
return null
}
/** First lineset whose start or end coincides with `p`. */
function findConnection(
existing: LinesetNode[],
p: Point,
): { line: LinesetNode; side: 'start' | 'end' } | null {
for (const line of existing) {
if (line.path.length < 2) continue
const side = matchEnd(line, p)
if (side) return { line, side }
}
return null
}
/** Path re-ordered so the connecting terminal is its LAST point. */
function endLast(path: Point[], side: 'start' | 'end'): Point[] {
return side === 'end' ? path : [...path].reverse()
}
/** Path re-ordered so the connecting terminal is its FIRST point. */
function startFirst(path: Point[], side: 'start' | 'end'): Point[] {
return side === 'start' ? path : [...path].reverse()
}
/**
* Outcome of committing a new `start`→`end` segment against the existing
* lineset runs on the same level:
* - `create` — no shared endpoint; place a fresh standalone run.
* - `extend` — one end lands on run `id`; grow that run's path so the old
* terminal becomes an interior point (the geometry miters it).
* - `bridge` — both ends land on two *different* runs; weld them plus the
* new segment into one path on `id` and delete the absorbed `deleteId`.
*/
export type LinesetConnectPlan =
| { kind: 'create'; path: Point[] }
| { kind: 'extend'; id: LinesetId; path: Point[] }
| { kind: 'bridge'; id: LinesetId; path: Point[]; deleteId: LinesetId }
/**
* Decide how a freshly drawn `start`→`end` segment folds into existing
* lineset runs that share an endpoint coordinate. Pure: returns a plan, the
* caller mutates the scene. Coords are level-local, so `existing` must be
* pre-filtered to the segment's level.
*/
export function planLinesetConnect(
existing: LinesetNode[],
start: Point,
end: Point,
): LinesetConnectPlan {
const atStart = findConnection(existing, start)
const atEnd = findConnection(existing, end)
// Both ends meet distinct runs → weld the three into one path.
if (atStart && atEnd && atStart.line.id !== atEnd.line.id) {
const left = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
const right = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
return {
kind: 'bridge',
id: atStart.line.id,
path: [...left, ...right],
deleteId: atEnd.line.id,
}
}
if (atStart) {
const base = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
return { kind: 'extend', id: atStart.line.id, path: [...base, end] }
}
if (atEnd) {
const base = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
return { kind: 'extend', id: atEnd.line.id, path: [start, ...base] }
}
return { kind: 'create', path: [start, end] }
}
+132
View File
@@ -0,0 +1,132 @@
import type { NodeDefinition } from '@pascal-app/core'
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
import { buildLinesetFloorplan } from './floorplan'
import { buildLinesetGeometry } from './geometry'
import { linesetParametrics } from './parametrics'
import { LinesetNode } from './schema'
/**
* Refrigerant lineset — the copper suction + liquid pair joining a split
* system's outdoor condenser to its indoor coil. The refrigerant-side
* sibling of `duct-segment`: same polyline model and draw tool, but it
* snaps onto refrigerant service ports instead of duct collars.
*
* Composition: `def.geometry` only, plus a selection-time path-handle
* system shared in spirit with the duct segment. The framework's
* `<ParametricNodeRenderer>` mounts an empty group; `<GeometrySystem>`
* fills it via `buildLinesetGeometry` on dirty.
*/
export const linesetDefinition: NodeDefinition<typeof LinesetNode> = {
kind: 'lineset',
schemaVersion: 1,
schema: LinesetNode,
category: 'utility',
distributionRole: 'run',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
path: [
[0, 0, 0],
[2, 0, 0],
],
suctionDiameter: 0.875,
liquidDiameter: 0.375,
insulated: true,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: linesetParametrics,
geometry: buildLinesetGeometry,
geometryKey: (n) => JSON.stringify([n.path, n.suctionDiameter, n.liquidDiameter, n.insulated]),
// Open run ends as typed refrigerant ports — directions point outward
// along the path tangent so they mate flush onto a service valve. Path
// coords are already level-local, so no transform is needed.
ports: (n) => {
if (n.path.length < 2) return []
const diameter = n.suctionDiameter
const unit = (
a: readonly [number, number, number],
b: readonly [number, number, number],
): [number, number, number] => {
const d: [number, number, number] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
const len = Math.hypot(d[0], d[1], d[2])
return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len]
}
const first = n.path[0]!
const second = n.path[1]!
const last = n.path[n.path.length - 1]!
const prev = n.path[n.path.length - 2]!
return [
{
id: 'start',
position: first,
direction: unit(first, second),
diameter,
system: 'refrigerant',
},
{
id: 'end',
position: last,
direction: unit(last, prev),
diameter,
system: 'refrigerant',
},
]
},
floorplan: buildLinesetFloorplan,
// 2D selection-time path-point handles — the floor-plan twin of the 3D
// `affordanceTools.selection` handles. The builder emits an
// `endpoint-handle` per path vertex; this drags the matching point.
floorplanAffordances: {
'move-path-point': createPathPointMoveAffordance('lineset'),
},
// Selection-time path-point handles (drag to edit a committed run).
// Editor-only UI (reads gridSnapStep, renders DimensionPill), so it
// mounts via the editor's SelectionAffordanceManager — not `def.system`,
// which the viewer package mounts for the read-only route.
affordanceTools: {
selection: () => import('./selection'),
// Ghost-preview duplicate / move (the refrigerant-loop sibling of
// duct-segment's mover). Duplicate is pure drag-to-place: a translucent
// copy of the run, wrapped in a footprint bounding box, follows the
// cursor and only lands on the commit click — nothing is inserted into
// the scene before that.
move: () => import('./move-tool'),
},
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Start lineset' },
{ key: 'Click again', label: 'Place it (locked to 45°)' },
{ key: 'Shift', label: 'Free angle' },
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
{ key: 'Esc', label: 'Cancel start point' },
],
presentation: {
label: 'Lineset',
description:
'Refrigerant lineset — copper suction + liquid pair joining a condenser to the indoor coil.',
icon: { kind: 'url', src: '/icons/lineset.png' },
paletteSection: 'structure',
paletteOrder: 93,
},
mcp: {
description:
'A refrigerant lineset defined as a polyline: an insulated suction line plus a bare liquid line, joining an HVAC condenser to its indoor coil. Snaps onto refrigerant service ports.',
},
}
+89
View File
@@ -0,0 +1,89 @@
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import type { LinesetNode } from './schema'
const COPPER_LINE = '#b06b3f'
const BODY_COLOR = '#9ca3af'
/**
* Floor-plan representation of a lineset: the path drawn at the suction
* jacket's real width with a dashed copper centerline. Vertical risers
* collapse to a point in plan; consecutive duplicate plan points are
* dropped so they don't render zero-length artifacts.
*/
export function buildLinesetFloorplan(
node: LinesetNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
if (node.path.length < 2) return null
const points: FloorplanPoint[] = []
// Plan point k ← original path index indexMap[k] (risers collapse to one
// plan point), so the path-point drag handle edits the right vertex.
const indexMap: number[] = []
for (let i = 0; i < node.path.length; i++) {
const [x, , z] = node.path[i]!
const prev = points[points.length - 1]
if (prev && Math.abs(prev[0] - x) < 1e-6 && Math.abs(prev[1] - z) < 1e-6) continue
points.push([x, z])
indexMap.push(i)
}
const widthM = Math.max(node.suctionDiameter, node.liquidDiameter) * INCHES_TO_METERS
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
if (points.length < 2) {
const p = points[0] ?? [node.path[0]![0], node.path[0]![2]]
return {
kind: 'circle',
cx: p[0],
cy: p[1],
r: widthM,
fill: BODY_COLOR,
stroke: showSelectedChrome && palette ? palette.selectedStroke : COPPER_LINE,
strokeWidth: 0.02,
opacity: 0.9,
}
}
const children: FloorplanGeometry[] = [
{
kind: 'polyline',
points,
stroke: showSelectedChrome && palette ? palette.selectedStroke : BODY_COLOR,
strokeWidth: widthM * 2,
strokeLinecap: 'round',
strokeLinejoin: 'round',
opacity: showSelectedChrome ? 0.95 : 0.8,
},
{
kind: 'polyline',
points,
stroke: COPPER_LINE,
strokeWidth: 1.5,
vectorEffect: 'non-scaling-stroke',
strokeDasharray: '4 3',
strokeLinecap: 'round',
strokeLinejoin: 'round',
opacity: 0.9,
},
]
// Selection chrome: one draggable handle per path vertex (2D twin of the
// 3D selection handles). Routes to the shared `move-path-point` affordance.
if (view?.selected) {
for (let k = 0; k < points.length; k++) {
children.push({
kind: 'endpoint-handle',
point: points[k]!,
state: 'idle',
affordance: 'move-path-point',
payload: { pointIndex: indexMap[k]! },
})
}
}
return { kind: 'group', children }
}
+105
View File
@@ -0,0 +1,105 @@
import { CylinderGeometry, Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import type { LinesetNode } from './schema'
const RADIAL_SEGMENTS = 16
const COPPER_COLOR = '#b06b3f'
// Light foam sleeve. Real Armaflex is black, but a light jacket reads
// cleaner against the scene and matches the white pipe materials.
const INSULATION_COLOR = '#e8e8ea'
const UP = new Vector3(0, 1, 0)
/**
* Foam-jacket thickness (meters) wrapped around the line when `insulated`. A
* real ~3/4" black Armaflex sleeve adds ~3/8" of wall; this matches that so an
* insulated line reads visibly fatter than the bare copper underneath.
*/
const INSULATION_THICKNESS_M = 0.01
/** Cylinder spanning `start`→`end` at `radius`, named for debugging. */
function buildRun(
start: Vector3,
end: Vector3,
radius: number,
material: MeshStandardMaterial,
name: string,
): Mesh | null {
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-6) return null
dir.normalize()
const mesh = new Mesh(
new CylinderGeometry(radius, radius, length, RADIAL_SEGMENTS, 1, false),
material,
)
mesh.name = name
mesh.position.copy(start).addScaledVector(dir, length / 2)
mesh.quaternion.setFromUnitVectors(UP, dir)
return mesh
}
/**
* Pure geometry builder for a refrigerant lineset: a single copper line that
* follows the node path centerline, optionally wrapped in a foam jacket.
*
* One line per node — what the ghost previews is exactly what commits. To run
* the suction line beside the liquid line, draw them as two separate linesets
* rather than rendering both together off one path. Joint spheres cap interior
* corners so turns read as continuous pipe.
*
* Children are level-local meters; `<ParametricNodeRenderer>` owns the
* node transform (identity today — the path is absolute within the level).
*/
export function buildLinesetGeometry(node: LinesetNode): Group {
const group = new Group()
if (node.path.length < 2) return group
const copperR = (node.suctionDiameter * INCHES_TO_METERS) / 2
const jacketR = node.insulated ? copperR + INSULATION_THICKNESS_M : copperR
const copperMat = new MeshStandardMaterial({
color: COPPER_COLOR,
metalness: 0.8,
roughness: 0.3,
})
const insulationMat = new MeshStandardMaterial({
color: INSULATION_COLOR,
metalness: 0.1,
roughness: 0.9,
})
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
for (let i = 0; i < points.length - 1; i++) {
const copper = buildRun(points[i]!, points[i + 1]!, copperR, copperMat, `lineset-copper-${i}`)
if (copper) group.add(copper)
if (node.insulated) {
const jacket = buildRun(
points[i]!,
points[i + 1]!,
jacketR,
insulationMat,
`lineset-jacket-${i}`,
)
if (jacket) group.add(jacket)
}
}
// Joint caps at interior corners so turns read as continuous pipe.
for (let i = 1; i < points.length - 1; i++) {
const joint = new Mesh(new SphereGeometry(copperR, RADIAL_SEGMENTS, 10), copperMat)
joint.name = `lineset-copper-joint-${i}`
joint.position.copy(points[i] as Vector3)
group.add(joint)
if (node.insulated) {
const jJoint = new Mesh(new SphereGeometry(jacketR, RADIAL_SEGMENTS, 10), insulationMat)
jJoint.name = `lineset-jacket-joint-${i}`
jJoint.position.copy(points[i] as Vector3)
group.add(jJoint)
}
}
return group
}
+4
View File
@@ -0,0 +1,4 @@
export { type LinesetConnectPlan, planLinesetConnect } from './connect'
export { linesetDefinition } from './definition'
export { buildLinesetGeometry } from './geometry'
export { LinesetNode } from './schema'
+304
View File
@@ -0,0 +1,304 @@
'use client'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
emitter,
type GridEvent,
LinesetNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import {
DragBoundingBox,
EDITOR_LAYER,
markToolCancelConsumed,
stripPlacementMetadataFlags,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef, useState } from 'react'
import { Vector3 } from 'three'
import {
type Aabb2D,
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
type Vec3 = [number, number, number]
const GHOST_COLOR = '#818cf8'
const GHOST_OPACITY = 0.5
const IN_TO_M = 0.0254
/** Snap a coordinate to the editor's live grid step. */
function snapToGridStep(value: number): number {
const step = useEditor.getState().gridSnapStep
if (step <= 0) return value
return Math.round(value / step) * step
}
function pathCenterXZ(path: readonly Vec3[]): [number, number] {
let x = 0
let z = 0
for (const p of path) {
x += p[0]
z += p[2]
}
const n = path.length || 1
return [x / n, z / n]
}
/** The lineset's footprint radius (meters) — half the suction OD (the
* bigger of the pair), used as box / footprint padding and ghost radius. */
function linesetRadiusM(lineset: LinesetNode): number {
return (lineset.suctionDiameter * IN_TO_M) / 2
}
/** XZ bounds of a path padded by the lineset's radius. */
function pathAabb(path: readonly Vec3[], r: number): Aabb2D {
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (const p of path) {
if (p[0] < minX) minX = p[0]
if (p[0] > maxX) maxX = p[0]
if (p[2] < minZ) minZ = p[2]
if (p[2] > maxZ) maxZ = p[2]
}
return { minX: minX - r, maxX: maxX + r, minZ: minZ - r, maxZ: maxZ + r }
}
/**
* Ghost-preview duplicate / move tool for refrigerant linesets — the
* refrigerant-loop sibling of `MovePipeSegmentTool`. A lineset is a
* suction + liquid copper pair; the ghost stands in with a single
* translucent cylinder at the suction OD per section (mirrors the draw
* tool's `PreviewSegment`).
*
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
* inserted into the scene until the commit click. A translucent ghost of
* the run rides the cursor inside a footprint bounding box — the same
* affordance other items get — and Figma-style alignment guides snap the
* box's edges to nearby geometry. The next grid click calls `createNode`;
* Esc discards. The run's Y coords ride along untouched: the move only
* shifts XZ.
*
* **Move** (existing run): the real node's mesh is hidden while the same
* ghost + box tracks the cursor; the commit click writes the translated
* `path` and reveals it, Esc reveals it unchanged.
*
* Wired via `def.affordanceTools.move`.
*/
export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
const lineset = node as LinesetNode
const originalPathRef = useRef<Vec3[]>(lineset.path.map((p) => [...p] as Vec3))
const isNew =
typeof node.metadata === 'object' &&
node.metadata !== null &&
!Array.isArray(node.metadata) &&
(node.metadata as Record<string, unknown>).isNew === true
const [previewPath, setPreviewPath] = useState<Vec3[]>(originalPathRef.current)
const previewPathRef = useRef<Vec3[]>(originalPathRef.current)
const hasMovedRef = useRef(false)
const activatedAtRef = useRef<number>(Date.now())
const prevSnapRef = useRef<[number, number] | null>(null)
useEffect(() => {
const nodeId = node.id as AnyNodeId
const originalPath = originalPathRef.current
const [centerX, centerZ] = pathCenterXZ(originalPath)
const r = linesetRadiusM(lineset)
const baseAabb = pathAabb(originalPath, r)
useScene.temporal.getState().pause()
let committed = false
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
useScene.getState().nodes,
nodeId,
useViewer.getState().selection.levelId ?? node.parentId,
)
// Moving an existing run: hide its 3D MESH imperatively (NOT the store
// `visible` flag — the 2D floor plan skips `visible:false` nodes, so a
// store hide makes the run vanish in 2D / split view). The ghost stands
// in until commit; the real mesh is restored on cancel / unmount.
const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId]
const setMeshHidden = (hidden: boolean) => {
const obj = sceneRegistry.nodes.get(nodeId)
if (obj) obj.visible = !hidden
}
if (existedAtStart) setMeshHidden(true)
const setPreview = (path: Vec3[]) => {
previewPathRef.current = path
setPreviewPath(path)
}
const onMove = (event: GridEvent) => {
const bypass = event.nativeEvent?.shiftKey === true
const snap = bypass ? (v: number) => v : snapToGridStep
let dx = snap(event.localPosition[0] - centerX)
let dz = snap(event.localPosition[2] - centerZ)
// Figma-style alignment: snap the run's footprint box edges onto
// nearby geometry and publish the guides (Shift bypass).
if (!bypass) {
const proposed: Aabb2D = {
minX: baseAabb.minX + dx,
maxX: baseAabb.maxX + dx,
minZ: baseAabb.minZ + dz,
maxZ: baseAabb.maxZ + dz,
}
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
dx += sdx
dz += sdz
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
}
const cur: [number, number] = [centerX + dx, centerZ + dz]
if (
!bypass &&
(!prevSnapRef.current ||
prevSnapRef.current[0] !== cur[0] ||
prevSnapRef.current[1] !== cur[1])
) {
triggerSFX('sfx:grid-snap')
}
prevSnapRef.current = cur
hasMovedRef.current = true
setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3))
}
const commit = (event: GridEvent) => {
if (committed) return
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
if (!hasMovedRef.current) {
event.nativeEvent?.stopPropagation?.()
return
}
committed = true
const finalPath = previewPathRef.current
useScene.temporal.getState().resume()
let selectId = nodeId
if (isNew && !useScene.getState().nodes[nodeId]) {
const created = LinesetNode.parse({
...(node as Record<string, unknown>),
path: finalPath,
metadata: stripPlacementMetadataFlags(node.metadata),
visible: true,
})
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
useScene.getState().updateNode(nodeId, { path: finalPath } as Partial<AnyNode>)
useScene.getState().markDirty(nodeId)
}
useScene.temporal.getState().pause()
setMeshHidden(false)
useAlignmentGuides.getState().clear()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [selectId] })
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
}
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
markToolCancelConsumed()
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', commit)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
}
}, [lineset, isNew, node])
const segments: Array<{ a: Vec3; b: Vec3 }> = []
for (let i = 0; i < previewPath.length - 1; i++) {
segments.push({ a: previewPath[i]!, b: previewPath[i + 1]! })
}
// Footprint box spanning the whole run (axis-aligned), drawn around the
// ghost the same way items get one. Recomputed from the live preview path.
const r = linesetRadiusM(lineset)
const box = pathAabb(previewPath, r)
const boxY = previewPath[0]?.[1] ?? 0
return (
<group>
{segments.map((seg, i) => (
<GhostSegment a={seg.a} b={seg.b} radius={r} key={`ghost-${i}`} />
))}
<DragBoundingBox
centerY={0}
nodeId={node.id}
position={[(box.minX + box.maxX) / 2, boxY, (box.minZ + box.maxZ) / 2]}
size={[box.maxX - box.minX, lineset.suctionDiameter * IN_TO_M, box.maxZ - box.minZ]}
/>
</group>
)
}
/** Translucent stand-in for one lineset section — mirrors the draw tool's
* `PreviewSegment` so the ghost matches what actually lands. */
function GhostSegment({ a, b, radius }: { a: Vec3; b: Vec3; radius: number }) {
const start = new Vector3(...a)
const end = new Vector3(...b)
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-4) return null
dir.normalize()
const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5)
return (
<mesh
layers={EDITOR_LAYER}
position={mid.toArray()}
ref={(m) => {
if (!m) return
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
}}
>
<cylinderGeometry args={[radius, radius, length, 16, 1, false]} />
<meshBasicMaterial
color={GHOST_COLOR}
depthTest={false}
opacity={GHOST_OPACITY}
transparent
/>
</mesh>
)
}
export default MoveLinesetTool
+37
View File
@@ -0,0 +1,37 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { LinesetNode } from './schema'
export const linesetParametrics: ParametricDescriptor<LinesetNode> = {
groups: [
{
label: 'Lines',
fields: [
{
key: 'suctionDiameter',
kind: 'number',
unit: 'in',
min: 0.25,
max: 1.5,
step: 0.125,
},
{
key: 'liquidDiameter',
kind: 'number',
unit: 'in',
min: 0.125,
max: 0.75,
step: 0.125,
},
],
},
{
label: 'Insulation',
fields: [
{
key: 'insulated',
kind: 'boolean',
},
],
},
],
}
+1
View File
@@ -0,0 +1 @@
export { LinesetNode } from '@pascal-app/core'
+282
View File
@@ -0,0 +1,282 @@
'use client'
import {
type AnyNodeId,
type LinesetNode,
pauseSceneHistory,
resumeSceneHistory,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useRef, useState } from 'react'
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
const HANDLE_RADIUS = 0.08
const PORT_SNAP_RADIUS_M = 0.4
const UP = new Vector3(0, 1, 0)
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
type Point = [number, number, number]
/**
* Selection-time editing for committed lineset runs: one draggable handle
* per path point. Mirrors the duct-segment path-handle system, but dragged
* run endpoints snap onto refrigerant ports only.
*
* Handles are PORTALED into the lineset's registered scene group so they
* share its exact frame. Drag raycasts run in world space and convert hits
* back into the group's local frame before writing the path.
*/
const LinesetSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const lineset = useScene((s) => {
if (selectedIds.length !== 1) return null
const node = s.nodes[selectedIds[0] as AnyNodeId]
return node?.type === 'lineset' ? (node as LinesetNode) : null
})
const linesetId = lineset?.id ?? null
const [target, setTarget] = useState<Object3D | null>(null)
useEffect(() => {
if (!linesetId) {
setTarget(null)
return
}
let frameId = 0
const resolve = () => {
const next = sceneRegistry.nodes.get(linesetId as AnyNodeId) ?? null
setTarget((cur) => (cur === next ? cur : next))
if (!next) frameId = window.requestAnimationFrame(resolve)
}
resolve()
return () => window.cancelAnimationFrame(frameId)
}, [linesetId])
if (!lineset || !target) return null
return createPortal(<LinesetPointHandles lineset={lineset} target={target} />, target, undefined)
}
const LinesetPointHandles = ({ lineset, target }: { lineset: LinesetNode; target: Object3D }) => {
const { camera, gl } = useThree()
const unit = useViewer((s) => s.unit)
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const dragRef = useRef<{
index: number
initialPath: Point[]
current: Point
cleanup: () => void
} | null>(null)
const makeRay = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const ndc = new Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
const raycaster = new Raycaster()
raycaster.setFromCamera(ndc, camera)
return raycaster.ray
}
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
const hit = new Vector3()
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
}
const projectOntoAxis = (
clientX: number,
clientY: number,
anchorWorld: Vector3,
axisWorld: Vector3,
): number | null => {
const ray = makeRay(clientX, clientY)
const w0 = new Vector3().subVectors(ray.origin, anchorWorld)
const b = ray.direction.dot(axisWorld)
const denom = 1 - b * b
if (Math.abs(denom) < 1e-6) return null
const d0 = ray.direction.dot(w0)
const e0 = axisWorld.dot(w0)
return (e0 - b * d0) / denom
}
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
const toLocal = (world: Vector3): Point => {
const local = target.worldToLocal(world.clone())
return [local.x, local.y, local.z]
}
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const initialPath = lineset.path.map((p) => [...p] as Point)
const startPoint = initialPath[index]!
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
document.body.style.cursor = 'grabbing'
setDraggingIndex(index)
const isEndpoint = index === 0 || index === initialPath.length - 1
const neighbor = initialPath[index === 0 ? 1 : index - 1]!
const axisLocal = new Vector3(
startPoint[0] - neighbor[0],
startPoint[1] - neighbor[1],
startPoint[2] - neighbor[2],
)
if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0)
axisLocal.normalize()
const anchorWorldStart = toWorld(startPoint)
const axisWorld = toWorld([
startPoint[0] + axisLocal.x,
startPoint[1] + axisLocal.y,
startPoint[2] + axisLocal.z,
])
.sub(anchorWorldStart)
.normalize()
const onMove = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag) return
const current = drag.current
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
let next: Point | null = null
if (event.altKey) {
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current))
const hit = intersect(event.clientX, event.clientY, plane)
if (hit) {
const local = toLocal(hit)
next = [snap(local[0], step), current[1], snap(local[2], step)]
if (isEndpoint) {
const port = findNearestPortXZ(
[local[0], current[1], local[2]],
collectScenePorts({ excludeNodeId: lineset.id, systems: REFRIGERANT_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (port) next = [port.position[0], port.position[1], port.position[2]]
}
}
} else {
const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld)
if (t !== null) {
const dist = snap(t, step)
next = [
startPoint[0] + axisLocal.x * dist,
Math.max(0, startPoint[1] + axisLocal.y * dist),
startPoint[2] + axisLocal.z * dist,
]
}
}
if (!next) return
if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return
drag.current = next
const path = lineset.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
useScene.getState().updateNode(lineset.id, { path })
}
const onUp = () => {
const drag = dragRef.current
if (!drag) return
drag.cleanup()
dragRef.current = null
setDraggingIndex(null)
const finalPath = drag.initialPath.map((p, i) =>
i === drag.index ? drag.current : p,
) as Point[]
useScene.getState().updateNode(lineset.id, { path: drag.initialPath })
resumeSceneHistory(useScene)
const moved = finalPath[drag.index]!.some(
(v, axis) => v !== drag.initialPath[drag.index]![axis],
)
if (moved) useScene.getState().updateNode(lineset.id, { path: finalPath })
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
useViewer.getState().setInputDragging(false)
document.body.style.cursor = ''
}
dragRef.current = { index, initialPath, current: startPoint, cleanup }
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
return (
<group>
{lineset.path.map((p, i) => {
const active = draggingIndex === i
const hovered = hoverIndex === i
return (
<mesh
key={`lineset-handle-${i}`}
layers={EDITOR_LAYER}
onPointerDown={onHandleDown(i)}
onPointerEnter={(e) => {
e.stopPropagation()
setHoverIndex(i)
if (draggingIndex === null) document.body.style.cursor = 'grab'
}}
onPointerLeave={() => {
setHoverIndex((prev) => (prev === i ? null : prev))
if (draggingIndex === null) document.body.style.cursor = ''
}}
position={p as Point}
>
<sphereGeometry args={[HANDLE_RADIUS, 16, 12]} />
<meshBasicMaterial
color={active || hovered ? '#a5b4fc' : '#818cf8'}
depthTest={false}
opacity={active ? 1 : 0.85}
transparent
/>
</mesh>
)
})}
{draggingIndex !== null &&
lineset.path[draggingIndex] &&
(() => {
const point = lineset.path[draggingIndex]!
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
const axes = ['x', 'y', 'z'] as const
const primary = axes.reduce((best, axis, i) =>
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
)
return (
<Html
center
position={[point[0], point[1] + 0.35, point[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<DimensionPill
parts={axes.map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: deltas[i]!,
signed: true,
}))}
primary={primary}
unit={unit}
/>
</Html>
)
})()}
</group>
)
}
export default LinesetSelectionAffordance
+388
View File
@@ -0,0 +1,388 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, LinesetNode, useScene } from '@pascal-app/core'
import {
CursorSphere,
DimensionPill,
EDITOR_LAYER,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useRef, useState } from 'react'
import { type Group, Vector3 } from 'three'
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
import { planLinesetConnect } from './connect'
import { linesetDefinition } from './definition'
/**
* One-segment-at-a-time placement tool for refrigerant linesets — the
* refrigerant-loop sibling of the duct-segment tool.
*
* Mouse-driven model:
* - **First click** anchors the run start. Within range of a refrigerant
* service port (a condenser / coil valve, or another lineset's end) it
* snaps onto the port so a run mates flush.
* - **Second click** commits a two-point lineset and re-arms the tool.
* - The in-flight end is angle-locked to the nearest 45° step in XZ from
* the start; Y stays at the start's height. Hold **Shift** to release.
* - Hold **Alt** → vertical mode. XZ locks to the start; vertical mouse
* motion drives Y. Click commits the riser segment.
* - Esc clears an anchored start point.
*
* Snapping is restricted to refrigerant ports, so a lineset never grabs a
* supply/return duct collar.
*/
const PREVIEW_OPACITY = 0.6
const PREVIEW_COLOR = '#b06b3f'
/** Snap radius (meters) for joining onto a refrigerant port. */
const ENDPOINT_SNAP_RADIUS_M = 0.5
/** Angle step (radians) for the XZ angle lock — 45°. */
const ANGLE_STEP_RAD = Math.PI / 4
/** Mouse pixels → meters mapping for Alt-vertical drag. 100 px ≈ 1 m. */
const ALT_PIXELS_PER_METER = 100
const ALT_Y_MIN_M = -3
const ALT_Y_MAX_M = 10
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
/** Nearest refrigerant port within snap range on the XZ plane, as a
* position tuple. Y is ignored for the distance check; the snap adopts the
* port's full 3D position. */
function findNearbyPort(point: [number, number, number]): [number, number, number] | null {
const port = findNearestPortXZ(
point,
collectScenePorts({ systems: REFRIGERANT_PORT_SYSTEMS }),
ENDPOINT_SNAP_RADIUS_M,
)
return port ? [port.position[0], port.position[1], port.position[2]] : null
}
function projectToAngleLock(
from: [number, number, number],
raw: [number, number, number],
): [number, number, number] {
const dx = raw[0] - from[0]
const dz = raw[2] - from[2]
const len = Math.hypot(dx, dz)
if (len < 1e-4) return [from[0], from[1], from[2]]
const theta = Math.atan2(dz, dx)
const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD
const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped)
const d = Math.max(0, proj)
return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d]
}
const LinesetTool = () => {
const activeLevelId = useViewer((s) => s.selection.levelId)
const unit = useViewer((s) => s.unit)
const cursorRef = useRef<Group>(null)
const [draftPoints, setDraftPoints] = useState<Array<[number, number, number]>>([])
const [cursorPos, setCursorPos] = useState<[number, number, number] | null>(null)
const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null)
const [altActive, setAltActive] = useState(false)
const draftRef = useRef(draftPoints)
draftRef.current = draftPoints
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
const lastClientYRef = useRef<number | null>(null)
useEffect(() => {
if (!activeLevelId) return
const commitSegment = (start: [number, number, number], end: [number, number, number]) => {
const sameSpot =
Math.abs(start[0] - end[0]) < 1e-4 &&
Math.abs(start[1] - end[1]) < 1e-4 &&
Math.abs(start[2] - end[2]) < 1e-4
if (sameSpot) return
// Fold into any existing run that shares this segment's endpoint, so
// two runs meeting at a coordinate become one mitered path instead of
// overlapping nodes. Only same-level runs are candidates — lineset
// paths are level-local.
const scene = useScene.getState()
const existing = Object.values(scene.nodes).filter(
(n): n is LinesetNode =>
n?.type === 'lineset' && (n.parentId as AnyNodeId | null) === activeLevelId,
)
const plan = planLinesetConnect(existing, start, end)
if (plan.kind === 'create') {
const lineset = LinesetNode.parse({
...linesetDefinition.defaults(),
name: 'Lineset',
path: plan.path,
})
scene.createNode(lineset, activeLevelId)
} else if (plan.kind === 'extend') {
scene.updateNode(plan.id, { path: plan.path })
} else {
scene.updateNode(plan.id, { path: plan.path })
scene.deleteNode(plan.deleteId)
}
triggerSFX('sfx:item-place')
setDraftPoints([])
setSnapTarget(null)
altAnchorRef.current = null
setAltActive(false)
}
const resolveSnappedPoint = (
event: GridEvent,
): { point: [number, number, number]; snapped: [number, number, number] | null } => {
const last = draftRef.current.at(-1)
if (!last) {
const raw: [number, number, number] = [event.localPosition[0], 0, event.localPosition[2]]
if (event.nativeEvent?.altKey !== true) {
const target = findNearbyPort(raw)
if (target) return { point: target, snapped: target }
}
const step = useEditor.getState().gridSnapStep
return { point: [snap(raw[0], step), 0, snap(raw[2], step)], snapped: null }
}
const rawXZ: [number, number, number] = [
event.localPosition[0],
last[1],
event.localPosition[2],
]
const shift = event.nativeEvent?.shiftKey === true
const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ)
if (event.nativeEvent?.altKey !== true && !shift) {
const target = findNearbyPort(rawXZ)
if (target) return { point: target, snapped: target }
}
const step = useEditor.getState().gridSnapStep
return { point: [snap(angled[0], step), angled[1], snap(angled[2], step)], snapped: null }
}
const resolveAltVerticalPoint = (clientY: number): [number, number, number] | null => {
const anchor = altAnchorRef.current
const last = draftRef.current.at(-1)
if (!anchor || !last) return null
const step = useEditor.getState().gridSnapStep
const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER
const snappedDy = snap(dy, step)
const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy))
return [last[0], y, last[2]]
}
// Resolve the cursor point (port / grid / angle snap) then layer
// Figma-style alignment so a lineset lines up with other runs, equipment,
// and items as it's drawn. Free point (first vertex / Shift) snaps; an
// angle-locked continuation shows the guide passively. Port snap or Alt
// bypasses alignment.
const resolveAlignedPoint = (event: GridEvent) => {
const r = resolveSnappedPoint(event)
const hasStart = draftRef.current.length > 0
const shift = event.nativeEvent?.shiftKey === true
const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, {
applySnap: !hasStart || shift,
bypass: alt || r.snapped !== null,
})
return { ...r, point }
}
const onMove = (event: GridEvent) => {
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
if (typeof clientY === 'number') lastClientYRef.current = clientY
if (altAnchorRef.current && typeof clientY === 'number') {
const point = resolveAltVerticalPoint(clientY)
if (point) {
clearDrawAlignment()
setCursorPos(point)
setSnapTarget(null)
return
}
}
const { point, snapped } = resolveAlignedPoint(event)
setCursorPos(point)
setSnapTarget(snapped)
}
const onClick = (event: GridEvent) => {
const start = draftRef.current.at(-1)
if (altAnchorRef.current && start) {
const clientY =
(event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current
if (typeof clientY === 'number') {
const point = resolveAltVerticalPoint(clientY)
if (point && Math.abs(point[1] - start[1]) >= 1e-4) {
commitSegment(start, point)
}
}
return
}
const { point } = resolveAlignedPoint(event)
if (!start) {
triggerSFX('sfx:grid-snap')
setDraftPoints([point])
return
}
commitSegment(start, point)
}
const enterAltMode = () => {
const last = draftRef.current.at(-1)
if (!last || lastClientYRef.current === null) return
if (altAnchorRef.current) return
altAnchorRef.current = { clientY: lastClientYRef.current, baseY: last[1] }
setAltActive(true)
}
const exitAltMode = () => {
if (!altAnchorRef.current) return
altAnchorRef.current = null
setAltActive(false)
}
const onKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (e.key === 'Alt') {
e.preventDefault()
enterAltMode()
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Alt') {
e.preventDefault()
exitAltMode()
}
}
const onCancel = () => {
clearDrawAlignment()
if (draftRef.current.length === 0) return
markToolCancelConsumed()
setDraftPoints([])
setCursorPos(null)
setSnapTarget(null)
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', onClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', onClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
altAnchorRef.current = null
clearDrawAlignment()
}
}, [activeLevelId])
if (!activeLevelId) return null
const previewSegments: Array<{ a: [number, number, number]; b: [number, number, number] }> = []
for (let i = 0; i < draftPoints.length - 1; i++) {
previewSegments.push({ a: draftPoints[i]!, b: draftPoints[i + 1]! })
}
const last = draftPoints.at(-1)
if (last && cursorPos) {
previewSegments.push({ a: last, b: cursorPos })
}
const pillParts = cursorPos
? (['x', 'y', 'z'] as const).map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: last ? cursorPos[i]! - last[i]! : cursorPos[i]!,
signed: !!last,
}))
: null
const pillPrimary =
last && cursorPos
? altActive
? 'y'
: Math.abs(cursorPos[0] - last[0]) >= Math.abs(cursorPos[2] - last[2])
? 'x'
: 'z'
: undefined
return (
<LevelOffsetGroup>
{/* Cursor marker — the same ground ring + vertical line + tool-icon
badge the duct draw tool shows in 3D (icon resolved from the active
`lineset` structure-tools entry). In 2D the floorplan overlay draws
this for every tool; in 3D each tool renders its own. The dimension
pill rides just above the cursor. */}
{cursorPos && (
<>
<CursorSphere color={PREVIEW_COLOR} position={cursorPos} ref={cursorRef} />
{pillParts && (
<group position={cursorPos}>
<Html
center
position={[0, 0.35, 0]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
</Html>
</group>
)}
</>
)}
{snapTarget && (
<mesh layers={EDITOR_LAYER} position={snapTarget}>
<sphereGeometry args={[0.1, 24, 16]} />
<meshBasicMaterial color={PREVIEW_COLOR} depthTest={false} opacity={0.35} transparent />
</mesh>
)}
{draftPoints.map((p, i) => (
<mesh key={`pt-${i}`} layers={EDITOR_LAYER} position={p}>
<sphereGeometry args={[0.06, 16, 12]} />
<meshBasicMaterial color={PREVIEW_COLOR} depthTest={false} />
</mesh>
))}
{previewSegments.map((seg, i) => (
<PreviewSegment a={seg.a} b={seg.b} key={`seg-${i}`} />
))}
</LevelOffsetGroup>
)
}
function PreviewSegment({ a, b }: { a: [number, number, number]; b: [number, number, number] }) {
const start = new Vector3(...a)
const end = new Vector3(...b)
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-4) return null
dir.normalize()
const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5)
// Default suction OD (~7/8") for the ghost.
const radius = (0.875 * 0.0254) / 2
return (
<mesh
layers={EDITOR_LAYER}
position={mid.toArray()}
ref={(m) => {
if (!m) return
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
}}
>
<cylinderGeometry args={[radius, radius, length, 16, 1, false]} />
<meshBasicMaterial
color={PREVIEW_COLOR}
depthTest={false}
opacity={PREVIEW_OPACITY}
transparent
/>
</mesh>
)
}
export default LinesetTool
+98
View File
@@ -0,0 +1,98 @@
import type { LiquidLineNode } from './schema'
type Point = [number, number, number]
type LiquidLineId = LiquidLineNode['id']
/** Coincidence tolerance (meters) for folding endpoints into one run. The
* draw tool snaps onto an existing run's endpoint exactly, so this only
* needs to absorb float drift, not user aim. */
const COINCIDENT_EPS_M = 1e-3
function samePoint(a: Point, b: Point): boolean {
return (
Math.abs(a[0] - b[0]) < COINCIDENT_EPS_M &&
Math.abs(a[1] - b[1]) < COINCIDENT_EPS_M &&
Math.abs(a[2] - b[2]) < COINCIDENT_EPS_M
)
}
/** Which terminal of `line` coincides with `p`, if either. */
function matchEnd(line: LiquidLineNode, p: Point): 'start' | 'end' | null {
const path = line.path as Point[]
if (samePoint(path[0]!, p)) return 'start'
if (samePoint(path[path.length - 1]!, p)) return 'end'
return null
}
/** First liquid line whose start or end coincides with `p`. */
function findConnection(
existing: LiquidLineNode[],
p: Point,
): { line: LiquidLineNode; side: 'start' | 'end' } | null {
for (const line of existing) {
if (line.path.length < 2) continue
const side = matchEnd(line, p)
if (side) return { line, side }
}
return null
}
/** Path re-ordered so the connecting terminal is its LAST point. */
function endLast(path: Point[], side: 'start' | 'end'): Point[] {
return side === 'end' ? path : [...path].reverse()
}
/** Path re-ordered so the connecting terminal is its FIRST point. */
function startFirst(path: Point[], side: 'start' | 'end'): Point[] {
return side === 'start' ? path : [...path].reverse()
}
/**
* Outcome of committing a new `start`→`end` segment against the existing
* liquid-line runs on the same level:
* - `create` — no shared endpoint; place a fresh standalone run.
* - `extend` — one end lands on run `id`; grow that run's path so the old
* terminal becomes an interior point (the geometry miters it).
* - `bridge` — both ends land on two *different* runs; weld them plus the
* new segment into one path on `id` and delete the absorbed `deleteId`.
*/
export type LiquidLineConnectPlan =
| { kind: 'create'; path: Point[] }
| { kind: 'extend'; id: LiquidLineId; path: Point[] }
| { kind: 'bridge'; id: LiquidLineId; path: Point[]; deleteId: LiquidLineId }
/**
* Decide how a freshly drawn `start`→`end` segment folds into existing
* liquid-line runs that share an endpoint coordinate. Pure: returns a plan,
* the caller mutates the scene. Coords are level-local, so `existing` must be
* pre-filtered to the segment's level.
*/
export function planLiquidLineConnect(
existing: LiquidLineNode[],
start: Point,
end: Point,
): LiquidLineConnectPlan {
const atStart = findConnection(existing, start)
const atEnd = findConnection(existing, end)
// Both ends meet distinct runs → weld the three into one path.
if (atStart && atEnd && atStart.line.id !== atEnd.line.id) {
const left = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
const right = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
return {
kind: 'bridge',
id: atStart.line.id,
path: [...left, ...right],
deleteId: atEnd.line.id,
}
}
if (atStart) {
const base = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
return { kind: 'extend', id: atStart.line.id, path: [...base, end] }
}
if (atEnd) {
const base = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
return { kind: 'extend', id: atEnd.line.id, path: [start, ...base] }
}
return { kind: 'create', path: [start, end] }
}
@@ -0,0 +1,124 @@
import type { NodeDefinition } from '@pascal-app/core'
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
import { buildLiquidLineFloorplan } from './floorplan'
import { buildLiquidLineGeometry } from './geometry'
import { liquidLineParametrics } from './parametrics'
import { LiquidLineNode } from './schema'
/**
* Standalone refrigerant liquid line — the thin bare-copper line broken out of
* the lineset so it can be drawn on its own. The refrigerant-side sibling of
* `lineset`: same polyline model and draw tool, snapping onto refrigerant
* service ports, but a single thin line. Its tool adds a Follow mode that
* traces an existing lineset's path at an offset.
*
* Composition: `def.geometry` only, plus a selection-time path-handle system
* shared in spirit with the lineset. The framework's `<ParametricNodeRenderer>`
* mounts an empty group; `<GeometrySystem>` fills it via
* `buildLiquidLineGeometry` on dirty.
*/
export const liquidLineDefinition: NodeDefinition<typeof LiquidLineNode> = {
kind: 'liquid-line',
schemaVersion: 1,
schema: LiquidLineNode,
category: 'utility',
distributionRole: 'run',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
path: [
[0, 0, 0],
[2, 0, 0],
],
diameter: 0.375,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: liquidLineParametrics,
geometry: buildLiquidLineGeometry,
geometryKey: (n) => JSON.stringify([n.path, n.diameter]),
// Open run ends as typed refrigerant ports — directions point outward along
// the path tangent so they mate flush onto a service valve. Path coords are
// already level-local, so no transform is needed.
ports: (n) => {
if (n.path.length < 2) return []
const diameter = n.diameter
const unit = (
a: readonly [number, number, number],
b: readonly [number, number, number],
): [number, number, number] => {
const d: [number, number, number] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
const len = Math.hypot(d[0], d[1], d[2])
return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len]
}
const first = n.path[0]!
const second = n.path[1]!
const last = n.path[n.path.length - 1]!
const prev = n.path[n.path.length - 2]!
return [
{
id: 'start',
position: first,
direction: unit(first, second),
diameter,
system: 'refrigerant',
},
{
id: 'end',
position: last,
direction: unit(last, prev),
diameter,
system: 'refrigerant',
},
]
},
floorplan: buildLiquidLineFloorplan,
// 2D selection-time path-point handles — the floor-plan twin of the 3D
// `affordanceTools.selection` handles.
floorplanAffordances: {
'move-path-point': createPathPointMoveAffordance('liquid-line'),
},
// Selection-time path-point handles (drag to edit a committed run) and the
// ghost-preview duplicate / move tool (drag-to-place a translucent copy).
affordanceTools: {
selection: () => import('./selection'),
move: () => import('./move-tool'),
},
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Start liquid line' },
{ key: 'Click again', label: 'Place it (locked to 45°)' },
{ key: 'Shift', label: 'Free angle' },
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
{ key: 'F', label: 'Follow: trace a lineset' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Liquid Line',
description:
'Standalone refrigerant liquid line — a thin bare-copper run; Follow mode traces an existing lineset.',
icon: { kind: 'url', src: '/icons/lineset.png' },
paletteSection: 'structure',
paletteOrder: 94,
},
mcp: {
description:
'A standalone refrigerant liquid line defined as a polyline of thin bare copper. Snaps onto refrigerant service ports; can be traced alongside an existing lineset.',
},
}
@@ -0,0 +1,77 @@
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import type { LiquidLineNode } from './schema'
const COPPER_LINE = '#b06b3f'
/**
* Floor-plan representation of a liquid line: a single thin copper polyline at
* the line's real width. Vertical risers collapse to a point in plan;
* consecutive duplicate plan points are dropped so they don't render
* zero-length artifacts.
*/
export function buildLiquidLineFloorplan(
node: LiquidLineNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
if (node.path.length < 2) return null
const points: FloorplanPoint[] = []
// Plan point k ← original path index indexMap[k] (risers collapse to one
// plan point), so the path-point drag handle edits the right vertex.
const indexMap: number[] = []
for (let i = 0; i < node.path.length; i++) {
const [x, , z] = node.path[i]!
const prev = points[points.length - 1]
if (prev && Math.abs(prev[0] - x) < 1e-6 && Math.abs(prev[1] - z) < 1e-6) continue
points.push([x, z])
indexMap.push(i)
}
const widthM = node.diameter * INCHES_TO_METERS
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
if (points.length < 2) {
const p = points[0] ?? [node.path[0]![0], node.path[0]![2]]
return {
kind: 'circle',
cx: p[0],
cy: p[1],
r: Math.max(widthM, 0.02),
fill: COPPER_LINE,
stroke: showSelectedChrome && palette ? palette.selectedStroke : COPPER_LINE,
strokeWidth: 0.02,
opacity: 0.9,
}
}
const children: FloorplanGeometry[] = [
{
kind: 'polyline',
points,
stroke: showSelectedChrome && palette ? palette.selectedStroke : COPPER_LINE,
strokeWidth: Math.max(widthM * 2, 0.04),
strokeLinecap: 'round',
strokeLinejoin: 'round',
opacity: showSelectedChrome ? 0.95 : 0.85,
},
]
// Selection chrome: one draggable handle per path vertex (2D twin of the
// 3D selection handles). Routes to the shared `move-path-point` affordance.
if (view?.selected) {
for (let k = 0; k < points.length; k++) {
children.push({
kind: 'endpoint-handle',
point: points[k]!,
state: 'idle',
affordance: 'move-path-point',
payload: { pointIndex: indexMap[k]! },
})
}
}
return { kind: 'group', children }
}
@@ -0,0 +1,66 @@
import { CylinderGeometry, Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import type { LiquidLineNode } from './schema'
const RADIAL_SEGMENTS = 16
const COPPER_COLOR = '#b06b3f'
const UP = new Vector3(0, 1, 0)
/** Cylinder spanning `start`→`end` at `radius`, named for debugging. */
function buildRun(
start: Vector3,
end: Vector3,
radius: number,
material: MeshStandardMaterial,
name: string,
): Mesh | null {
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-6) return null
dir.normalize()
const mesh = new Mesh(
new CylinderGeometry(radius, radius, length, RADIAL_SEGMENTS, 1, false),
material,
)
mesh.name = name
mesh.position.copy(start).addScaledVector(dir, length / 2)
mesh.quaternion.setFromUnitVectors(UP, dir)
return mesh
}
/**
* Pure geometry builder for a standalone liquid line: a single thin bare-copper
* cylinder following the node path centerline, with joint spheres capping
* interior corners so turns read as continuous pipe.
*
* Children are level-local meters; `<ParametricNodeRenderer>` owns the node
* transform (identity today — the path is absolute within the level).
*/
export function buildLiquidLineGeometry(node: LiquidLineNode): Group {
const group = new Group()
if (node.path.length < 2) return group
const radius = (node.diameter * INCHES_TO_METERS) / 2
const copperMat = new MeshStandardMaterial({
color: COPPER_COLOR,
metalness: 0.8,
roughness: 0.3,
})
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
for (let i = 0; i < points.length - 1; i++) {
const run = buildRun(points[i]!, points[i + 1]!, radius, copperMat, `liquid-line-${i}`)
if (run) group.add(run)
}
for (let i = 1; i < points.length - 1; i++) {
const joint = new Mesh(new SphereGeometry(radius, RADIAL_SEGMENTS, 10), copperMat)
joint.name = `liquid-line-joint-${i}`
joint.position.copy(points[i] as Vector3)
group.add(joint)
}
return group
}
+5
View File
@@ -0,0 +1,5 @@
export { type LiquidLineConnectPlan, planLiquidLineConnect } from './connect'
export { liquidLineDefinition } from './definition'
export { buildLiquidLineGeometry } from './geometry'
export { useLiquidLineToolOptions } from './options'
export { LiquidLineNode } from './schema'
@@ -0,0 +1,300 @@
'use client'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
emitter,
type GridEvent,
LiquidLineNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import {
DragBoundingBox,
EDITOR_LAYER,
markToolCancelConsumed,
stripPlacementMetadataFlags,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef, useState } from 'react'
import { Vector3 } from 'three'
import {
type Aabb2D,
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
type Vec3 = [number, number, number]
const GHOST_COLOR = '#818cf8'
const GHOST_OPACITY = 0.5
const IN_TO_M = 0.0254
/** Snap a coordinate to the editor's live grid step. */
function snapToGridStep(value: number): number {
const step = useEditor.getState().gridSnapStep
if (step <= 0) return value
return Math.round(value / step) * step
}
function pathCenterXZ(path: readonly Vec3[]): [number, number] {
let x = 0
let z = 0
for (const p of path) {
x += p[0]
z += p[2]
}
const n = path.length || 1
return [x / n, z / n]
}
/** The liquid line's footprint radius (meters) — half its OD, used as box /
* footprint padding and ghost radius. */
function liquidLineRadiusM(line: LiquidLineNode): number {
return (line.diameter * IN_TO_M) / 2
}
/** XZ bounds of a path padded by the line's radius. */
function pathAabb(path: readonly Vec3[], r: number): Aabb2D {
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (const p of path) {
if (p[0] < minX) minX = p[0]
if (p[0] > maxX) maxX = p[0]
if (p[2] < minZ) minZ = p[2]
if (p[2] > maxZ) maxZ = p[2]
}
return { minX: minX - r, maxX: maxX + r, minZ: minZ - r, maxZ: maxZ + r }
}
/**
* Ghost-preview duplicate / move tool for liquid lines — the path-mover sibling
* of `MoveLinesetTool`. A translucent cylinder at the line's OD per section
* stands in for the run (mirrors the draw tool's `PreviewSegment`).
*
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is inserted
* into the scene until the commit click. A translucent ghost rides the cursor
* inside a footprint bounding box and Figma-style alignment guides snap the
* box's edges to nearby geometry. The next grid click calls `createNode`; Esc
* discards. The run's Y coords ride along untouched: the move only shifts XZ.
*
* **Move** (existing run): the real node's mesh is hidden while the same ghost
* + box tracks the cursor; the commit click writes the translated `path` and
* reveals it, Esc reveals it unchanged.
*
* Wired via `def.affordanceTools.move`.
*/
export const MoveLiquidLineTool: React.FC<{ node: AnyNode }> = ({ node }) => {
const line = node as LiquidLineNode
const originalPathRef = useRef<Vec3[]>(line.path.map((p) => [...p] as Vec3))
const isNew =
typeof node.metadata === 'object' &&
node.metadata !== null &&
!Array.isArray(node.metadata) &&
(node.metadata as Record<string, unknown>).isNew === true
const [previewPath, setPreviewPath] = useState<Vec3[]>(originalPathRef.current)
const previewPathRef = useRef<Vec3[]>(originalPathRef.current)
const hasMovedRef = useRef(false)
const activatedAtRef = useRef<number>(Date.now())
const prevSnapRef = useRef<[number, number] | null>(null)
useEffect(() => {
const nodeId = node.id as AnyNodeId
const originalPath = originalPathRef.current
const [centerX, centerZ] = pathCenterXZ(originalPath)
const r = liquidLineRadiusM(line)
const baseAabb = pathAabb(originalPath, r)
useScene.temporal.getState().pause()
let committed = false
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
useScene.getState().nodes,
nodeId,
useViewer.getState().selection.levelId ?? node.parentId,
)
// Moving an existing run: hide its 3D MESH imperatively (NOT the store
// `visible` flag — the 2D floor plan skips `visible:false` nodes, so a
// store hide makes the run vanish in 2D / split view). The ghost stands
// in until commit; the real mesh is restored on cancel / unmount.
const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId]
const setMeshHidden = (hidden: boolean) => {
const obj = sceneRegistry.nodes.get(nodeId)
if (obj) obj.visible = !hidden
}
if (existedAtStart) setMeshHidden(true)
const setPreview = (path: Vec3[]) => {
previewPathRef.current = path
setPreviewPath(path)
}
const onMove = (event: GridEvent) => {
const bypass = event.nativeEvent?.shiftKey === true
const snap = bypass ? (v: number) => v : snapToGridStep
let dx = snap(event.localPosition[0] - centerX)
let dz = snap(event.localPosition[2] - centerZ)
// Figma-style alignment: snap the run's footprint box edges onto nearby
// geometry and publish the guides (Shift bypass).
if (!bypass) {
const proposed: Aabb2D = {
minX: baseAabb.minX + dx,
maxX: baseAabb.maxX + dx,
minZ: baseAabb.minZ + dz,
maxZ: baseAabb.maxZ + dz,
}
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
dx += sdx
dz += sdz
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
}
const cur: [number, number] = [centerX + dx, centerZ + dz]
if (
!bypass &&
(!prevSnapRef.current ||
prevSnapRef.current[0] !== cur[0] ||
prevSnapRef.current[1] !== cur[1])
) {
triggerSFX('sfx:grid-snap')
}
prevSnapRef.current = cur
hasMovedRef.current = true
setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3))
}
const commit = (event: GridEvent) => {
if (committed) return
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
if (!hasMovedRef.current) {
event.nativeEvent?.stopPropagation?.()
return
}
committed = true
const finalPath = previewPathRef.current
useScene.temporal.getState().resume()
let selectId = nodeId
if (isNew && !useScene.getState().nodes[nodeId]) {
const created = LiquidLineNode.parse({
...(node as Record<string, unknown>),
path: finalPath,
metadata: stripPlacementMetadataFlags(node.metadata),
visible: true,
})
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
useScene.getState().updateNode(nodeId, { path: finalPath } as Partial<AnyNode>)
useScene.getState().markDirty(nodeId)
}
useScene.temporal.getState().pause()
setMeshHidden(false)
useAlignmentGuides.getState().clear()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [selectId] })
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
}
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
markToolCancelConsumed()
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', commit)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
}
}, [line, isNew, node])
const segments: Array<{ a: Vec3; b: Vec3 }> = []
for (let i = 0; i < previewPath.length - 1; i++) {
segments.push({ a: previewPath[i]!, b: previewPath[i + 1]! })
}
// Footprint box spanning the whole run (axis-aligned), drawn around the ghost
// the same way items get one. Recomputed from the live preview path.
const r = liquidLineRadiusM(line)
const box = pathAabb(previewPath, r)
const boxY = previewPath[0]?.[1] ?? 0
return (
<group>
{segments.map((seg, i) => (
<GhostSegment a={seg.a} b={seg.b} radius={r} key={`ghost-${i}`} />
))}
<DragBoundingBox
centerY={0}
nodeId={node.id}
position={[(box.minX + box.maxX) / 2, boxY, (box.minZ + box.maxZ) / 2]}
size={[box.maxX - box.minX, line.diameter * IN_TO_M, box.maxZ - box.minZ]}
/>
</group>
)
}
/** Translucent stand-in for one liquid-line section — mirrors the draw tool's
* `PreviewSegment` so the ghost matches what actually lands. */
function GhostSegment({ a, b, radius }: { a: Vec3; b: Vec3; radius: number }) {
const start = new Vector3(...a)
const end = new Vector3(...b)
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-4) return null
dir.normalize()
const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5)
return (
<mesh
layers={EDITOR_LAYER}
position={mid.toArray()}
ref={(m) => {
if (!m) return
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
}}
>
<cylinderGeometry args={[radius, radius, length, 16, 1, false]} />
<meshBasicMaterial
color={GHOST_COLOR}
depthTest={false}
opacity={GHOST_OPACITY}
transparent
/>
</mesh>
)
}
export default MoveLiquidLineTool
+21
View File
@@ -0,0 +1,21 @@
import { create } from 'zustand'
/**
* Shared draw-time options for the liquid-line tool. Lives in the nodes
* package so both the tool (which reads + key-toggles it) and the app's MEP
* panel (which renders the toggle button) can bind to the same state.
*
* `follow` arms "trace a lineset": while on, clicking an existing lineset
* lays a liquid line beside it along the same path instead of free-drawing.
*/
type LiquidLineToolOptions = {
follow: boolean
setFollow: (value: boolean) => void
toggleFollow: () => void
}
export const useLiquidLineToolOptions = create<LiquidLineToolOptions>((set) => ({
follow: false,
setFollow: (value) => set({ follow: value }),
toggleFollow: () => set((s) => ({ follow: !s.follow })),
}))
@@ -0,0 +1,20 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { LiquidLineNode } from './schema'
export const liquidLineParametrics: ParametricDescriptor<LiquidLineNode> = {
groups: [
{
label: 'Line',
fields: [
{
key: 'diameter',
kind: 'number',
unit: 'in',
min: 0.125,
max: 0.75,
step: 0.125,
},
],
},
],
}
+1
View File
@@ -0,0 +1 @@
export { LiquidLineNode } from '@pascal-app/core'
@@ -0,0 +1,282 @@
'use client'
import {
type AnyNodeId,
type LiquidLineNode,
pauseSceneHistory,
resumeSceneHistory,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useRef, useState } from 'react'
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
const HANDLE_RADIUS = 0.07
const PORT_SNAP_RADIUS_M = 0.4
const UP = new Vector3(0, 1, 0)
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
type Point = [number, number, number]
/**
* Selection-time editing for committed liquid-line runs: one draggable handle
* per path point. Mirrors the lineset path-handle system; dragged run
* endpoints snap onto refrigerant ports only.
*
* Handles are PORTALED into the line's registered scene group so they share
* its exact frame. Drag raycasts run in world space and convert hits back into
* the group's local frame before writing the path.
*/
const LiquidLineSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const line = useScene((s) => {
if (selectedIds.length !== 1) return null
const node = s.nodes[selectedIds[0] as AnyNodeId]
return node?.type === 'liquid-line' ? (node as LiquidLineNode) : null
})
const lineId = line?.id ?? null
const [target, setTarget] = useState<Object3D | null>(null)
useEffect(() => {
if (!lineId) {
setTarget(null)
return
}
let frameId = 0
const resolve = () => {
const next = sceneRegistry.nodes.get(lineId as AnyNodeId) ?? null
setTarget((cur) => (cur === next ? cur : next))
if (!next) frameId = window.requestAnimationFrame(resolve)
}
resolve()
return () => window.cancelAnimationFrame(frameId)
}, [lineId])
if (!line || !target) return null
return createPortal(<LiquidLinePointHandles line={line} target={target} />, target, undefined)
}
const LiquidLinePointHandles = ({ line, target }: { line: LiquidLineNode; target: Object3D }) => {
const { camera, gl } = useThree()
const unit = useViewer((s) => s.unit)
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const dragRef = useRef<{
index: number
initialPath: Point[]
current: Point
cleanup: () => void
} | null>(null)
const makeRay = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const ndc = new Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
const raycaster = new Raycaster()
raycaster.setFromCamera(ndc, camera)
return raycaster.ray
}
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
const hit = new Vector3()
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
}
const projectOntoAxis = (
clientX: number,
clientY: number,
anchorWorld: Vector3,
axisWorld: Vector3,
): number | null => {
const ray = makeRay(clientX, clientY)
const w0 = new Vector3().subVectors(ray.origin, anchorWorld)
const b = ray.direction.dot(axisWorld)
const denom = 1 - b * b
if (Math.abs(denom) < 1e-6) return null
const d0 = ray.direction.dot(w0)
const e0 = axisWorld.dot(w0)
return (e0 - b * d0) / denom
}
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
const toLocal = (world: Vector3): Point => {
const local = target.worldToLocal(world.clone())
return [local.x, local.y, local.z]
}
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const initialPath = line.path.map((p) => [...p] as Point)
const startPoint = initialPath[index]!
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
document.body.style.cursor = 'grabbing'
setDraggingIndex(index)
const isEndpoint = index === 0 || index === initialPath.length - 1
const neighbor = initialPath[index === 0 ? 1 : index - 1]!
const axisLocal = new Vector3(
startPoint[0] - neighbor[0],
startPoint[1] - neighbor[1],
startPoint[2] - neighbor[2],
)
if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0)
axisLocal.normalize()
const anchorWorldStart = toWorld(startPoint)
const axisWorld = toWorld([
startPoint[0] + axisLocal.x,
startPoint[1] + axisLocal.y,
startPoint[2] + axisLocal.z,
])
.sub(anchorWorldStart)
.normalize()
const onMove = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag) return
const current = drag.current
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
let next: Point | null = null
if (event.altKey) {
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current))
const hit = intersect(event.clientX, event.clientY, plane)
if (hit) {
const local = toLocal(hit)
next = [snap(local[0], step), current[1], snap(local[2], step)]
if (isEndpoint) {
const port = findNearestPortXZ(
[local[0], current[1], local[2]],
collectScenePorts({ excludeNodeId: line.id, systems: REFRIGERANT_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (port) next = [port.position[0], port.position[1], port.position[2]]
}
}
} else {
const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld)
if (t !== null) {
const dist = snap(t, step)
next = [
startPoint[0] + axisLocal.x * dist,
Math.max(0, startPoint[1] + axisLocal.y * dist),
startPoint[2] + axisLocal.z * dist,
]
}
}
if (!next) return
if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return
drag.current = next
const path = line.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
useScene.getState().updateNode(line.id, { path })
}
const onUp = () => {
const drag = dragRef.current
if (!drag) return
drag.cleanup()
dragRef.current = null
setDraggingIndex(null)
const finalPath = drag.initialPath.map((p, i) =>
i === drag.index ? drag.current : p,
) as Point[]
useScene.getState().updateNode(line.id, { path: drag.initialPath })
resumeSceneHistory(useScene)
const moved = finalPath[drag.index]!.some(
(v, axis) => v !== drag.initialPath[drag.index]![axis],
)
if (moved) useScene.getState().updateNode(line.id, { path: finalPath })
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
useViewer.getState().setInputDragging(false)
document.body.style.cursor = ''
}
dragRef.current = { index, initialPath, current: startPoint, cleanup }
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
return (
<group>
{line.path.map((p, i) => {
const active = draggingIndex === i
const hovered = hoverIndex === i
return (
<mesh
key={`liquid-line-handle-${i}`}
layers={EDITOR_LAYER}
onPointerDown={onHandleDown(i)}
onPointerEnter={(e) => {
e.stopPropagation()
setHoverIndex(i)
if (draggingIndex === null) document.body.style.cursor = 'grab'
}}
onPointerLeave={() => {
setHoverIndex((prev) => (prev === i ? null : prev))
if (draggingIndex === null) document.body.style.cursor = ''
}}
position={p as Point}
>
<sphereGeometry args={[HANDLE_RADIUS, 16, 12]} />
<meshBasicMaterial
color={active || hovered ? '#a5b4fc' : '#818cf8'}
depthTest={false}
opacity={active ? 1 : 0.85}
transparent
/>
</mesh>
)
})}
{draggingIndex !== null &&
line.path[draggingIndex] &&
(() => {
const point = line.path[draggingIndex]!
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
const axes = ['x', 'y', 'z'] as const
const primary = axes.reduce((best, axis, i) =>
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
)
return (
<Html
center
position={[point[0], point[1] + 0.35, point[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<DimensionPill
parts={axes.map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: deltas[i]!,
signed: true,
}))}
primary={primary}
unit={unit}
/>
</Html>
)
})()}
</group>
)
}
export default LiquidLineSelectionAffordance
+545
View File
@@ -0,0 +1,545 @@
'use client'
import {
type AnyNodeId,
emitter,
type GridEvent,
type LinesetNode,
LiquidLineNode,
useScene,
} from '@pascal-app/core'
import {
CursorSphere,
DimensionPill,
EDITOR_LAYER,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useRef, useState } from 'react'
import { type Group, Vector3 } from 'three'
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import { offsetPathHorizontal } from '../shared/path-offset'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
import { planLiquidLineConnect } from './connect'
import { liquidLineDefinition } from './definition'
import { useLiquidLineToolOptions } from './options'
/**
* One-segment-at-a-time placement tool for standalone liquid lines — the same
* draw model as the lineset tool (the line it used to be a rail of):
* - **First click** anchors the run start; within range of a refrigerant
* service port it snaps onto it so a run mates flush.
* - **Second click** commits a two-point line and re-arms; the in-flight end
* is angle-locked to 45° (Shift frees it), Alt drags it vertical.
*
* **Follow mode** (toggled by the MEP panel's Follow button or the `F` key):
* instead of free-drawing, hover an existing lineset and click — a liquid line
* is laid beside it, tracing the lineset's whole path at a fixed offset on the
* side the cursor is on. This is the "place it exactly next to this" affordance.
*/
const PREVIEW_OPACITY = 0.6
const PREVIEW_COLOR = '#b06b3f'
/** Snap radius (meters) for joining onto a refrigerant port. */
const ENDPOINT_SNAP_RADIUS_M = 0.5
/** Angle step (radians) for the XZ angle lock — 45°. */
const ANGLE_STEP_RAD = Math.PI / 4
/** Mouse pixels → meters mapping for Alt-vertical drag. 100 px ≈ 1 m. */
const ALT_PIXELS_PER_METER = 100
const ALT_Y_MIN_M = -3
const ALT_Y_MAX_M = 10
const IN_TO_M = 0.0254
/** Default liquid OD (~3/8") — the ghost radius and trace-line size. */
const DEFAULT_DIAMETER_IN = 0.375
const GHOST_RADIUS_M = (DEFAULT_DIAMETER_IN * IN_TO_M) / 2
/** Matches the lineset's foam-jacket thickness so the traced line sits just
* outside an insulated suction line, exactly where the old paired rail was. */
const INSULATION_THICKNESS_M = 0.01
/** How close (meters, XZ) the cursor must be to a lineset path to trace it. */
const FOLLOW_PICK_RADIUS_M = 0.6
/** Clear-air gap (meters) between the lineset's outer surface and the traced
* liquid line, so the new run reads as its own line instead of fusing onto
* the lineset (~2"). */
const FOLLOW_GAP_M = 0.05
type Vec3 = [number, number, number]
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
/** Nearest refrigerant port within snap range on the XZ plane, as a position
* tuple. Y is ignored for the distance check; the snap adopts the port's full
* 3D position. */
function findNearbyPort(point: Vec3): Vec3 | null {
const port = findNearestPortXZ(
point,
collectScenePorts({ systems: REFRIGERANT_PORT_SYSTEMS }),
ENDPOINT_SNAP_RADIUS_M,
)
return port ? [port.position[0], port.position[1], port.position[2]] : null
}
function projectToAngleLock(from: Vec3, raw: Vec3): Vec3 {
const dx = raw[0] - from[0]
const dz = raw[2] - from[2]
const len = Math.hypot(dx, dz)
if (len < 1e-4) return [from[0], from[1], from[2]]
const theta = Math.atan2(dz, dx)
const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD
const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped)
const d = Math.max(0, proj)
return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d]
}
/** Distance (XZ) from point `p` to segment `a`→`b`. */
function distToSegmentXZ(p: Vec3, a: Vec3, b: Vec3): number {
const dx = b[0] - a[0]
const dz = b[2] - a[2]
const len2 = dx * dx + dz * dz
let t = len2 > 0 ? ((p[0] - a[0]) * dx + (p[2] - a[2]) * dz) / len2 : 0
t = Math.max(0, Math.min(1, t))
const cx = a[0] + t * dx
const cz = a[2] + t * dz
return Math.hypot(p[0] - cx, p[2] - cz)
}
/** Center-to-center offset (meters) that drops the liquid line a small gap
* outside the lineset's outer surface, so the two read as separate lines. */
function traceOffsetMeters(lineset: LinesetNode): number {
const suctionR = (lineset.suctionDiameter * IN_TO_M) / 2
const jacket = lineset.insulated ? INSULATION_THICKNESS_M : 0
return suctionR + jacket + FOLLOW_GAP_M + GHOST_RADIUS_M
}
type FollowTarget = { lineset: LinesetNode; sign: number }
/**
* Nearest lineset whose path passes within `FOLLOW_PICK_RADIUS_M` of the
* cursor, plus which side of it the cursor is on (`sign`, matching
* `offsetPathHorizontal`'s side convention). Restricted to the active level.
*/
function findFollowTarget(point: Vec3, levelId: AnyNodeId): FollowTarget | null {
const scene = useScene.getState()
let best: FollowTarget | null = null
let bestD = FOLLOW_PICK_RADIUS_M
for (const n of Object.values(scene.nodes)) {
if (!n || n.type !== 'lineset') continue
if ((n.parentId as AnyNodeId | null) !== levelId) continue
const ls = n as LinesetNode
if (ls.path.length < 2) continue
for (let i = 0; i < ls.path.length - 1; i++) {
const a = ls.path[i] as Vec3
const b = ls.path[i + 1] as Vec3
const d = distToSegmentXZ(point, a, b)
if (d >= bestD) continue
bestD = d
// Side vector = normalize(heading_xz) × UP = (-hz, 0, hx); sign is which
// side of the segment the cursor sits on.
const hx = b[0] - a[0]
const hz = b[2] - a[2]
const hlen = Math.hypot(hx, hz)
const sx = hlen > 1e-9 ? -hz / hlen : 0
const sz = hlen > 1e-9 ? hx / hlen : 0
const dot = (point[0] - a[0]) * sx + (point[2] - a[2]) * sz
best = { lineset: ls, sign: dot >= 0 ? 1 : -1 }
}
}
return best
}
/** The offset path a follow-target would trace, or null if degenerate. */
function tracePath(target: FollowTarget): Vec3[] | null {
const offset = target.sign * traceOffsetMeters(target.lineset)
const traced = offsetPathHorizontal(target.lineset.path as Vec3[], offset)
return traced.length >= 2 ? traced : null
}
const LiquidLineTool = () => {
const activeLevelId = useViewer((s) => s.selection.levelId)
const unit = useViewer((s) => s.unit)
const follow = useLiquidLineToolOptions((s) => s.follow)
const cursorRef = useRef<Group>(null)
const [draftPoints, setDraftPoints] = useState<Vec3[]>([])
const [cursorPos, setCursorPos] = useState<Vec3 | null>(null)
const [snapTarget, setSnapTarget] = useState<Vec3 | null>(null)
const [traceGhost, setTraceGhost] = useState<Vec3[] | null>(null)
const [altActive, setAltActive] = useState(false)
const draftRef = useRef(draftPoints)
draftRef.current = draftPoints
const followTargetRef = useRef<FollowTarget | null>(null)
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
const lastClientYRef = useRef<number | null>(null)
// Clear in-flight draft / trace whenever Follow toggles (panel button or F).
// biome-ignore lint/correctness/useExhaustiveDependencies: `follow` is an intentional re-run trigger; the body clears the in-flight draft when it toggles.
useEffect(() => {
setDraftPoints([])
setTraceGhost(null)
followTargetRef.current = null
altAnchorRef.current = null
setAltActive(false)
}, [follow])
// Leaving the tool clears Follow so re-arming it starts in free-draw.
useEffect(() => () => useLiquidLineToolOptions.getState().setFollow(false), [])
useEffect(() => {
if (!activeLevelId) return
const commitSegment = (start: Vec3, end: Vec3) => {
const sameSpot =
Math.abs(start[0] - end[0]) < 1e-4 &&
Math.abs(start[1] - end[1]) < 1e-4 &&
Math.abs(start[2] - end[2]) < 1e-4
if (sameSpot) return
// Fold into any existing run that shares this segment's endpoint, so two
// runs meeting at a coordinate become one mitered path instead of
// overlapping nodes. Only same-level runs are candidates.
const scene = useScene.getState()
const existing = Object.values(scene.nodes).filter(
(n): n is LiquidLineNode =>
n?.type === 'liquid-line' && (n.parentId as AnyNodeId | null) === activeLevelId,
)
const plan = planLiquidLineConnect(existing, start, end)
if (plan.kind === 'create') {
const line = LiquidLineNode.parse({
...liquidLineDefinition.defaults(),
name: 'Liquid Line',
path: plan.path,
})
scene.createNode(line, activeLevelId)
} else if (plan.kind === 'extend') {
scene.updateNode(plan.id, { path: plan.path })
} else {
scene.updateNode(plan.id, { path: plan.path })
scene.deleteNode(plan.deleteId)
}
triggerSFX('sfx:item-place')
setDraftPoints([])
setSnapTarget(null)
altAnchorRef.current = null
setAltActive(false)
}
// Lay a liquid line beside a lineset, tracing its whole path at the offset.
const commitTrace = (target: FollowTarget) => {
const traced = tracePath(target)
if (!traced) return
const scene = useScene.getState()
const line = LiquidLineNode.parse({
...liquidLineDefinition.defaults(),
name: 'Liquid Line',
path: traced,
})
scene.createNode(line, activeLevelId)
triggerSFX('sfx:item-place')
setTraceGhost(null)
followTargetRef.current = null
}
const resolveSnappedPoint = (event: GridEvent): { point: Vec3; snapped: Vec3 | null } => {
const last = draftRef.current.at(-1)
if (!last) {
const raw: Vec3 = [event.localPosition[0], 0, event.localPosition[2]]
if (event.nativeEvent?.altKey !== true) {
const target = findNearbyPort(raw)
if (target) return { point: target, snapped: target }
}
const step = useEditor.getState().gridSnapStep
return { point: [snap(raw[0], step), 0, snap(raw[2], step)], snapped: null }
}
const rawXZ: Vec3 = [event.localPosition[0], last[1], event.localPosition[2]]
const shift = event.nativeEvent?.shiftKey === true
const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ)
if (event.nativeEvent?.altKey !== true && !shift) {
const target = findNearbyPort(rawXZ)
if (target) return { point: target, snapped: target }
}
const step = useEditor.getState().gridSnapStep
return { point: [snap(angled[0], step), angled[1], snap(angled[2], step)], snapped: null }
}
const resolveAltVerticalPoint = (clientY: number): Vec3 | null => {
const anchor = altAnchorRef.current
const last = draftRef.current.at(-1)
if (!anchor || !last) return null
const step = useEditor.getState().gridSnapStep
const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER
const snappedDy = snap(dy, step)
const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy))
return [last[0], y, last[2]]
}
const resolveAlignedPoint = (event: GridEvent) => {
const r = resolveSnappedPoint(event)
const hasStart = draftRef.current.length > 0
const shift = event.nativeEvent?.shiftKey === true
const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, {
applySnap: !hasStart || shift,
bypass: alt || r.snapped !== null,
})
return { ...r, point }
}
const onMove = (event: GridEvent) => {
// Follow mode: track the lineset under the cursor and preview its trace.
if (useLiquidLineToolOptions.getState().follow) {
const raw: Vec3 = [event.localPosition[0], 0, event.localPosition[2]]
clearDrawAlignment()
setCursorPos(raw)
setSnapTarget(null)
const target = findFollowTarget(raw, activeLevelId as AnyNodeId)
followTargetRef.current = target
setTraceGhost(target ? tracePath(target) : null)
return
}
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
if (typeof clientY === 'number') lastClientYRef.current = clientY
if (altAnchorRef.current && typeof clientY === 'number') {
const point = resolveAltVerticalPoint(clientY)
if (point) {
clearDrawAlignment()
setCursorPos(point)
setSnapTarget(null)
return
}
}
const { point, snapped } = resolveAlignedPoint(event)
setCursorPos(point)
setSnapTarget(snapped)
}
const onClick = (event: GridEvent) => {
// Follow mode: a click commits the trace beside the hovered lineset.
if (useLiquidLineToolOptions.getState().follow) {
const target = followTargetRef.current
if (target) commitTrace(target)
return
}
const start = draftRef.current.at(-1)
if (altAnchorRef.current && start) {
const clientY =
(event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current
if (typeof clientY === 'number') {
const point = resolveAltVerticalPoint(clientY)
if (point && Math.abs(point[1] - start[1]) >= 1e-4) {
commitSegment(start, point)
}
}
return
}
const { point } = resolveAlignedPoint(event)
if (!start) {
triggerSFX('sfx:grid-snap')
setDraftPoints([point])
return
}
commitSegment(start, point)
}
const enterAltMode = () => {
if (useLiquidLineToolOptions.getState().follow) return
const last = draftRef.current.at(-1)
if (!last || lastClientYRef.current === null) return
if (altAnchorRef.current) return
altAnchorRef.current = { clientY: lastClientYRef.current, baseY: last[1] }
setAltActive(true)
}
const exitAltMode = () => {
if (!altAnchorRef.current) return
altAnchorRef.current = null
setAltActive(false)
}
const onKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (e.key === 'f' || e.key === 'F') {
e.preventDefault()
useLiquidLineToolOptions.getState().toggleFollow()
return
}
if (e.key === 'Alt') {
e.preventDefault()
enterAltMode()
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Alt') {
e.preventDefault()
exitAltMode()
}
}
const onCancel = () => {
clearDrawAlignment()
if (draftRef.current.length === 0 && !followTargetRef.current) return
markToolCancelConsumed()
setDraftPoints([])
setCursorPos(null)
setSnapTarget(null)
setTraceGhost(null)
followTargetRef.current = null
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', onClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', onClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
altAnchorRef.current = null
clearDrawAlignment()
}
}, [activeLevelId])
if (!activeLevelId) return null
const previewSegments: Array<{ a: Vec3; b: Vec3 }> = []
for (let i = 0; i < draftPoints.length - 1; i++) {
previewSegments.push({ a: draftPoints[i]!, b: draftPoints[i + 1]! })
}
const last = draftPoints.at(-1)
if (last && cursorPos) {
previewSegments.push({ a: last, b: cursorPos })
}
const traceSegments: Array<{ a: Vec3; b: Vec3 }> = []
if (traceGhost) {
for (let i = 0; i < traceGhost.length - 1; i++) {
traceSegments.push({ a: traceGhost[i]!, b: traceGhost[i + 1]! })
}
}
const pillParts = cursorPos
? (['x', 'y', 'z'] as const).map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: last ? cursorPos[i]! - last[i]! : cursorPos[i]!,
signed: !!last,
}))
: null
const pillPrimary =
last && cursorPos
? altActive
? 'y'
: Math.abs(cursorPos[0] - last[0]) >= Math.abs(cursorPos[2] - last[2])
? 'x'
: 'z'
: undefined
return (
<LevelOffsetGroup>
{cursorPos && (
<>
<CursorSphere color={PREVIEW_COLOR} position={cursorPos} ref={cursorRef} />
{follow ? (
<group position={cursorPos}>
<Html
center
position={[0, 0.45, 0]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<div
style={{
background: 'rgba(17,17,20,0.85)',
border: '1px solid rgba(176,107,63,0.6)',
borderRadius: 6,
color: '#f3e7dd',
fontSize: 11,
padding: '3px 7px',
whiteSpace: 'nowrap',
}}
>
{followTargetRef.current
? 'Click to trace this lineset'
: 'Follow: hover a lineset'}
</div>
</Html>
</group>
) : (
pillParts && (
<group position={cursorPos}>
<Html
center
position={[0, 0.35, 0]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
</Html>
</group>
)
)}
</>
)}
{snapTarget && (
<mesh layers={EDITOR_LAYER} position={snapTarget}>
<sphereGeometry args={[0.1, 24, 16]} />
<meshBasicMaterial color={PREVIEW_COLOR} depthTest={false} opacity={0.35} transparent />
</mesh>
)}
{draftPoints.map((p, i) => (
<mesh key={`pt-${i}`} layers={EDITOR_LAYER} position={p}>
<sphereGeometry args={[0.05, 16, 12]} />
<meshBasicMaterial color={PREVIEW_COLOR} depthTest={false} />
</mesh>
))}
{previewSegments.map((seg, i) => (
<PreviewSegment a={seg.a} b={seg.b} key={`seg-${i}`} />
))}
{traceSegments.map((seg, i) => (
<PreviewSegment a={seg.a} b={seg.b} key={`trace-${i}`} />
))}
</LevelOffsetGroup>
)
}
function PreviewSegment({ a, b }: { a: Vec3; b: Vec3 }) {
const start = new Vector3(...a)
const end = new Vector3(...b)
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-4) return null
dir.normalize()
const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5)
return (
<mesh
layers={EDITOR_LAYER}
position={mid.toArray()}
ref={(m) => {
if (!m) return
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
}}
>
<cylinderGeometry args={[GHOST_RADIUS_M, GHOST_RADIUS_M, length, 16, 1, false]} />
<meshBasicMaterial
color={PREVIEW_COLOR}
depthTest={false}
opacity={PREVIEW_OPACITY}
transparent
/>
</mesh>
)
}
export default LiquidLineTool
@@ -0,0 +1,106 @@
import type { NodeDefinition } from '@pascal-app/core'
import { useScene } from '@pascal-app/core'
import { getRotationAxis, rotateEulerWorld } from '../shared/fitting-rotation'
import { buildPipeFittingFloorplan } from './floorplan'
import { buildPipeFittingGeometry } from './geometry'
import { pipeFittingParametrics } from './parametrics'
import { getPipeFittingPorts } from './ports'
import { PipeFittingNode } from './schema'
/**
* DWV fittings — minted automatically by the pipe draw tool (corner
* joints → elbows, body taps → wyes on horizontal drains / sanitary
* tees on stacks), or click-placed via the tool (armed from the Build
* tab's DWV Pipe panel). Editable after the fact via the inspector.
*/
export const pipeFittingDefinition: NodeDefinition<typeof PipeFittingNode> = {
kind: 'pipe-fitting',
schemaVersion: 1,
schema: PipeFittingNode,
category: 'utility',
distributionRole: 'fitting',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
fittingType: 'elbow',
angle: 90,
diameter: 2,
diameter2: 2,
pipeMaterial: 'pvc',
system: 'waste',
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
duplicable: true,
deletable: true,
},
parametrics: pipeFittingParametrics,
geometry: buildPipeFittingGeometry,
geometryKey: (n) =>
JSON.stringify([n.fittingType, n.angle, n.diameter, n.diameter2, n.pipeMaterial, n.system]),
ports: getPipeFittingPorts,
floorplan: buildPipeFittingFloorplan,
// R/T rotate a selected fitting ±45° around the shared active axis —
// same scheme as duct fittings (the default editor rotate only knows
// Y; DWV stacks need X/Z). Alt-cycling lives in `./selection.tsx`.
keyboardActions: {
r: {
appliesTo: (node) => node.type === 'pipe-fitting',
run: (node) =>
useScene.getState().updateNode(node.id, {
rotation: rotateEulerWorld((node as PipeFittingNode).rotation, getRotationAxis(), 1),
}),
},
t: {
appliesTo: (node) => node.type === 'pipe-fitting',
run: (node) =>
useScene.getState().updateNode(node.id, {
rotation: rotateEulerWorld((node as PipeFittingNode).rotation, getRotationAxis(), -1),
}),
},
axisCycling: true,
},
// Alt-cycles the active rotation axis while a fitting is selected.
// Editor-only (drives `useEditor.rotationAxis`), so it mounts via the
// editor's SelectionAffordanceManager rather than `def.system`.
affordanceTools: {
selection: () => import('./selection'),
},
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Place fitting' },
{ key: 'Hover a pipe end', label: 'Snap onto the run' },
{ key: 'R / T', label: 'Rotate ±45°' },
{ key: 'Alt', label: 'Switch rotation axis (Y → X → Z)' },
{ key: 'Esc', label: 'Exit' },
],
presentation: {
label: 'Pipe Fitting',
description: 'DWV joint — elbow bend, 45° wye, or sanitary tee.',
// Reuses the duct-fitting artwork — DWV fittings read the same in the UI.
icon: { kind: 'url', src: '/icons/duct-fitting.png' },
paletteSection: 'structure',
paletteOrder: 96,
hidden: true,
},
mcp: {
description:
'A DWV pipe fitting (elbow, wye, or sanitary tee) with typed ports. Minted automatically at drain joints; position is level-local meters, rotation an XYZ euler.',
},
}
@@ -0,0 +1,58 @@
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import { getPipeFittingPorts } from './ports'
import type { PipeFittingNode } from './schema'
const WASTE_COLOR = '#57534e'
const VENT_COLOR = '#78716c'
/**
* Floor-plan symbol for a DWV fitting: one line per collar from the
* junction out (a wye's 45° branch reads at its true plan angle), plus
* a hub circle. Vertical collars (stack connections) collapse onto the
* hub, which is how they should read from above.
*/
export function buildPipeFittingFloorplan(
node: PipeFittingNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const [cx, , cz] = node.position
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
const stroke =
showSelectedChrome && palette
? palette.selectedStroke
: node.system === 'vent'
? VENT_COLOR
: WASTE_COLOR
const children: FloorplanGeometry[] = []
for (const port of getPipeFittingPorts(node)) {
const px = port.position[0]
const pz = port.position[2]
if (Math.hypot(px - cx, pz - cz) < 1e-4) continue
children.push({
kind: 'line',
x1: cx,
y1: cz,
x2: px,
y2: pz,
stroke,
strokeWidth: port.diameter * INCHES_TO_METERS,
strokeLinecap: 'round',
opacity: showSelectedChrome ? 0.95 : 0.85,
})
}
children.push({
kind: 'circle',
cx,
cy: cz,
r: (node.diameter * INCHES_TO_METERS) / 2 + 0.012,
fill: stroke,
opacity: 0.95,
})
if (showSelectedChrome) children.push({ kind: 'move-handle', point: [cx, cz] })
return { kind: 'group', children }
}
@@ -0,0 +1,42 @@
import { Group, Mesh, SphereGeometry, Vector3 } from 'three'
import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry'
import { createPipeMaterial } from '../pipe-segment/geometry'
import { localPipeFittingPorts } from './ports'
import type { PipeFittingNode } from './schema'
const RADIAL_SEGMENTS = 20
/**
* Pure geometry builder for a DWV fitting, in the node's LOCAL frame.
* One cylinder stub per port from the junction outward, an oversized
* hub sphere at the junction, and a smaller hub at each collar opening
* (solvent-weld couplings). Wyes read correctly because their branch
* stub leaves at 45° — the port layout does the work.
*/
export function buildPipeFittingGeometry(node: PipeFittingNode): Group {
const group = new Group()
const material = createPipeMaterial(node)
const radiusRun = (node.diameter * INCHES_TO_METERS) / 2
for (const port of localPipeFittingPorts(node)) {
const radius = (port.diameter * INCHES_TO_METERS) / 2
const stub = buildSection(
new Vector3(0, 0, 0),
port.position,
radius,
material,
`pipe-fitting-stub-${port.id}`,
)
if (stub) group.add(stub)
const hub = new Mesh(new SphereGeometry(radius * 1.18, RADIAL_SEGMENTS, 12), material)
hub.name = `pipe-fitting-hub-${port.id}`
hub.position.copy(port.position)
group.add(hub)
}
const junction = new Mesh(new SphereGeometry(radiusRun * 1.18, RADIAL_SEGMENTS, 12), material)
junction.name = 'pipe-fitting-junction'
group.add(junction)
return group
}
+4
View File
@@ -0,0 +1,4 @@
export { pipeFittingDefinition } from './definition'
export { buildPipeFittingGeometry } from './geometry'
export { getPipeFittingPorts } from './ports'
export { PipeFittingNode } from './schema'
@@ -0,0 +1,56 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { PipeFittingNode } from './schema'
export const pipeFittingParametrics: ParametricDescriptor<PipeFittingNode> = {
groups: [
{
label: 'Fitting',
fields: [
{
key: 'fittingType',
kind: 'enum',
options: ['elbow', 'wye', 'sanitary-tee', 'cross'],
display: 'segmented',
},
{
key: 'angle',
kind: 'number',
unit: '°',
min: 15,
max: 90,
step: 7.5,
visibleIf: (n) => n.fittingType === 'elbow',
},
{
key: 'system',
kind: 'enum',
options: ['waste', 'vent'],
display: 'segmented',
},
],
},
{
label: 'Connections',
fields: [
{ key: 'diameter', kind: 'number', unit: 'in', min: 1.25, max: 6, step: 0.25 },
{
key: 'diameter2',
kind: 'number',
unit: 'in',
min: 1.25,
max: 6,
step: 0.25,
visibleIf: (n) => n.fittingType !== 'elbow',
},
{ key: 'pipeMaterial', kind: 'enum', options: ['pvc', 'abs', 'cast-iron'] },
],
},
{
label: 'Placement',
fields: [
{ key: 'position', kind: 'vec3' },
{ key: 'rotation', kind: 'vec3' },
],
},
],
}
+102
View File
@@ -0,0 +1,102 @@
import type { NodePort } from '@pascal-app/core'
import { Euler, Vector3 } from 'three'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import type { PipeFittingNode } from './schema'
/** Hub stub length in meters — pipe fittings are stubbier than duct
* fittings (a 2" wye hub is ~7 cm to the collar). */
export function pipeFittingLegLength(diameterInches: number): number {
const radius = (diameterInches * INCHES_TO_METERS) / 2
return Math.max(0.07, radius * 2.2)
}
/** Wye branch angle — DWV wyes enter at 45°. */
export const WYE_BRANCH_RAD = Math.PI / 4
type LocalPort = { id: string; position: Vector3; direction: Vector3; diameter: number }
/**
* Ports in the fitting's LOCAL frame (origin at the junction, before
* `position`/`rotation`). Conventions documented on the schema: elbow
* inlet -X / outlet at `angle`° in XZ; wye run along X with the branch
* at 45° between +X and +Z; sanitary tee run along X, branch +Z; cross
* run along X, two opposed branches on ±Z.
*/
export function localPipeFittingPorts(node: PipeFittingNode): LocalPort[] {
const run = pipeFittingLegLength(node.diameter)
const inlet: LocalPort = {
id: 'inlet',
position: new Vector3(-run, 0, 0),
direction: new Vector3(-1, 0, 0),
diameter: node.diameter,
}
if (node.fittingType === 'elbow') {
const theta = (node.angle * Math.PI) / 180
const outDir = new Vector3(Math.cos(theta), 0, Math.sin(theta))
return [
inlet,
{
id: 'outlet',
position: outDir.clone().multiplyScalar(run),
direction: outDir,
diameter: node.diameter,
},
]
}
const outlet: LocalPort = {
id: 'outlet',
position: new Vector3(run, 0, 0),
direction: new Vector3(1, 0, 0),
diameter: node.diameter,
}
const branchLeg = pipeFittingLegLength(node.diameter2)
if (node.fittingType === 'cross') {
return [
inlet,
outlet,
{
id: 'branch',
position: new Vector3(0, 0, branchLeg),
direction: new Vector3(0, 0, 1),
diameter: node.diameter2,
},
{
id: 'branch2',
position: new Vector3(0, 0, -branchLeg),
direction: new Vector3(0, 0, -1),
diameter: node.diameter2,
},
]
}
const branchDir =
node.fittingType === 'wye'
? new Vector3(Math.cos(WYE_BRANCH_RAD), 0, Math.sin(WYE_BRANCH_RAD))
: new Vector3(0, 0, 1)
return [
inlet,
outlet,
{
id: 'branch',
position: branchDir.clone().multiplyScalar(branchLeg),
direction: branchDir,
diameter: node.diameter2,
},
]
}
/** `def.ports` — local ports transformed into level-local space. */
export function getPipeFittingPorts(node: PipeFittingNode): NodePort[] {
const euler = new Euler(node.rotation[0], node.rotation[1], node.rotation[2])
const offset = new Vector3(node.position[0], node.position[1], node.position[2])
return localPipeFittingPorts(node).map((port) => {
const position = port.position.clone().applyEuler(euler).add(offset)
const direction = port.direction.clone().applyEuler(euler).normalize()
return {
id: port.id,
position: [position.x, position.y, position.z] as const,
direction: [direction.x, direction.y, direction.z] as const,
diameter: port.diameter,
system: node.system,
}
})
}
@@ -0,0 +1 @@
export { PipeFittingNode } from '@pascal-app/core'
@@ -0,0 +1,43 @@
'use client'
import { type AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { cycleRotationAxis } from '../shared/fitting-rotation'
/**
* Selection-time rotation support for placed pipe fittings — mirrors
* the duct-fitting affordance, mounted by the editor's
* SelectionAffordanceManager (`def.affordanceTools.selection`). R/T
* rotation lives in `def.keyboardActions`; this contributes the piece
* that hook can't: **Alt cycles the active rotation axis** while a
* single fitting is selected. The axis lives on `useEditor.rotationAxis`,
* which the floating action menu reads to show the axis pill — so this
* component renders nothing.
*/
const PipeFittingSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const hasSelectedFitting = useScene((s) => {
if (selectedIds.length !== 1) return false
return s.nodes[selectedIds[0] as AnyNodeId]?.type === 'pipe-fitting'
})
useEffect(() => {
if (!hasSelectedFitting) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Alt' || e.repeat) return
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
e.preventDefault()
cycleRotationAxis()
}
// Bubble phase — when the placement tool is active its capture-phase
// handler stops propagation, so the two never double-cycle.
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [hasSelectedFitting])
return null
}
export default PipeFittingSelectionAffordance
+255
View File
@@ -0,0 +1,255 @@
'use client'
import { emitter, type GridEvent, PipeFittingNode, useScene } from '@pascal-app/core'
import { CursorSphere, EDITOR_LAYER, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Euler, Quaternion, Vector3 } from 'three'
import {
AXIS_VECTORS,
cycleRotationAxis,
getRotationAxis,
ROTATE_STEP_RAD,
} from '../shared/fitting-rotation'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import {
collectScenePorts,
DWV_PORT_SYSTEMS,
findNearestPortXZ,
type ScenePort,
} from '../shared/ports'
import { pipeFittingDefinition } from './definition'
import { buildPipeFittingGeometry } from './geometry'
import { localPipeFittingPorts } from './ports'
/** Snap radius (meters, XZ) for mating onto an existing DWV port. */
const PORT_SNAP_RADIUS_M = 0.5
const PREVIEW_OPACITY = 0.55
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
type Placement = {
position: [number, number, number]
rotation: [number, number, number]
snapPort: ScenePort | null
}
/**
* Resolve where the fitting would land for a cursor at `raw`:
* - Near an existing DWV port → mate: orientation aligns the inlet
* onto the port (plus the user's manual R/T rotation, pivoting
* around the inlet collar so it stays on the port while the body
* sweeps).
* - Otherwise → grid-snapped free placement on the floor, manual
* rotation only.
*/
function resolvePlacement(
raw: [number, number, number],
previewNode: PipeFittingNode,
gridStep: number,
manualQuat: Quaternion,
): Placement {
const port = findNearestPortXZ(
raw,
collectScenePorts({ systems: DWV_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (port) {
const direction = new Vector3(...port.direction).normalize()
// Local +X must map onto the port's outward direction so the inlet
// (local -X) faces back into the run it's joining. Manual rotation
// composes in the world frame on top of the mate orientation.
const mate = new Quaternion().setFromUnitVectors(new Vector3(1, 0, 0), direction)
const final = manualQuat.clone().multiply(mate)
const inlet = localPipeFittingPorts(previewNode)[0]!
const inletWorldOffset = inlet.position.clone().applyQuaternion(final)
const position = new Vector3(...port.position).sub(inletWorldOffset)
const euler = new Euler().setFromQuaternion(final)
return {
position: [position.x, position.y, position.z],
rotation: [euler.x, euler.y, euler.z],
snapPort: port,
}
}
const euler = new Euler().setFromQuaternion(manualQuat)
return {
position: [snap(raw[0], gridStep), 0, snap(raw[2], gridStep)],
rotation: [euler.x, euler.y, euler.z],
snapPort: null,
}
}
/**
* Click-place tool for DWV pipe fittings (elbow / wye / sanitary tee) —
* the plumbing sibling of the duct-fitting tool.
*
* A translucent ghost of the fitting follows the cursor. Within snap
* range of any DWV port (pipe run ends, other fittings' collars) the
* ghost jumps onto the port — position AND orientation — so one click
* mates the fitting onto the run.
*
* Rotation while placing: **R / T** turn the ghost ±45° around the
* active world axis; **Alt** cycles the axis (Y → X → Z). The HUD badge
* above the ghost shows the current axis. When snapped to a port the
* rotation pivots around the inlet collar so the joint stays mated.
* Handlers run in the capture phase so R doesn't also spin whatever
* node happens to be selected.
*/
const PipeFittingTool = () => {
const activeLevelId = useViewer((s) => s.selection.levelId)
const [placement, setPlacement] = useState<Placement | null>(null)
const axis = useEditor((s) => s.rotationAxis)
// Accumulated manual rotation from R/T presses. Ref (not state) so the
// emitter callbacks always read the latest without re-subscribing; a
// placement recompute is triggered explicitly after each change.
const manualQuatRef = useRef(new Quaternion())
// Last raw cursor position so a key press can recompute the placement
// without waiting for the next mouse move.
const lastRawRef = useRef<[number, number, number] | null>(null)
// Ghost matches exactly what a click creates (the kind's defaults).
const previewNode = useMemo(
() => PipeFittingNode.parse({ ...pipeFittingDefinition.defaults(), name: 'Pipe fitting' }),
[],
)
const ghost = useMemo(() => {
const group = buildPipeFittingGeometry(previewNode)
group.traverse((child) => {
// Overlay layer keeps the placement ghost out of the ink / SSGI
// buffers and the thumbnail export, like every other tool preview.
child.layers.set(EDITOR_LAYER)
const mesh = child as { material?: { transparent: boolean; opacity: number } }
if (mesh.material) {
mesh.material.transparent = true
mesh.material.opacity = PREVIEW_OPACITY
}
})
return group
}, [previewNode])
useEffect(() => {
if (!activeLevelId) return
const recompute = () => {
const raw = lastRawRef.current
if (!raw) return
setPlacement(
resolvePlacement(
raw,
previewNode,
useEditor.getState().gridSnapStep,
manualQuatRef.current,
),
)
}
const onMove = (event: GridEvent) => {
lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]]
recompute()
}
const onClick = (event: GridEvent) => {
lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]]
const { position, rotation } = resolvePlacement(
lastRawRef.current,
previewNode,
useEditor.getState().gridSnapStep,
manualQuatRef.current,
)
const fitting = PipeFittingNode.parse({
...pipeFittingDefinition.defaults(),
name: 'Pipe fitting',
position,
rotation,
})
useScene.getState().createNode(fitting, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [fitting.id] })
triggerSFX('sfx:item-place')
}
const onKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
const key = e.key
if (key === 'r' || key === 'R' || key === 't' || key === 'T') {
// Capture-phase + stopPropagation so the editor's selection-rotate
// R handler doesn't also fire while the placement tool owns R.
e.preventDefault()
e.stopPropagation()
const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1
const turn = new Quaternion().setFromAxisAngle(
AXIS_VECTORS[getRotationAxis()],
steps * ROTATE_STEP_RAD,
)
manualQuatRef.current = turn.multiply(manualQuatRef.current)
triggerSFX('sfx:item-rotate')
recompute()
} else if (key === 'Alt' && !e.repeat) {
e.preventDefault()
e.stopPropagation()
cycleRotationAxis()
}
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', onClick)
window.addEventListener('keydown', onKeyDown, true)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', onClick)
window.removeEventListener('keydown', onKeyDown, true)
}
}, [activeLevelId, previewNode])
if (!activeLevelId || !placement) return null
return (
<LevelOffsetGroup>
{/* Same ground ring + vertical line + tool-icon badge the duct draw
tool shows in 3D (icon resolved from the active `pipe-fitting`
structure-tools entry). In 2D the floorplan overlay draws this for
every tool; in 3D each tool renders its own. */}
<CursorSphere position={placement.position} />
<group position={placement.position} rotation={placement.rotation}>
<primitive object={ghost} />
</group>
{/* Rotation HUD — active axis + key hints, pinned above the ghost. */}
<Html
center
position={[placement.position[0], placement.position[1] + 0.5, placement.position[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
{/* Same pill shell as DimensionPill so the placement HUD matches
the drawing / dragging readouts. */}
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
<span className="font-medium text-foreground">Axis {axis.toUpperCase()}</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground">R/T rotate</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground"> axis</span>
</div>
</Html>
{/* Port-snap halo so the user sees the click will mate, not free-place. */}
{placement.snapPort && (
<mesh
layers={EDITOR_LAYER}
position={placement.snapPort.position as [number, number, number]}
>
<sphereGeometry args={[0.18, 24, 16]} />
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
</mesh>
)}
</LevelOffsetGroup>
)
}
export default PipeFittingTool
@@ -0,0 +1,130 @@
import type { NodeDefinition } from '@pascal-app/core'
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
import { buildPipeSegmentFloorplan } from './floorplan'
import { buildPipeSegmentGeometry } from './geometry'
import { pipeSegmentParametrics } from './parametrics'
import { PipeSegmentNode } from './schema'
/**
* Phase 4 of the distribution-system effort (the research doc's Phase 2)
* — DWV plumbing's first kind: the pipe run. The plumbing sibling of
* `duct-segment`: same polyline + typed-ports model, with SLOPE as the
* new ingredient (the draw tool drops waste runs ¼"/ft; vents run level
* or vertical).
*
* Deferred to later slices: DWV fittings (wye / sanitary tee / closet
* bend), fixtures, traps, cleanouts, IPC validators, riser view.
*/
export const pipeSegmentDefinition: NodeDefinition<typeof PipeSegmentNode> = {
kind: 'pipe-segment',
schemaVersion: 1,
schema: PipeSegmentNode,
category: 'utility',
distributionRole: 'run',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
path: [
[0, 0, 0],
[3, -0.0625, 0],
],
diameter: 2,
pipeMaterial: 'pvc',
system: 'waste',
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: pipeSegmentParametrics,
geometry: buildPipeSegmentGeometry,
geometryKey: (n) => JSON.stringify([n.path, n.diameter, n.pipeMaterial, n.system]),
// Open run ends as typed ports — system 'waste'/'vent' keeps the DWV
// network invisible to duct / refrigerant tools and vice versa.
ports: (n) => {
if (n.path.length < 2) return []
const unit = (
a: readonly [number, number, number],
b: readonly [number, number, number],
): [number, number, number] => {
const d: [number, number, number] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
const len = Math.hypot(d[0], d[1], d[2])
return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len]
}
const first = n.path[0]!
const second = n.path[1]!
const last = n.path[n.path.length - 1]!
const prev = n.path[n.path.length - 2]!
return [
{
id: 'start',
position: first,
direction: unit(first, second),
diameter: n.diameter,
system: n.system,
},
{
id: 'end',
position: last,
direction: unit(last, prev),
diameter: n.diameter,
system: n.system,
},
]
},
floorplan: buildPipeSegmentFloorplan,
// 2D selection-time path-point handles — the floor-plan twin of the 3D
// `affordanceTools.selection` handles. The builder emits an
// `endpoint-handle` per path vertex; this drags the matching point.
floorplanAffordances: {
'move-path-point': createPathPointMoveAffordance('pipe-segment'),
},
// Selection-time path-point handles (drag to edit a committed run).
// Editor-only UI (reads gridSnapStep, renders DimensionPill), so it
// mounts via the editor's SelectionAffordanceManager — not `def.system`,
// which the viewer package mounts for the read-only route.
affordanceTools: {
selection: () => import('./selection'),
// Ghost-preview duplicate / move (the plumbing sibling of duct-segment's
// mover). Duplicate is pure drag-to-place: a translucent copy of the run,
// wrapped in a footprint bounding box, follows the cursor and only lands
// on the commit click — nothing is inserted into the scene before that.
move: () => import('./move-tool'),
},
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Start run' },
{ key: 'Click again', label: 'Place it (waste falls ¼″/ft)' },
{ key: 'Q', label: 'Waste / vent' },
{ key: '[ / ]', label: 'Pipe size down / up' },
{ key: 'Alt + drag', label: 'Vertical stack ↕, click to place' },
{ key: 'Shift', label: 'Free angle' },
{ key: 'Esc', label: 'Cancel start point' },
],
presentation: {
label: 'DWV Pipe',
description:
'Drain / waste / vent pipe run — waste lines fall at ¼″ per foot, vents run level or vertical.',
icon: { kind: 'url', src: '/icons/dwv-pipes.png' },
paletteSection: 'structure',
paletteOrder: 95,
},
mcp: {
description:
'A DWV (drain-waste-vent) pipe run defined as a polyline. Waste runs slope downward (slope lives in the path Y coordinates); vents run level or vertical. Sized in nominal inches.',
},
}
@@ -0,0 +1,99 @@
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import type { PipeSegmentNode } from './schema'
const WASTE_COLOR = '#57534e'
const VENT_COLOR = '#78716c'
/**
* Floor-plan representation of a DWV run, following drafting convention:
* waste lines draw SOLID at the pipe's width, vent lines draw DASHED and
* thin. Vertical stacks collapse to a circle.
*/
export function buildPipeSegmentFloorplan(
node: PipeSegmentNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
if (node.path.length < 2) return null
const points: FloorplanPoint[] = []
// Plan point k ← original path index indexMap[k] (stacks collapse to one
// plan point), so the path-point drag handle edits the right vertex.
const indexMap: number[] = []
for (let i = 0; i < node.path.length; i++) {
const [x, , z] = node.path[i]!
const prev = points[points.length - 1]
if (prev && Math.abs(prev[0] - x) < 1e-6 && Math.abs(prev[1] - z) < 1e-6) continue
points.push([x, z])
indexMap.push(i)
}
const diameterM = node.diameter * INCHES_TO_METERS
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
const isVent = node.system === 'vent'
const stroke =
showSelectedChrome && palette ? palette.selectedStroke : isVent ? VENT_COLOR : WASTE_COLOR
// Vertical stack — a single plan point: hub circle.
if (points.length < 2) {
const p = points[0] ?? [node.path[0]![0], node.path[0]![2]]
return {
kind: 'group',
children: [
{
kind: 'circle',
cx: p[0],
cy: p[1],
r: diameterM / 2 + 0.01,
fill: 'none',
stroke,
strokeWidth: 2,
vectorEffect: 'non-scaling-stroke',
opacity: 0.95,
},
],
}
}
const children: FloorplanGeometry[] = [
isVent
? {
kind: 'polyline',
points,
stroke,
strokeWidth: 1.5,
vectorEffect: 'non-scaling-stroke',
strokeDasharray: '6 4',
strokeLinecap: 'round',
strokeLinejoin: 'round',
opacity: 0.9,
}
: {
kind: 'polyline',
points,
stroke,
strokeWidth: diameterM,
strokeLinecap: 'round',
strokeLinejoin: 'round',
opacity: showSelectedChrome ? 0.95 : 0.85,
},
]
// Selection chrome: one draggable handle per path vertex (2D twin of the
// 3D selection handles). Routes to the shared `move-path-point` affordance.
if (view?.selected) {
for (let k = 0; k < points.length; k++) {
children.push({
kind: 'endpoint-handle',
point: points[k]!,
state: 'idle',
affordance: 'move-path-point',
payload: { pointIndex: indexMap[k]! },
})
}
}
return { kind: 'group', children }
}
@@ -0,0 +1,64 @@
import { Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three'
import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry'
import type { PipeSegmentNode } from './schema'
const PVC_COLOR = '#f5f5f5'
const ABS_COLOR = '#3a3a3a'
const CAST_IRON_COLOR = '#54575c'
/** Vents read slightly translucent-matte so they don't visually compete
* with the water-carrying waste runs. */
const VENT_OPACITY = 0.85
const RADIAL_SEGMENTS = 20
type PipeAppearance = {
pipeMaterial: 'pvc' | 'abs' | 'cast-iron'
system: 'waste' | 'vent'
}
function getPipeColor(node: PipeAppearance): string {
if (node.pipeMaterial === 'abs') return ABS_COLOR
if (node.pipeMaterial === 'cast-iron') return CAST_IRON_COLOR
return PVC_COLOR
}
export function createPipeMaterial(node: PipeAppearance): MeshStandardMaterial {
return new MeshStandardMaterial({
color: getPipeColor(node),
metalness: node.pipeMaterial === 'cast-iron' ? 0.5 : 0.05,
roughness: node.pipeMaterial === 'cast-iron' ? 0.6 : 0.45,
transparent: node.system === 'vent',
opacity: node.system === 'vent' ? VENT_OPACITY : 1,
})
}
/**
* Pure geometry builder for a DWV pipe run: capped cylinder sections
* between consecutive path points with sphere hubs at interior joints
* (proper wyes / sanitary tees come in the next slice). Slope lives in
* the path's Y coordinates — nothing here is slope-aware.
*/
export function buildPipeSegmentGeometry(node: PipeSegmentNode): Group {
const group = new Group()
if (node.path.length < 2) return group
const radius = (node.diameter * INCHES_TO_METERS) / 2
const material = createPipeMaterial(node)
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
for (let i = 0; i < points.length - 1; i++) {
const a = points[i] as Vector3
const b = points[i + 1] as Vector3
const mesh = buildSection(a, b, radius, material, `pipe-section-${i}`)
if (mesh) group.add(mesh)
}
// Slightly proud hubs at interior joints — reads as a coupling.
for (let i = 1; i < points.length - 1; i++) {
const hub = new Mesh(new SphereGeometry(radius * 1.12, RADIAL_SEGMENTS, 12), material)
hub.name = `pipe-hub-${i}`
hub.position.copy(points[i] as Vector3)
group.add(hub)
}
return group
}
+3
View File
@@ -0,0 +1,3 @@
export { pipeSegmentDefinition } from './definition'
export { buildPipeSegmentGeometry } from './geometry'
export { PipeSegmentNode } from './schema'
@@ -0,0 +1,302 @@
'use client'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
emitter,
type GridEvent,
PipeSegmentNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import {
DragBoundingBox,
EDITOR_LAYER,
markToolCancelConsumed,
stripPlacementMetadataFlags,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef, useState } from 'react'
import { Vector3 } from 'three'
import {
type Aabb2D,
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
type Vec3 = [number, number, number]
const GHOST_COLOR = '#818cf8'
const GHOST_OPACITY = 0.5
const IN_TO_M = 0.0254
/** Snap a coordinate to the editor's live grid step. */
function snapToGridStep(value: number): number {
const step = useEditor.getState().gridSnapStep
if (step <= 0) return value
return Math.round(value / step) * step
}
function pathCenterXZ(path: readonly Vec3[]): [number, number] {
let x = 0
let z = 0
for (const p of path) {
x += p[0]
z += p[2]
}
const n = path.length || 1
return [x / n, z / n]
}
/** The pipe's radius (meters) — half the nominal diameter, used as the
* box / footprint padding and the ghost cylinder radius. */
function pipeRadiusM(pipe: PipeSegmentNode): number {
return (pipe.diameter * IN_TO_M) / 2
}
/** XZ bounds of a path padded by the pipe's radius. */
function pathAabb(path: readonly Vec3[], r: number): Aabb2D {
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (const p of path) {
if (p[0] < minX) minX = p[0]
if (p[0] > maxX) maxX = p[0]
if (p[2] < minZ) minZ = p[2]
if (p[2] > maxZ) maxZ = p[2]
}
return { minX: minX - r, maxX: maxX + r, minZ: minZ - r, maxZ: maxZ + r }
}
/**
* Ghost-preview duplicate / move tool for DWV pipe runs — the plumbing
* sibling of `MoveDuctSegmentTool`. Pipes are always round, so the ghost
* is a translucent cylinder per section (no rect branch).
*
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
* inserted into the scene until the commit click. A translucent ghost of
* the run rides the cursor inside a footprint bounding box — the same
* affordance other items get — and Figma-style alignment guides snap the
* box's edges to nearby geometry. The next grid click calls `createNode`;
* Esc discards. The run's Y coords (slope) ride along untouched: the move
* only shifts XZ.
*
* **Move** (existing run): the real node's mesh is hidden while the same
* ghost + box tracks the cursor; the commit click writes the translated
* `path` and reveals it, Esc reveals it unchanged.
*
* Wired via `def.affordanceTools.move`.
*/
export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
const pipe = node as PipeSegmentNode
const originalPathRef = useRef<Vec3[]>(pipe.path.map((p) => [...p] as Vec3))
const isNew =
typeof node.metadata === 'object' &&
node.metadata !== null &&
!Array.isArray(node.metadata) &&
(node.metadata as Record<string, unknown>).isNew === true
const [previewPath, setPreviewPath] = useState<Vec3[]>(originalPathRef.current)
const previewPathRef = useRef<Vec3[]>(originalPathRef.current)
const hasMovedRef = useRef(false)
const activatedAtRef = useRef<number>(Date.now())
const prevSnapRef = useRef<[number, number] | null>(null)
useEffect(() => {
const nodeId = node.id as AnyNodeId
const originalPath = originalPathRef.current
const [centerX, centerZ] = pathCenterXZ(originalPath)
const r = pipeRadiusM(pipe)
const baseAabb = pathAabb(originalPath, r)
useScene.temporal.getState().pause()
let committed = false
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
useScene.getState().nodes,
nodeId,
useViewer.getState().selection.levelId ?? node.parentId,
)
// Moving an existing run: hide its 3D MESH imperatively (NOT the store
// `visible` flag — the 2D floor plan skips `visible:false` nodes, so a
// store hide makes the run vanish in 2D / split view). The ghost stands
// in until commit; the real mesh is restored on cancel / unmount.
const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId]
const setMeshHidden = (hidden: boolean) => {
const obj = sceneRegistry.nodes.get(nodeId)
if (obj) obj.visible = !hidden
}
if (existedAtStart) setMeshHidden(true)
const setPreview = (path: Vec3[]) => {
previewPathRef.current = path
setPreviewPath(path)
}
const onMove = (event: GridEvent) => {
const bypass = event.nativeEvent?.shiftKey === true
const snap = bypass ? (v: number) => v : snapToGridStep
let dx = snap(event.localPosition[0] - centerX)
let dz = snap(event.localPosition[2] - centerZ)
// Figma-style alignment: snap the run's footprint box edges onto
// nearby geometry and publish the guides (Shift bypass).
if (!bypass) {
const proposed: Aabb2D = {
minX: baseAabb.minX + dx,
maxX: baseAabb.maxX + dx,
minZ: baseAabb.minZ + dz,
maxZ: baseAabb.maxZ + dz,
}
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
dx += sdx
dz += sdz
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
}
const cur: [number, number] = [centerX + dx, centerZ + dz]
if (
!bypass &&
(!prevSnapRef.current ||
prevSnapRef.current[0] !== cur[0] ||
prevSnapRef.current[1] !== cur[1])
) {
triggerSFX('sfx:grid-snap')
}
prevSnapRef.current = cur
hasMovedRef.current = true
setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3))
}
const commit = (event: GridEvent) => {
if (committed) return
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
if (!hasMovedRef.current) {
event.nativeEvent?.stopPropagation?.()
return
}
committed = true
const finalPath = previewPathRef.current
useScene.temporal.getState().resume()
let selectId = nodeId
if (isNew && !useScene.getState().nodes[nodeId]) {
const created = PipeSegmentNode.parse({
...(node as Record<string, unknown>),
path: finalPath,
metadata: stripPlacementMetadataFlags(node.metadata),
visible: true,
})
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
useScene.getState().updateNode(nodeId, { path: finalPath } as Partial<AnyNode>)
useScene.getState().markDirty(nodeId)
}
useScene.temporal.getState().pause()
setMeshHidden(false)
useAlignmentGuides.getState().clear()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [selectId] })
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
}
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
markToolCancelConsumed()
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', commit)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
}
}, [pipe, isNew, node])
const segments: Array<{ a: Vec3; b: Vec3 }> = []
for (let i = 0; i < previewPath.length - 1; i++) {
segments.push({ a: previewPath[i]!, b: previewPath[i + 1]! })
}
// Footprint box spanning the whole run (axis-aligned), drawn around the
// ghost the same way items get one. Recomputed from the live preview path.
const r = pipeRadiusM(pipe)
const box = pathAabb(previewPath, r)
const boxY = previewPath[0]?.[1] ?? 0
return (
<group>
{segments.map((seg, i) => (
<GhostSegment a={seg.a} b={seg.b} radius={r} key={`ghost-${i}`} />
))}
<DragBoundingBox
centerY={0}
nodeId={node.id}
position={[(box.minX + box.maxX) / 2, boxY, (box.minZ + box.maxZ) / 2]}
size={[box.maxX - box.minX, pipe.diameter * IN_TO_M, box.maxZ - box.minZ]}
/>
</group>
)
}
/** Translucent stand-in for one pipe section — mirrors the draw tool's
* `PreviewPipe` so the ghost matches what actually lands. */
function GhostSegment({ a, b, radius }: { a: Vec3; b: Vec3; radius: number }) {
const start = new Vector3(...a)
const end = new Vector3(...b)
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-4) return null
dir.normalize()
const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5)
return (
<mesh
layers={EDITOR_LAYER}
position={mid.toArray()}
ref={(m) => {
if (!m) return
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
}}
>
<cylinderGeometry args={[radius, radius, length, 24, 1, false]} />
<meshBasicMaterial
color={GHOST_COLOR}
depthTest={false}
opacity={GHOST_OPACITY}
transparent
/>
</mesh>
)
}
export default MovePipeSegmentTool
@@ -0,0 +1,36 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { PipeSegmentNode } from './schema'
export const pipeSegmentParametrics: ParametricDescriptor<PipeSegmentNode> = {
groups: [
{
label: 'Drainage',
fields: [
{
key: 'system',
kind: 'enum',
options: ['waste', 'vent'],
display: 'segmented',
},
{
key: 'diameter',
kind: 'number',
unit: 'in',
min: 1.25,
max: 6,
step: 0.25,
},
],
},
{
label: 'Construction',
fields: [
{
key: 'pipeMaterial',
kind: 'enum',
options: ['pvc', 'abs', 'cast-iron'],
},
],
},
],
}
@@ -0,0 +1 @@
export { PipeSegmentNode } from '@pascal-app/core'
@@ -0,0 +1,362 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
analyzePortConnectivity,
type PipeSegmentNode,
type PortConnectivity,
pauseSceneHistory,
resolveConnectivityUpdates,
resumeSceneHistory,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useRef, useState } from 'react'
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import { collectScenePorts, DWV_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports'
/** Handle pip radius (meters). */
const HANDLE_RADIUS = 0.09
/** Port-snap radius for dragged run endpoints (meters, XZ). */
const PORT_SNAP_RADIUS_M = 0.4
const UP = new Vector3(0, 1, 0)
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
type Point = [number, number, number]
/**
* Selection-time editing for committed DWV pipe runs: one draggable
* handle per path point. The plumbing sibling of the duct-segment
* affordance — same portal / constrained-drag / single-undo model, snapping
* to DWV ports instead of duct ports.
*
* Handles are PORTALED into the pipe's registered scene group so they
* share its exact frame — path coords are node-local, and the level /
* building transform above the group applies to the handles for free.
*
* Drag model: by default the point is CONSTRAINED to the axis the
* segment was drawn along. Holding **Alt** releases it into free
* horizontal-plane movement (endpoints port-snap onto nearby DWV ports).
* Holding **Shift** bypasses grid snapping for a precision drag.
*/
const PipeSegmentSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const pipe = useScene((s) => {
if (selectedIds.length !== 1) return null
const node = s.nodes[selectedIds[0] as AnyNodeId]
return node?.type === 'pipe-segment' ? (node as PipeSegmentNode) : null
})
// Portal target: the pipe's registered group. Resolved with a rAF
// retry because registration happens on the renderer's mount, which
// can land a frame after selection.
const pipeId = pipe?.id ?? null
const [target, setTarget] = useState<Object3D | null>(null)
useEffect(() => {
if (!pipeId) {
setTarget(null)
return
}
let frameId = 0
const resolve = () => {
const next = sceneRegistry.nodes.get(pipeId as AnyNodeId) ?? null
setTarget((cur) => (cur === next ? cur : next))
if (!next) frameId = window.requestAnimationFrame(resolve)
}
resolve()
return () => window.cancelAnimationFrame(frameId)
}, [pipeId])
if (!pipe || !target) return null
return createPortal(<PipePointHandles pipe={pipe} target={target} />, target, undefined)
}
const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Object3D }) => {
const { camera, gl } = useThree()
const unit = useViewer((s) => s.unit)
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
// Set while a drag is live; null otherwise. Holds everything the window
// pointer handlers need so they never read stale React state.
const dragRef = useRef<{
index: number
initialPath: Point[]
current: Point
cleanup: () => void
// Connectivity snapshot taken at pointer-down: which fittings / pipes are
// mated to this run's endpoints, so they follow as the endpoint moves.
connectivity: PortConnectivity | null
} | null>(null)
const makeRay = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const ndc = new Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
const raycaster = new Raycaster()
raycaster.setFromCamera(ndc, camera)
return raycaster.ray
}
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
const hit = new Vector3()
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
}
/**
* Signed distance along `axisWorld` (unit, through `anchorWorld`) of the
* point on that line closest to the cursor ray. Null when the ray runs
* (near-)parallel to the axis and the projection is unstable.
*/
const projectOntoAxis = (
clientX: number,
clientY: number,
anchorWorld: Vector3,
axisWorld: Vector3,
): number | null => {
const ray = makeRay(clientX, clientY)
const w0 = new Vector3().subVectors(ray.origin, anchorWorld)
const b = ray.direction.dot(axisWorld)
const denom = 1 - b * b
if (Math.abs(denom) < 1e-6) return null
const d0 = ray.direction.dot(w0)
const e0 = axisWorld.dot(w0)
return (e0 - b * d0) / denom
}
/** World-space position of a local path point. */
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
/** Convert a world-space hit back into the pipe group's local frame. */
const toLocal = (world: Vector3): Point => {
const local = target.worldToLocal(world.clone())
return [local.x, local.y, local.z]
}
// Follow-updates for fittings / pipes mated to this run's endpoints, given
// the run's live path. Endpoints whose position didn't change resolve to a
// zero delta, so only the dragged endpoint's partner actually moves.
const connectivityUpdatesForPath = (
connectivity: PortConnectivity | null,
path: Point[],
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
if (!connectivity) return []
const preview = { ...(pipe as Record<string, unknown>), path } as AnyNode
return resolveConnectivityUpdates(connectivity, preview).filter(
(u) => useScene.getState().nodes[u.id],
)
}
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const initialPath = pipe.path.map((p) => [...p] as Point)
const startPoint = initialPath[index]!
const connectivity = analyzePortConnectivity(pipe as AnyNode, useScene.getState().nodes)
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
document.body.style.cursor = 'grabbing'
setDraggingIndex(index)
const isEndpoint = index === 0 || index === initialPath.length - 1
// Axis the segment was drawn along, at this point: from the
// neighbouring path point toward the dragged one. The default drag
// is constrained to this line.
const neighbor = initialPath[index === 0 ? 1 : index - 1]!
const axisLocal = new Vector3(
startPoint[0] - neighbor[0],
startPoint[1] - neighbor[1],
startPoint[2] - neighbor[2],
)
if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0)
axisLocal.normalize()
// World-space anchor + axis, derived once — the constraint line is
// fixed for the whole drag regardless of where the point currently is.
const anchorWorldStart = toWorld(startPoint)
const axisWorld = toWorld([
startPoint[0] + axisLocal.x,
startPoint[1] + axisLocal.y,
startPoint[2] + axisLocal.z,
])
.sub(anchorWorldStart)
.normalize()
const onMove = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag) return
const current = drag.current
// Shift = precision: bypass grid snapping for a perfectly smooth
// drag (snap() is a no-op at step 0).
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
let next: Point | null = null
if (event.altKey) {
// Alt = freedom: slide on the horizontal plane at the point's
// height. Endpoints can port-snap here to mate onto a fitting.
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current))
const hit = intersect(event.clientX, event.clientY, plane)
if (hit) {
const local = toLocal(hit)
next = [snap(local[0], step), current[1], snap(local[2], step)]
if (isEndpoint) {
const port = findNearestPortXZ(
[local[0], current[1], local[2]],
collectScenePorts({ excludeNodeId: pipe.id, systems: DWV_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (port) next = [port.position[0], port.position[1], port.position[2]]
}
}
} else {
// Default: constrained to the axis the segment was drawn along —
// slide the point closer / further along its own line.
const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld)
if (t !== null) {
const dist = snap(t, step)
next = [
startPoint[0] + axisLocal.x * dist,
Math.max(0, startPoint[1] + axisLocal.y * dist),
startPoint[2] + axisLocal.z * dist,
]
}
}
if (!next) return
if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return
drag.current = next
const path = pipe.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
// Drag the run + any fittings mated to the moved endpoint as one batch.
useScene
.getState()
.updateNodes([
{ id: pipe.id as AnyNodeId, data: { path } },
...connectivityUpdatesForPath(drag.connectivity, path),
])
}
const onUp = () => {
const drag = dragRef.current
if (!drag) return
drag.cleanup()
dragRef.current = null
setDraggingIndex(null)
// Single-undo dance: revert (still paused), resume, re-apply the
// final path — plus any connected fitting moves — as one tracked batch.
const finalPath = drag.initialPath.map((p, i) =>
i === drag.index ? drag.current : p,
) as Point[]
const finalUpdates = connectivityUpdatesForPath(drag.connectivity, finalPath)
// Revert the run AND the followers to their pre-drag state while paused
// so history captures a clean before→after delta.
const revertUpdates = (drag.connectivity?.connections ?? []).flatMap((conn) =>
conn.kind === 'rigid-node'
? [{ id: conn.nodeId, data: { position: conn.startPosition } as Partial<AnyNode> }]
: [{ id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> }],
)
useScene
.getState()
.updateNodes([
{ id: pipe.id as AnyNodeId, data: { path: drag.initialPath } },
...revertUpdates.filter((u) => useScene.getState().nodes[u.id]),
])
resumeSceneHistory(useScene)
const moved = finalPath[drag.index]!.some(
(v, axis) => v !== drag.initialPath[drag.index]![axis],
)
if (moved) {
useScene
.getState()
.updateNodes([{ id: pipe.id as AnyNodeId, data: { path: finalPath } }, ...finalUpdates])
}
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
useViewer.getState().setInputDragging(false)
document.body.style.cursor = ''
}
dragRef.current = { index, initialPath, current: startPoint, cleanup, connectivity }
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
return (
<group>
{pipe.path.map((p, i) => {
const active = draggingIndex === i
const hovered = hoverIndex === i
return (
<mesh
key={`pipe-handle-${i}`}
layers={EDITOR_LAYER}
onPointerDown={onHandleDown(i)}
onPointerEnter={(e) => {
e.stopPropagation()
setHoverIndex(i)
if (draggingIndex === null) document.body.style.cursor = 'grab'
}}
onPointerLeave={() => {
setHoverIndex((prev) => (prev === i ? null : prev))
if (draggingIndex === null) document.body.style.cursor = ''
}}
position={p as Point}
>
<sphereGeometry args={[HANDLE_RADIUS, 16, 12]} />
<meshBasicMaterial
color={active || hovered ? '#7dd3fc' : '#38bdf8'}
depthTest={false}
opacity={active ? 1 : 0.85}
transparent
/>
</mesh>
)
})}
{draggingIndex !== null &&
pipe.path[draggingIndex] &&
(() => {
// Same pill as the draw tool: signed per-axis deltas from the
// drag-start position, dominant axis emphasised.
const point = pipe.path[draggingIndex]!
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
const axes = ['x', 'y', 'z'] as const
const primary = axes.reduce((best, axis, i) =>
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
)
return (
<Html
center
position={[point[0], point[1] + 0.35, point[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<DimensionPill
parts={axes.map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: deltas[i]!,
signed: true,
}))}
primary={primary}
unit={unit}
/>
</Html>
)
})()}
</group>
)
}
export default PipeSegmentSelectionAffordance
+691
View File
@@ -0,0 +1,691 @@
'use client'
import { type AnyNode, emitter, type GridEvent, PipeSegmentNode, useScene } from '@pascal-app/core'
import {
CursorSphere,
DimensionPill,
EDITOR_LAYER,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useRef, useState } from 'react'
import { Vector3 } from 'three'
import {
planPipeBranchTap,
planPipeCrossAtRunBody,
planPipeElbowAtPort,
} from '../shared/auto-fitting'
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import {
collectScenePorts,
DWV_PORT_SYSTEMS,
findNearestPortXZ,
findNearestRunBodyXZ,
findRunBodyCrossingXZ,
type RunBodyHit,
type ScenePort,
} from '../shared/ports'
import { pipeSegmentDefinition } from './definition'
/**
* Slope-aware two-click placement tool for DWV pipe runs — the plumbing
* sibling of the duct tool.
*
* - **First click** anchors the run start (port snap joins onto an
* existing pipe end — DWV ports only, duct/refrigerant collars are
* invisible to it). The start inherits the snapped port's height.
* - **Second click** commits a two-point pipe and re-arms.
* - **Slope**: runs draw LEVEL by default. **S** toggles slope mode,
* where waste runs fall at ¼" per foot (1:48) of horizontal
* distance, the IPC default for residential drains. When sloped, a
* freely placed start is RAISED so the run falls onto the grid plane
* (nothing clips below); a port/body-snapped start keeps its fixed
* height and the end drops instead. Vent runs always stay level.
* The pill shows the live drop in the Y part.
* - **Q** toggles waste ↔ vent. **[ / ]** steps the pipe size through
* nominal DWV diameters.
* - Hold **Alt** → vertical mode (stacks): XZ locks to the start,
* mouse vertical motion drives Y, click commits the riser.
* - 45° XZ angle lock from the start; **Shift** frees the angle and
* grid snap.
* - Esc clears an anchored start point.
*/
const PREVIEW_OPACITY = 0.55
/** Nominal residential DWV sizes (inches). */
const PIPE_DIAMETERS_IN = [1.25, 1.5, 2, 3, 4, 6] as const
/** IPC default drain slope — ¼" per foot (1:48). */
const DRAIN_SLOPE = 1 / 48
/** Snap radius (meters, XZ) for joining onto an existing pipe end. */
const PORT_SNAP_RADIUS_M = 0.5
/** Snap radius (meters, XZ) for tapping the side of an existing run. */
const BODY_SNAP_RADIUS_M = 0.3
const ANGLE_STEP_RAD = Math.PI / 4
const ALT_PIXELS_PER_METER = 100
const ALT_Y_MIN_M = -3
const ALT_Y_MAX_M = 10
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
function dist2(a: readonly [number, number, number], b: readonly [number, number, number]): number {
const dx = a[0] - b[0]
const dy = a[1] - b[1]
const dz = a[2] - b[2]
return dx * dx + dy * dy + dz * dz
}
function findNearbyPort(point: [number, number, number]): ScenePort | null {
return findNearestPortXZ(
point,
collectScenePorts({ systems: DWV_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
}
function projectToAngleLock(
from: [number, number, number],
raw: [number, number, number],
): [number, number, number] {
const dx = raw[0] - from[0]
const dz = raw[2] - from[2]
const len = Math.hypot(dx, dz)
if (len < 1e-4) return [from[0], from[1], from[2]]
const theta = Math.atan2(dz, dx)
const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD
const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped)
const d = Math.max(0, proj)
return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d]
}
const PipeSegmentTool = () => {
const activeLevelId = useViewer((s) => s.selection.levelId)
const unit = useViewer((s) => s.unit)
const [system, setSystem] = useState<'waste' | 'vent'>('waste')
const [sloped, setSloped] = useState(false)
const [diameter, setDiameter] = useState<number>(
(pipeSegmentDefinition.defaults() as { diameter: number }).diameter,
)
const [draftStart, setDraftStart] = useState<[number, number, number] | null>(null)
const [cursorPos, setCursorPos] = useState<[number, number, number] | null>(null)
const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null)
const [altActive, setAltActive] = useState(false)
const startRef = useRef(draftStart)
startRef.current = draftStart
const systemRef = useRef(system)
systemRef.current = system
const slopedRef = useRef(sloped)
slopedRef.current = sloped
const diameterRef = useRef(diameter)
diameterRef.current = diameter
// Port / run-body the anchored start snapped onto — read at commit so
// joints mint bends (corner) or wyes / sanitary tees (body tap).
const startPortRef = useRef<ScenePort | null>(null)
const startBodyRef = useRef<RunBodyHit | null>(null)
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
const lastClientYRef = useRef<number | null>(null)
useEffect(() => {
if (!activeLevelId) return
/** Corner-bend gate: joints onto another PIPE run's open end. */
const bendPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
if (!port) return null
const owner = useScene.getState().nodes[port.nodeId]
if (owner?.type !== 'pipe-segment') return null
const plan = planPipeElbowAtPort(port, awayDir, diameterRef.current, owner.pipeMaterial)
if (!plan) return null
// Trim the run's snapped endpoint back to the bend's inlet collar.
const path = owner.path.map((p) => [...p] as [number, number, number])
const index = port.id === 'start' ? 0 : path.length - 1
const neighbor = path[index === 0 ? 1 : index - 1]!
const remaining = Math.hypot(
plan.trimmedPortPoint[0] - neighbor[0],
plan.trimmedPortPoint[1] - neighbor[1],
plan.trimmedPortPoint[2] - neighbor[2],
)
const original = path[index]!
const originalLen = Math.hypot(
original[0] - neighbor[0],
original[1] - neighbor[1],
original[2] - neighbor[2],
)
if (remaining < 0.05 || remaining >= originalLen) return null
path[index] = plan.trimmedPortPoint
return { ...plan, trim: { id: port.nodeId, data: { path } as Partial<AnyNode> } }
}
const commitSegment = (
rawStart: [number, number, number],
end: [number, number, number],
endPort: ScenePort | null = null,
endBody: RunBodyHit | null = null,
) => {
// Free waste start: lift it by the drain fall so the run lands ON
// the grid plane instead of sinking below it. Snapped starts are
// height-fixed (fixture drain, run end), so their end drops instead.
let start = rawStart
if (
slopedRef.current &&
systemRef.current === 'waste' &&
!startPortRef.current &&
!startBodyRef.current &&
!endPort
) {
const run = Math.hypot(end[0] - rawStart[0], end[2] - rawStart[2])
start = [rawStart[0], rawStart[1] + run * DRAIN_SLOPE, rawStart[2]]
}
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
if (length < 1e-4) return
const dir: [number, number, number] = [
(end[0] - start[0]) / length,
(end[1] - start[1]) / length,
(end[2] - start[2]) / length,
]
const startPlan = bendPlanFor(startPortRef.current, dir)
const endPlan = bendPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
// Body tap (wye / sanitary tee) when the start landed on a run's side.
const body = startPlan ? null : startBodyRef.current
const bodyOwner = body ? useScene.getState().nodes[body.nodeId] : null
const tapPlan =
body && bodyOwner?.type === 'pipe-segment'
? planPipeBranchTap(bodyOwner, body, dir, diameterRef.current)
: null
// End body tap: the END landed on a run's side — split that trunk and
// the new run ends at the branch collar, the branch leaving back
// toward the drawn run (along -dir, since dir points start→end).
const endTapBody = endPlan ? null : endBody
const endTapOwner = endTapBody ? useScene.getState().nodes[endTapBody.nodeId] : null
const endTapPlan =
endTapBody && endTapOwner?.type === 'pipe-segment'
? planPipeBranchTap(
endTapOwner,
endTapBody,
[-dir[0], -dir[1], -dir[2]],
diameterRef.current,
)
: null
// Both ends tapping the SAME run would split one polyline twice in a
// single change — drop the end tap and let the end butt-join instead.
const endTap = endTapPlan && endTapBody?.nodeId === body?.nodeId ? null : endTapPlan
let pipeStart = startPlan?.collarPoint ?? tapPlan?.branchCollar ?? start
let pipeEnd = endPlan?.collarPoint ?? endTap?.branchCollar ?? end
const remaining = Math.hypot(
pipeEnd[0] - pipeStart[0],
pipeEnd[1] - pipeStart[1],
pipeEnd[2] - pipeStart[2],
)
let bends = [startPlan, endPlan].filter((p) => p !== null)
let tap = tapPlan
let endTapFinal = endTap
// Cross tap: the drawn run passes straight THROUGH a run's body
// (interior crossing, not an end touch). Split that run and the drawn
// pipe into two halves meeting the cross's opposed branch collars.
// Skip a run already tapped by a start / end tee so one polyline isn't
// split twice in a single change.
const crossHit = findRunBodyCrossingXZ(start, end, BODY_SNAP_RADIUS_M, {
kinds: ['pipe-segment'],
})
const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null
const crossTappedElsewhere =
crossHit?.nodeId === body?.nodeId || crossHit?.nodeId === endTapBody?.nodeId
let cross =
crossHit && !crossTappedElsewhere && crossOwner?.type === 'pipe-segment'
? planPipeCrossAtRunBody(crossOwner, crossHit, dir, diameterRef.current)
: null
if (remaining <= 0.05) {
bends = []
tap = null
endTapFinal = null
cross = null
pipeStart = start
pipeEnd = end
}
const makePipe = (from: [number, number, number], to: [number, number, number]) =>
PipeSegmentNode.parse({
...pipeSegmentDefinition.defaults(),
name: systemRef.current === 'vent' ? 'Vent' : 'Drain',
path: [from, to],
diameter: diameterRef.current,
system: systemRef.current,
})
// A cross splits the drawn run into two halves that meet its opposed
// branch collars; otherwise it's one pipe end-to-end. Degenerate
// halves (the crossing too near an end) are dropped.
const pipes = cross
? [
dist2(pipeStart, cross.branchCollarNear) > 0.05 * 0.05
? makePipe(pipeStart, cross.branchCollarNear)
: null,
dist2(cross.branchCollarFar, pipeEnd) > 0.05 * 0.05
? makePipe(cross.branchCollarFar, pipeEnd)
: null,
].filter((p) => p !== null)
: [makePipe(pipeStart, pipeEnd)]
useScene.getState().applyNodeChanges({
create: [
...bends.map((plan) => ({ node: plan.fitting, parentId: activeLevelId })),
...(tap
? [
{ node: tap.fitting, parentId: activeLevelId },
{ node: tap.runTail, parentId: activeLevelId },
]
: []),
...(endTapFinal
? [
{ node: endTapFinal.fitting, parentId: activeLevelId },
{ node: endTapFinal.runTail, parentId: activeLevelId },
]
: []),
...(cross
? [
{ node: cross.fitting, parentId: activeLevelId },
{ node: cross.runTail, parentId: activeLevelId },
]
: []),
...pipes.map((node) => ({ node, parentId: activeLevelId })),
],
update: [
...bends.map((plan) => plan.trim),
...(tap ? [tap.runUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
...(endTapFinal
? [endTapFinal.runUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }]
: []),
...(cross ? [cross.runUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
],
})
triggerSFX('sfx:item-place')
setDraftStart(null)
setSnapTarget(null)
startPortRef.current = null
startBodyRef.current = null
altAnchorRef.current = null
setAltActive(false)
}
/** Apply the drain fall to an XZ-resolved end point. Only snapped
* starts (fixture drain, run end/body) drop the end — they're
* height-fixed. A free start keeps the end on the grid plane and
* gets LIFTED at commit instead, so the run never sinks below it. */
const applySlope = (
start: [number, number, number],
end: [number, number, number],
): [number, number, number] => {
if (!slopedRef.current || systemRef.current !== 'waste') return end
if (!startPortRef.current && !startBodyRef.current) return end
const run = Math.hypot(end[0] - start[0], end[2] - start[2])
return [end[0], start[1] - run * DRAIN_SLOPE, end[2]]
}
const resolveSnappedPoint = (
event: GridEvent,
): {
point: [number, number, number]
snapped: [number, number, number] | null
port: ScenePort | null
body: RunBodyHit | null
} => {
const start = startRef.current
if (!start) {
const raw: [number, number, number] = [event.localPosition[0], 0, event.localPosition[2]]
const step = useEditor.getState().gridSnapStep
const shift = event.nativeEvent?.shiftKey === true
if (event.nativeEvent?.altKey !== true) {
const port = findNearbyPort(raw)
if (port) {
const p: [number, number, number] = [
port.position[0],
port.position[1],
port.position[2],
]
return { point: p, snapped: p, port, body: null }
}
// No open end nearby — try the side of a run (wye / santee tap).
// Probe with a grid-snapped cursor so the tap steps along the run
// like every other placement; Shift frees it to ride smoothly.
const probe: [number, number, number] = shift
? raw
: [snap(raw[0], step), 0, snap(raw[2], step)]
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M, {
kinds: ['pipe-segment'],
})
if (body) return { point: body.point, snapped: body.point, port: null, body }
}
return {
point: [snap(raw[0], step), 0, snap(raw[2], step)],
snapped: null,
port: null,
body: null,
}
}
const rawXZ: [number, number, number] = [
event.localPosition[0],
start[1],
event.localPosition[2],
]
const shift = event.nativeEvent?.shiftKey === true
const angled = shift ? rawXZ : projectToAngleLock(start, rawXZ)
const step = useEditor.getState().gridSnapStep
if (event.nativeEvent?.altKey !== true && !shift) {
const port = findNearbyPort(rawXZ)
if (port) {
const p: [number, number, number] = [port.position[0], port.position[1], port.position[2]]
return { point: p, snapped: p, port, body: null }
}
// No open end nearby — landing on the side of a run taps a wye /
// sanitary tee there (mirror of the first-point tap). Probe with a
// grid-snapped cursor so the tap steps along the run; checked against
// the cursor, not the 45° projection, so a slightly-off trunk captures.
const probe: [number, number, number] = [
snap(rawXZ[0], step),
rawXZ[1],
snap(rawXZ[2], step),
]
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M, { kinds: ['pipe-segment'] })
if (body) return { point: body.point, snapped: body.point, port: null, body }
}
let end: [number, number, number]
if (shift) {
end = [snap(angled[0], step), angled[1], snap(angled[2], step)]
} else {
// Snap the run LENGTH along the locked ray, not each axis — an
// off-grid start (port / body snap) plus per-axis rounding pulls
// the end off the 45° ray, bending the run as the cursor moves.
const dx = angled[0] - start[0]
const dz = angled[2] - start[2]
const len = Math.hypot(dx, dz)
if (len < 1e-6) {
end = angled
} else {
const s = snap(len, step) / len
end = [start[0] + dx * s, angled[1], start[2] + dz * s]
}
}
return { point: applySlope(start, end), snapped: null, port: null, body: null }
}
const resolveAltVerticalPoint = (clientY: number): [number, number, number] | null => {
const anchor = altAnchorRef.current
const start = startRef.current
if (!anchor || !start) return null
const step = useEditor.getState().gridSnapStep
const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER
const snappedDy = snap(dy, step)
const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy))
return [start[0], y, start[2]]
}
// Resolve the cursor point (port / body / grid / angle snap) then layer
// Figma-style alignment so a run lines up with other runs, fittings, and
// items as it's drawn. Free point (first vertex / Shift) snaps; an
// angle-locked continuation shows the guide passively. Port / body snap or
// Alt bypasses alignment.
const resolveAlignedPoint = (event: GridEvent) => {
const r = resolveSnappedPoint(event)
const hasStart = !!startRef.current
const shift = event.nativeEvent?.shiftKey === true
const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, {
applySnap: !hasStart || shift,
bypass: alt || r.snapped !== null,
})
return { ...r, point }
}
const onMove = (event: GridEvent) => {
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
if (typeof clientY === 'number') lastClientYRef.current = clientY
if (altAnchorRef.current && typeof clientY === 'number') {
const point = resolveAltVerticalPoint(clientY)
if (point) {
clearDrawAlignment()
setCursorPos(point)
setSnapTarget(null)
return
}
}
const { point, snapped } = resolveAlignedPoint(event)
setCursorPos(point)
setSnapTarget(snapped)
}
const onClick = (event: GridEvent) => {
const start = startRef.current
if (altAnchorRef.current && start) {
const clientY =
(event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current
if (typeof clientY === 'number') {
const point = resolveAltVerticalPoint(clientY)
if (point && Math.abs(point[1] - start[1]) >= 1e-4) commitSegment(start, point)
}
return
}
const { point, port, body } = resolveAlignedPoint(event)
if (!start) {
// First click: anchor the start, remembering the port / run body
// it snapped to so the commit can mint a bend / wye.
triggerSFX('sfx:grid-snap')
startPortRef.current = port
startBodyRef.current = port ? null : body
setDraftStart(point)
return
}
commitSegment(start, point, port, port ? null : body)
}
const enterAltMode = () => {
const start = startRef.current
if (!start || lastClientYRef.current === null) return
if (altAnchorRef.current) return
altAnchorRef.current = { clientY: lastClientYRef.current, baseY: start[1] }
setAltActive(true)
}
const exitAltMode = () => {
if (!altAnchorRef.current) return
altAnchorRef.current = null
setAltActive(false)
}
const stepDiameter = (step: 1 | -1) => {
const sizes = PIPE_DIAMETERS_IN
const current = diameterRef.current
let nearest = 0
for (let i = 1; i < sizes.length; i++) {
if (Math.abs(sizes[i]! - current) < Math.abs(sizes[nearest]! - current)) nearest = i
}
const next = sizes[Math.min(sizes.length - 1, Math.max(0, nearest + step))]!
if (next === current) return
setDiameter(next)
triggerSFX('sfx:grid-snap')
}
const onKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (e.key === 'Alt') {
e.preventDefault()
enterAltMode()
} else if (e.key === '[') {
e.preventDefault()
stepDiameter(-1)
} else if (e.key === ']') {
e.preventDefault()
stepDiameter(1)
} else if (e.key === 'q' || e.key === 'Q') {
e.preventDefault()
setSystem((s) => (s === 'waste' ? 'vent' : 'waste'))
triggerSFX('sfx:grid-snap')
} else if (e.key === 's' || e.key === 'S') {
e.preventDefault()
setSloped((s) => !s)
triggerSFX('sfx:grid-snap')
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Alt') {
e.preventDefault()
exitAltMode()
}
}
const onCancel = () => {
clearDrawAlignment()
if (!startRef.current) return
markToolCancelConsumed()
setDraftStart(null)
setCursorPos(null)
setSnapTarget(null)
startPortRef.current = null
startBodyRef.current = null
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', onClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', onClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
altAnchorRef.current = null
clearDrawAlignment()
}
}, [activeLevelId])
if (!activeLevelId) return null
// Free waste start lifts at commit so the run falls ONTO the grid —
// mirror that here so the preview line / pill match the placed pipe.
// A snapped end (snapTarget set) keeps the start where it is.
const displayStart =
draftStart &&
cursorPos &&
sloped &&
system === 'waste' &&
!startPortRef.current &&
!startBodyRef.current &&
!snapTarget &&
!altActive
? ([
draftStart[0],
draftStart[1] +
Math.hypot(cursorPos[0] - draftStart[0], cursorPos[2] - draftStart[2]) * DRAIN_SLOPE,
draftStart[2],
] as [number, number, number])
: draftStart
const pillParts = cursorPos
? [
...(['x', 'y', 'z'] as const).map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: displayStart ? cursorPos[i]! - displayStart[i]! : cursorPos[i]!,
signed: !!displayStart,
})),
{ key: 'diameter', prefix: 'Ø', value: diameter * 0.0254, signed: false },
]
: null
const pillPrimary = draftStart && cursorPos ? (altActive ? 'y' : 'y') : undefined
return (
<LevelOffsetGroup>
{/* Cursor marker — the same ground ring + vertical line + tool-icon
badge the duct draw tool shows in 3D (icon resolved from the active
`pipe-segment` structure-tools entry). In 2D the floorplan overlay
draws this for every tool; in 3D each tool renders its own. The
dimension pill rides just above the cursor. */}
{cursorPos && (
<>
<CursorSphere position={cursorPos} />
{pillParts && (
<group position={cursorPos}>
<Html
center
position={[0, 0.3, 0]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<div className="flex flex-col items-center gap-1">
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
<div className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-3 py-0.5 text-[10px] text-muted-foreground shadow-sm backdrop-blur">
{system === 'waste'
? sloped
? 'Waste · ¼″/ft fall'
: 'Waste · level'
: 'Vent · level'}{' '}
· Q system{system === 'waste' ? ' · S slope' : ''}
</div>
</div>
</Html>
</group>
)}
</>
)}
{snapTarget && (
<mesh layers={EDITOR_LAYER} position={snapTarget}>
<sphereGeometry args={[0.1, 24, 16]} />
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
</mesh>
)}
{displayStart && (
<mesh layers={EDITOR_LAYER} position={displayStart}>
<sphereGeometry args={[0.05, 16, 12]} />
<meshBasicMaterial color="#818cf8" depthTest={false} />
</mesh>
)}
{displayStart && cursorPos && (
<PreviewPipe a={displayStart} b={cursorPos} diameterIn={diameter} />
)}
</LevelOffsetGroup>
)
}
function PreviewPipe({
a,
b,
diameterIn,
}: {
a: [number, number, number]
b: [number, number, number]
diameterIn: number
}) {
const start = new Vector3(...a)
const end = new Vector3(...b)
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-4) return null
dir.normalize()
const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5)
const radius = (diameterIn * 0.0254) / 2
return (
<mesh
layers={EDITOR_LAYER}
position={mid.toArray()}
ref={(m) => {
if (!m) return
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
}}
>
<cylinderGeometry args={[radius, radius, length, 20, 1, false]} />
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={PREVIEW_OPACITY} transparent />
</mesh>
)
}
export default PipeSegmentTool
@@ -0,0 +1,70 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildPipeTrapFloorplan } from './floorplan'
import { buildPipeTrapGeometry } from './geometry'
import { pipeTrapParametrics } from './parametrics'
import { getPipeTrapPorts } from './ports'
import { PipeTrapNode } from './schema'
/**
* DWV P-trap — the water-seal fitting on the waste line. Placed by its
* own click tool; the pipe tool then draws the trap arm off the outlet.
* Modeled explicitly so the IPC 909.1 trap-arm rule has a node to
* validate.
*/
export const pipeTrapDefinition: NodeDefinition<typeof PipeTrapNode> = {
kind: 'pipe-trap',
schemaVersion: 1,
schema: PipeTrapNode,
category: 'utility',
distributionRole: 'fitting',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: 0,
diameter: 1.5,
pipeMaterial: 'pvc',
armLengthM: 0,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
movable: { axes: ['x', 'y', 'z'], gridSnap: true, portSnap: { systems: ['waste'] } },
rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] },
duplicable: true,
deletable: true,
},
parametrics: pipeTrapParametrics,
geometry: buildPipeTrapGeometry,
geometryKey: (n) => JSON.stringify([n.diameter, n.pipeMaterial, n.armLengthM]),
ports: getPipeTrapPorts,
floorplan: buildPipeTrapFloorplan,
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Place trap' },
{ key: 'R / T', label: 'Rotate ±45°' },
{ key: 'Shift', label: 'Smooth (no grid snap)' },
{ key: 'Esc', label: 'Exit' },
],
presentation: {
label: 'Trap',
description: 'DWV P-trap — water seal on the waste line. The trap arm runs to the vent.',
icon: { kind: 'iconify', name: 'lucide:spline' },
paletteSection: 'structure',
paletteOrder: 98,
},
mcp: {
description:
'A DWV P-trap with inlet (up) and outlet (trap arm) ports. Position is level-local meters; rotation is yaw radians. armLengthM is the trap-arm developed length checked against IPC 909.1.',
},
}
+53
View File
@@ -0,0 +1,53 @@
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
import { getPipeTrapPorts } from './ports'
import type { PipeTrapNode } from './schema'
const PIPE_STROKE = '#57534e'
/**
* Floor-plan symbol — the conventional trap glyph: a short stub at the
* inlet (the fixture drop, drawn as a dot since it's vertical) and a
* solid line for the trap arm out to the outlet. Reads as the P-trap's
* arm in plan.
*/
export function buildPipeTrapFloorplan(
node: PipeTrapNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const ports = getPipeTrapPorts(node)
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
const stroke = showSelectedChrome && palette ? palette.selectedStroke : PIPE_STROKE
const inletXZ: FloorplanPoint = [inlet.position[0], inlet.position[2]]
const outletXZ: FloorplanPoint = [outlet.position[0], outlet.position[2]]
const children: FloorplanGeometry[] = [
{
kind: 'polyline',
points: [inletXZ, outletXZ],
stroke,
strokeWidth: showSelectedChrome ? 2.5 : 1.8,
vectorEffect: 'non-scaling-stroke',
opacity: 0.9,
},
{
kind: 'circle',
cx: inletXZ[0],
cy: inletXZ[1],
r: 0.04,
fill: stroke,
opacity: 0.9,
},
]
if (showSelectedChrome) {
children.push({ kind: 'move-handle', point: [node.position[0], node.position[2]] })
}
return { kind: 'group', children }
}
+69
View File
@@ -0,0 +1,69 @@
import { Group, Mesh, TorusGeometry, Vector3 } from 'three'
import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry'
import { createPipeMaterial } from '../pipe-segment/geometry'
import type { PipeTrapNode } from './schema'
const BEND_SEGMENTS = 24
/** Inlet drop and arm reach in pipe radii — keeps the trap proportional
* to its size without per-size tuning. */
const INLET_DROP_RADII = 2.6
const ARM_REACH_RADII = 3.2
/**
* P-trap geometry in the LOCAL frame (origin at the trap weir, the low
* point of the U). Inlet stub rises +Y to the fixture tailpiece; a
* half-torus U-bend turns the flow; the trap arm runs +X toward the
* vented waste line. `<ParametricNodeRenderer>` applies position + yaw.
*/
export function buildPipeTrapGeometry(node: PipeTrapNode): Group {
const group = new Group()
const material = createPipeMaterial({ pipeMaterial: node.pipeMaterial, system: 'waste' })
const radius = (node.diameter * INCHES_TO_METERS) / 2
const bendR = radius * 1.6
// U-bend: half torus in the XY plane, opening upward. Sits so its two
// tops are at y = bendR (the inlet riser and the arm rise).
const bend = new Mesh(new TorusGeometry(bendR, radius, 12, BEND_SEGMENTS, Math.PI), material)
bend.rotation.z = Math.PI // open side up
bend.position.set(bendR, bendR, 0)
bend.name = 'pipe-trap-bend'
group.add(bend)
// Inlet riser: from the left top of the U straight up to the fixture.
const inletDrop = radius * INLET_DROP_RADII
const inletTop = new Vector3(0, bendR + inletDrop, 0)
const inletStub = buildSection(
new Vector3(0, bendR, 0),
inletTop,
radius,
material,
'pipe-trap-inlet',
)
if (inletStub) group.add(inletStub)
// Trap arm: from the right top of the U horizontally along +X.
const armReach = Math.max(radius * ARM_REACH_RADII, node.armLengthM)
const armStart = new Vector3(bendR * 2, bendR, 0)
const armEnd = new Vector3(bendR * 2 + armReach, bendR, 0)
const arm = buildSection(armStart, armEnd, radius, material, 'pipe-trap-arm')
if (arm) group.add(arm)
return group
}
/** Local-frame port positions (before position/yaw): inlet at the top
* of the riser facing +Y, outlet at the end of the arm facing +X. */
export function localTrapPorts(node: PipeTrapNode): {
inlet: Vector3
outlet: Vector3
} {
const radius = (node.diameter * INCHES_TO_METERS) / 2
const bendR = radius * 1.6
const inletDrop = radius * INLET_DROP_RADII
const armReach = Math.max(radius * ARM_REACH_RADII, node.armLengthM)
return {
inlet: new Vector3(0, bendR + inletDrop, 0),
outlet: new Vector3(bendR * 2 + armReach, bendR, 0),
}
}
+4
View File
@@ -0,0 +1,4 @@
export { pipeTrapDefinition } from './definition'
export { buildPipeTrapGeometry } from './geometry'
export { getPipeTrapPorts } from './ports'
export { PipeTrapNode } from './schema'
@@ -0,0 +1,19 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { PipeTrapNode } from './schema'
export const pipeTrapParametrics: ParametricDescriptor<PipeTrapNode> = {
groups: [
{
label: 'Trap',
fields: [
{ key: 'diameter', kind: 'number', unit: 'in', min: 1.25, max: 4, step: 0.25 },
{ key: 'pipeMaterial', kind: 'enum', options: ['pvc', 'abs', 'cast-iron'] },
{ key: 'armLengthM', kind: 'number', unit: 'm', min: 0, max: 4, step: 0.05 },
],
},
{
label: 'Placement',
fields: [{ key: 'position', kind: 'vec3' }],
},
],
}
+35
View File
@@ -0,0 +1,35 @@
import type { NodePort } from '@pascal-app/core'
import { Vector3 } from 'three'
import { localTrapPorts } from './geometry'
import type { PipeTrapNode } from './schema'
/**
* `def.ports` — the trap's inlet (up, to the fixture) and outlet (the
* trap arm, toward the vented waste line), transformed by position +
* yaw into level-local space. Both carry the trap diameter and the
* 'waste' system tag so the pipe tool and system graph treat them like
* any other DWV joint.
*/
export function getPipeTrapPorts(node: PipeTrapNode): NodePort[] {
const { inlet, outlet } = localTrapPorts(node)
const yaw = node.rotation
const offset = new Vector3(node.position[0], node.position[1], node.position[2])
const place = (local: Vector3, dir: Vector3): NodePort => {
const position = local
.clone()
.applyAxisAngle(new Vector3(0, 1, 0), yaw)
.add(offset)
const direction = dir
.clone()
.applyAxisAngle(new Vector3(0, 1, 0), yaw)
.normalize()
return {
id: local === inlet ? 'inlet' : 'outlet',
position: [position.x, position.y, position.z] as const,
direction: [direction.x, direction.y, direction.z] as const,
diameter: node.diameter,
system: 'waste',
}
}
return [place(inlet, new Vector3(0, 1, 0)), place(outlet, new Vector3(1, 0, 0))]
}

Some files were not shown because too many files have changed in this diff Show More