930 lines
34 KiB
TypeScript
930 lines
34 KiB
TypeScript
import {
|
||
DuctFittingNode,
|
||
DuctSegmentNode,
|
||
PipeFittingNode,
|
||
PipeSegmentNode,
|
||
} from '@pascal-app/core'
|
||
import { Euler, Matrix4, Quaternion, Vector3 } from 'three'
|
||
import { fittingLegLength } from '../duct-fitting/ports'
|
||
import {
|
||
ductPortDiameterIn,
|
||
equivalentDiameterIn,
|
||
ovalEquivalentDiameterIn,
|
||
} from '../duct-segment/geometry'
|
||
import { pipeFittingLegLength } from '../pipe-fitting/ports'
|
||
import type { RunBodyHit, ScenePort } from './ports'
|
||
|
||
/** Turns shallower than this read as a straight continuation — butt-join
|
||
* the runs instead of minting a fitting. Matches the elbow schema's
|
||
* minimum angle so the planned fitting is always exactly buildable. */
|
||
const MIN_TURN_RAD = (15 * Math.PI) / 180
|
||
/** Elbows top out at 90°; anything sharper (doubling back) gets no
|
||
* fitting. Half a degree of slack absorbs float noise on right angles. */
|
||
const MAX_TURN_RAD = (90.5 * Math.PI) / 180
|
||
|
||
type Point = [number, number, number]
|
||
|
||
/** Cross-section a planned fitting (and the duct drawing it) carries. */
|
||
export type DuctProfile = {
|
||
shape: 'round' | 'rect' | 'oval'
|
||
/** Round size in inches (ignored for rect / oval — the equivalent is derived). */
|
||
diameter: number
|
||
/** Rect / oval profile in inches. */
|
||
width: number
|
||
height: number
|
||
}
|
||
|
||
/** Effective round-size (inches) a profile presents at joints. */
|
||
export function profileDiameterIn(profile: DuctProfile): number {
|
||
if (profile.shape === 'rect') {
|
||
return Math.min(48, equivalentDiameterIn(profile.width, profile.height))
|
||
}
|
||
if (profile.shape === 'oval') {
|
||
return Math.min(48, ovalEquivalentDiameterIn(profile.width, profile.height))
|
||
}
|
||
return profile.diameter
|
||
}
|
||
|
||
export type ElbowJointPlan = {
|
||
/** Parsed elbow node, its junction centered ON the drawn corner point,
|
||
* oriented so the inlet faces the existing run and the outlet faces
|
||
* the new one. */
|
||
fitting: DuctFittingNode
|
||
/** The elbow's outlet collar — where the new duct should start (or end)
|
||
* instead of the corner point, so duct meets metal instead of
|
||
* overlapping the fitting. */
|
||
collarPoint: Point
|
||
/** Where the EXISTING run's endpoint must move (pulled back one leg
|
||
* from the corner) so the elbow's inlet collar replaces that stretch
|
||
* of duct — keeping the visual corner exactly where it was drawn. */
|
||
trimmedPortPoint: Point
|
||
}
|
||
|
||
/** Orthonormal basis from a primary direction and a coplanar reference. */
|
||
function frame(primary: Vector3, reference: Vector3): Matrix4 | null {
|
||
const x = primary.clone().normalize()
|
||
const z = new Vector3().crossVectors(x, reference)
|
||
if (z.lengthSq() < 1e-10) return null
|
||
z.normalize()
|
||
const y = new Vector3().crossVectors(z, x)
|
||
return new Matrix4().makeBasis(x, y, z)
|
||
}
|
||
|
||
/**
|
||
* Plan the elbow that joins an existing run's open port to a new run
|
||
* leaving the joint along `awayDir`.
|
||
*
|
||
* Geometry: the elbow's local inlet faces -X and its outlet is turned
|
||
* `angle`° in the local XZ plane (see the duct-fitting schema). For a
|
||
* turn of θ between the port's outward direction and `awayDir`, an elbow
|
||
* with `angle = θ` mates both exactly; the rotation is whatever maps the
|
||
* local (inlet, outlet) direction pair onto the world (port, away) pair —
|
||
* which also covers vertical turns (horizontal run → riser), since the
|
||
* mapping is a full 3D rotation, not just yaw.
|
||
*
|
||
* Returns null when no fitting belongs at the joint: near-straight
|
||
* continuation (butt-join is fine), a back-turn sharper than 90°, or a
|
||
* degenerate direction pair.
|
||
*/
|
||
/**
|
||
* Domain-agnostic corner-joint math: where an elbow-shaped fitting (any
|
||
* kind whose local inlet faces -X with the outlet turned `angle`° in
|
||
* XZ) lands when joining `port` to a run leaving along `awayDir`, with
|
||
* legs of `legM` meters. The junction sits exactly ON the corner; the
|
||
* caller trims the existing run to `trimmedPortPoint` and starts the
|
||
* new one at `collarPoint`.
|
||
*/
|
||
export type CornerJointGeometry = {
|
||
angleDeg: number
|
||
rotation: Point
|
||
junction: Point
|
||
collarPoint: Point
|
||
trimmedPortPoint: Point
|
||
}
|
||
|
||
export function planCornerJoint(
|
||
port: Pick<ScenePort, 'position' | 'direction'>,
|
||
awayDir: Point,
|
||
legM: number,
|
||
): CornerJointGeometry | null {
|
||
const portDir = new Vector3(...port.direction).normalize()
|
||
const away = new Vector3(...awayDir).normalize()
|
||
if (portDir.lengthSq() < 1e-10 || away.lengthSq() < 1e-10) return null
|
||
|
||
const turn = portDir.angleTo(away)
|
||
if (turn < MIN_TURN_RAD || turn > MAX_TURN_RAD) return null
|
||
const angleDeg = Math.min(90, (turn * 180) / Math.PI)
|
||
|
||
// Rotation mapping the local pair onto the world pair: local +X (the
|
||
// inlet axis, flow direction) → portDir, local outlet → awayDir. Both
|
||
// pairs subtend the same angle, so a shared-plane basis transfer is
|
||
// exact — vertical turns included.
|
||
const outletLocal = new Vector3(Math.cos(turn), 0, Math.sin(turn))
|
||
const localFrame = frame(new Vector3(1, 0, 0), outletLocal)
|
||
const worldFrame = frame(portDir, away)
|
||
if (!localFrame || !worldFrame) return null
|
||
const rotation = new Quaternion().setFromRotationMatrix(
|
||
worldFrame.multiply(localFrame.transpose()),
|
||
)
|
||
const euler = new Euler().setFromQuaternion(rotation)
|
||
|
||
const junction = new Vector3(...port.position)
|
||
const collar = junction.clone().addScaledVector(away, legM)
|
||
const trimmed = junction.clone().addScaledVector(portDir, -legM)
|
||
|
||
return {
|
||
angleDeg,
|
||
rotation: [euler.x, euler.y, euler.z],
|
||
junction: [junction.x, junction.y, junction.z],
|
||
collarPoint: [collar.x, collar.y, collar.z],
|
||
trimmedPortPoint: [trimmed.x, trimmed.y, trimmed.z],
|
||
}
|
||
}
|
||
|
||
export function planElbowAtPort(
|
||
port: ScenePort,
|
||
awayDir: Point,
|
||
profile: DuctProfile,
|
||
): ElbowJointPlan | null {
|
||
const joint = planCornerJoint(port, awayDir, fittingLegLength(profileDiameterIn(profile)))
|
||
if (!joint) return null
|
||
|
||
const system = port.system === 'return' ? 'return' : 'supply'
|
||
// Built from the schema directly (defaults fill the rest) — importing
|
||
// the fitting's definition here would drag the editor package into the
|
||
// module graph, which test runners and non-editor embedders can't load.
|
||
const fitting = DuctFittingNode.parse({
|
||
object: 'node',
|
||
parentId: null,
|
||
visible: true,
|
||
metadata: {},
|
||
name: 'Elbow',
|
||
fittingType: 'elbow',
|
||
shape: profile.shape,
|
||
width: profile.width,
|
||
height: profile.height,
|
||
angle: joint.angleDeg,
|
||
diameter: profileDiameterIn(profile),
|
||
diameter2: profileDiameterIn(profile),
|
||
// Corner elbows are sheet metal even on flex runs (adjustable elbows).
|
||
ductMaterial: 'sheet-metal',
|
||
system,
|
||
position: joint.junction,
|
||
rotation: joint.rotation,
|
||
})
|
||
|
||
return {
|
||
fitting,
|
||
collarPoint: joint.collarPoint,
|
||
trimmedPortPoint: joint.trimmedPortPoint,
|
||
}
|
||
}
|
||
|
||
// ─── Tee taps (branch off a trunk's body) ────────────────────────────
|
||
|
||
export type TeeTapPlan = {
|
||
/** Parsed tee node, its junction centered ON the tap point, run legs
|
||
* along the trunk and branch collar toward the new run. */
|
||
fitting: DuctFittingNode
|
||
/** The tee's branch collar — where the new duct should start. */
|
||
branchCollar: Point
|
||
/** Trunk rewritten to END one run-leg before the tap point. */
|
||
trunkUpdate: { id: DuctSegmentNode['id']; data: { path: Point[] } }
|
||
/** New run carrying the rest of the trunk, starting one run-leg after
|
||
* the tap point. Created alongside the tee. */
|
||
trunkTail: DuctSegmentNode
|
||
}
|
||
|
||
/**
|
||
* Plan the tee that taps a branch off the SIDE of an existing run.
|
||
*
|
||
* The trunk is split at the tap point: the original node keeps the
|
||
* upstream half (trimmed one leg short), a new duct-segment node carries
|
||
* the downstream half (starting one leg after), and the tee's run legs
|
||
* bridge the gap with its junction exactly on the centerline hit. The
|
||
* branch collar follows `awayDir`: the tee becomes a lateral whose
|
||
* `branchAngle` (clamped to the buildable 45–135° range) matches the turn
|
||
* the drawn run makes off the trunk, so the new duct continues straight
|
||
* out of the collar instead of kinking square.
|
||
*
|
||
* Returns null when the tap can't be built: too close to the segment's
|
||
* ends (no room for the run legs — join the end port instead), or the
|
||
* branch direction is parallel to the trunk.
|
||
*/
|
||
export function planTeeAtRunBody(
|
||
trunk: DuctSegmentNode,
|
||
hit: RunBodyHit,
|
||
awayDir: Point,
|
||
branch: DuctProfile,
|
||
): TeeTapPlan | null {
|
||
const a = trunk.path[hit.segmentIndex]
|
||
const b = trunk.path[hit.segmentIndex + 1]
|
||
if (!a || !b) return null
|
||
const axis = new Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2])
|
||
if (axis.lengthSq() < 1e-10) return null
|
||
axis.normalize()
|
||
|
||
// The branch FOLLOWS the drawn run's angle: the tee becomes a lateral
|
||
// whose `branchAngle` matches the actual turn the new run makes off the
|
||
// trunk, instead of forcing a square tap and kinking the drawn duct.
|
||
// `branchDir` is the drawn direction's component square to the trunk —
|
||
// it sets the PLANE the branch leans in; the lean amount comes from how
|
||
// much of `away` runs along the trunk vs. across it.
|
||
const away = new Vector3(...awayDir).normalize()
|
||
if (away.lengthSq() < 1e-10) return null
|
||
const branchDir = away.clone().addScaledVector(axis, -away.dot(axis))
|
||
if (branchDir.lengthSq() < 1e-6) return null
|
||
branchDir.normalize()
|
||
|
||
// `branchAngle` is measured off the +X (outlet / downstream) axis in the
|
||
// tee's local XZ plane, where +Z is the branch's square direction. So
|
||
// the angle is atan2(across-trunk component, along-trunk component) of
|
||
// the drawn run — 90° when square, <90° leaning downstream, >90° leaning
|
||
// upstream. Clamped to the schema's buildable 45–135° lateral range.
|
||
const acrossLen = Math.sqrt(Math.max(0, 1 - away.dot(axis) ** 2))
|
||
const branchAngleDeg = Math.min(
|
||
135,
|
||
Math.max(45, (Math.atan2(acrossLen, away.dot(axis)) * 180) / Math.PI),
|
||
)
|
||
const phi = (branchAngleDeg * Math.PI) / 180
|
||
// Actual branch outward direction at the (possibly clamped) angle — the
|
||
// new run starts at its collar. When unclamped this equals `away`, so
|
||
// the drawn duct continues straight out of the tee.
|
||
const branchOutDir = axis
|
||
.clone()
|
||
.multiplyScalar(Math.cos(phi))
|
||
.addScaledVector(branchDir, Math.sin(phi))
|
||
.normalize()
|
||
|
||
// Room check: both run legs must fit inside the hit segment with a
|
||
// margin of real duct on each side.
|
||
// Rect trunks present their area-equivalent round size at joints
|
||
// (clamped to the fitting schema's 48" ceiling).
|
||
const trunkDiameterIn = Math.min(48, ductPortDiameterIn(trunk))
|
||
const branchDiameterIn = Math.min(48, profileDiameterIn(branch))
|
||
const legRun = fittingLegLength(trunkDiameterIn)
|
||
const legBranch = fittingLegLength(branchDiameterIn)
|
||
const P = new Vector3(...hit.point)
|
||
const upstream = P.distanceTo(new Vector3(...a))
|
||
const downstream = P.distanceTo(new Vector3(...b))
|
||
const MIN_STUB = 0.08
|
||
if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null
|
||
|
||
// Local +X (the run) → axis, local +Z (the branch plane) → branchDir.
|
||
// Both pairs are perpendicular, so the basis transfer is exact and the
|
||
// local branch leg (cos φ, sin φ) lands on `branchOutDir` in world.
|
||
const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1))
|
||
const worldFrame = frame(axis, branchDir)
|
||
if (!localFrame || !worldFrame) return null
|
||
const rotation = new Quaternion().setFromRotationMatrix(
|
||
worldFrame.multiply(localFrame.transpose()),
|
||
)
|
||
const euler = new Euler().setFromQuaternion(rotation)
|
||
|
||
const inletTrim = P.clone().addScaledVector(axis, -legRun)
|
||
const outletTrim = P.clone().addScaledVector(axis, legRun)
|
||
const collar = P.clone().addScaledVector(branchOutDir, legBranch)
|
||
|
||
const fitting = DuctFittingNode.parse({
|
||
object: 'node',
|
||
parentId: null,
|
||
visible: true,
|
||
metadata: {},
|
||
name: 'Tee',
|
||
fittingType: 'tee',
|
||
shape: trunk.shape,
|
||
width: trunk.width,
|
||
height: trunk.height,
|
||
diameter: trunkDiameterIn,
|
||
shape2: branch.shape,
|
||
width2: branch.width,
|
||
height2: branch.height,
|
||
diameter2: branchDiameterIn,
|
||
branchAngle: branchAngleDeg,
|
||
ductMaterial: 'sheet-metal',
|
||
system: trunk.system,
|
||
position: [P.x, P.y, P.z],
|
||
rotation: [euler.x, euler.y, euler.z],
|
||
})
|
||
|
||
// Split the polyline: original keeps the upstream points + the inlet
|
||
// trim; the tail node starts at the outlet trim and carries the rest.
|
||
const upstreamPath: Point[] = [
|
||
...trunk.path.slice(0, hit.segmentIndex + 1).map((p) => [...p] as Point),
|
||
[inletTrim.x, inletTrim.y, inletTrim.z],
|
||
]
|
||
const tailPath: Point[] = [
|
||
[outletTrim.x, outletTrim.y, outletTrim.z],
|
||
...trunk.path.slice(hit.segmentIndex + 1).map((p) => [...p] as Point),
|
||
]
|
||
|
||
const trunkTail = DuctSegmentNode.parse({
|
||
object: 'node',
|
||
parentId: null,
|
||
visible: true,
|
||
metadata: {},
|
||
name: trunk.name ?? 'Duct run',
|
||
path: tailPath,
|
||
shape: trunk.shape,
|
||
diameter: trunk.diameter,
|
||
width: trunk.width,
|
||
height: trunk.height,
|
||
roll: trunk.roll,
|
||
ductMaterial: trunk.ductMaterial,
|
||
insulated: trunk.insulated,
|
||
insulationR: trunk.insulationR,
|
||
system: trunk.system,
|
||
})
|
||
|
||
return {
|
||
fitting,
|
||
branchCollar: [collar.x, collar.y, collar.z],
|
||
trunkUpdate: { id: trunk.id, data: { path: upstreamPath } },
|
||
trunkTail,
|
||
}
|
||
}
|
||
|
||
// ─── Cross taps (drawn run passes THROUGH a trunk's body) ────────────
|
||
|
||
export type CrossTapPlan = {
|
||
/** Parsed cross node, junction ON the crossing point, run legs along
|
||
* the trunk and two opposed branch legs along the drawn run. */
|
||
fitting: DuctFittingNode
|
||
/** Branch collar on the START side of the drawn run — the first half
|
||
* of the drawn duct ENDS here. */
|
||
branchCollarNear: Point
|
||
/** Branch collar on the END side of the drawn run — the second half
|
||
* of the drawn duct STARTS here. */
|
||
branchCollarFar: Point
|
||
/** Trunk rewritten to END one run-leg before the crossing. */
|
||
trunkUpdate: { id: DuctSegmentNode['id']; data: { path: Point[] } }
|
||
/** New run carrying the rest of the trunk, starting one run-leg past
|
||
* the crossing. Created alongside the cross. */
|
||
trunkTail: DuctSegmentNode
|
||
}
|
||
|
||
/**
|
||
* Plan the four-way cross where a drawn run passes straight THROUGH the
|
||
* SIDE of an existing run. Like a tee tap, the trunk is split at the
|
||
* crossing (original keeps the upstream half, a new node carries the
|
||
* downstream half, both pulled one run-leg back). The drawn run is split
|
||
* by the CALLER into two halves that meet the cross's two opposed branch
|
||
* collars — `branchCollarNear` toward `awayDir`'s origin (the drawn
|
||
* start) and `branchCollarFar` along `awayDir` (the drawn end).
|
||
*
|
||
* `awayDir` is the drawn run's direction (start → end). Its component
|
||
* perpendicular to the trunk axis sets the branch axis; a drawn run that
|
||
* isn't square to the trunk still gets a square cross (the off-square
|
||
* lead-ins are absorbed by the drawn duct halves). Returns null when the
|
||
* crossing is too near a trunk end (no room for the run legs) or the
|
||
* drawn run is parallel to the trunk.
|
||
*/
|
||
export function planCrossAtRunBody(
|
||
trunk: DuctSegmentNode,
|
||
hit: RunBodyHit,
|
||
awayDir: Point,
|
||
branch: DuctProfile,
|
||
): CrossTapPlan | null {
|
||
const a = trunk.path[hit.segmentIndex]
|
||
const b = trunk.path[hit.segmentIndex + 1]
|
||
if (!a || !b) return null
|
||
const axis = new Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2])
|
||
if (axis.lengthSq() < 1e-10) return null
|
||
axis.normalize()
|
||
|
||
// Branch axis: the drawn direction projected square to the trunk.
|
||
const away = new Vector3(...awayDir)
|
||
const branchDir = away.clone().addScaledVector(axis, -away.dot(axis))
|
||
if (branchDir.lengthSq() < 1e-6) return null
|
||
branchDir.normalize()
|
||
|
||
const trunkDiameterIn = Math.min(48, ductPortDiameterIn(trunk))
|
||
const branchDiameterIn = Math.min(48, profileDiameterIn(branch))
|
||
const legRun = fittingLegLength(trunkDiameterIn)
|
||
const legBranch = fittingLegLength(branchDiameterIn)
|
||
const P = new Vector3(...hit.point)
|
||
const upstream = P.distanceTo(new Vector3(...a))
|
||
const downstream = P.distanceTo(new Vector3(...b))
|
||
const MIN_STUB = 0.08
|
||
if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null
|
||
|
||
// Local +X (the run) → axis, local +Z (the branch +Z leg) → branchDir.
|
||
const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1))
|
||
const worldFrame = frame(axis, branchDir)
|
||
if (!localFrame || !worldFrame) return null
|
||
const rotation = new Quaternion().setFromRotationMatrix(
|
||
worldFrame.multiply(localFrame.transpose()),
|
||
)
|
||
const euler = new Euler().setFromQuaternion(rotation)
|
||
|
||
const inletTrim = P.clone().addScaledVector(axis, -legRun)
|
||
const outletTrim = P.clone().addScaledVector(axis, legRun)
|
||
// +Z branch (`branch`) faces along branchDir = the drawn END side;
|
||
// -Z branch (`branch2`) faces the drawn START side.
|
||
const collarFar = P.clone().addScaledVector(branchDir, legBranch)
|
||
const collarNear = P.clone().addScaledVector(branchDir, -legBranch)
|
||
|
||
const fitting = DuctFittingNode.parse({
|
||
object: 'node',
|
||
parentId: null,
|
||
visible: true,
|
||
metadata: {},
|
||
name: 'Cross',
|
||
fittingType: 'cross',
|
||
shape: trunk.shape,
|
||
width: trunk.width,
|
||
height: trunk.height,
|
||
diameter: trunkDiameterIn,
|
||
shape2: branch.shape,
|
||
width2: branch.width,
|
||
height2: branch.height,
|
||
diameter2: branchDiameterIn,
|
||
ductMaterial: 'sheet-metal',
|
||
system: trunk.system,
|
||
position: [P.x, P.y, P.z],
|
||
rotation: [euler.x, euler.y, euler.z],
|
||
})
|
||
|
||
const upstreamPath: Point[] = [
|
||
...trunk.path.slice(0, hit.segmentIndex + 1).map((p) => [...p] as Point),
|
||
[inletTrim.x, inletTrim.y, inletTrim.z],
|
||
]
|
||
const tailPath: Point[] = [
|
||
[outletTrim.x, outletTrim.y, outletTrim.z],
|
||
...trunk.path.slice(hit.segmentIndex + 1).map((p) => [...p] as Point),
|
||
]
|
||
|
||
const trunkTail = DuctSegmentNode.parse({
|
||
object: 'node',
|
||
parentId: null,
|
||
visible: true,
|
||
metadata: {},
|
||
name: trunk.name ?? 'Duct run',
|
||
path: tailPath,
|
||
shape: trunk.shape,
|
||
diameter: trunk.diameter,
|
||
width: trunk.width,
|
||
height: trunk.height,
|
||
roll: trunk.roll,
|
||
ductMaterial: trunk.ductMaterial,
|
||
insulated: trunk.insulated,
|
||
insulationR: trunk.insulationR,
|
||
system: trunk.system,
|
||
})
|
||
|
||
return {
|
||
fitting,
|
||
branchCollarNear: [collarNear.x, collarNear.y, collarNear.z],
|
||
branchCollarFar: [collarFar.x, collarFar.y, collarFar.z],
|
||
trunkUpdate: { id: trunk.id, data: { path: upstreamPath } },
|
||
trunkTail,
|
||
}
|
||
}
|
||
|
||
// ─── Elbow realignment (run drawn onto an existing fitting's collar) ──
|
||
|
||
export type ElbowRealignPlan = {
|
||
/** Patch for the existing elbow: new turn angle + orientation. */
|
||
update: { id: DuctFittingNode['id']; data: { angle: number; rotation: Point } }
|
||
/** Where the free collar lands — the new duct starts (or ends) here. */
|
||
collarPoint: Point
|
||
}
|
||
|
||
export type PipeElbowRealignPlan = {
|
||
update: { id: PipeFittingNode['id']; data: { angle: number; rotation: Point } }
|
||
collarPoint: Point
|
||
}
|
||
|
||
/**
|
||
* Shared elbow re-aim geometry for duct AND pipe elbows — both share the
|
||
* exact same local convention (inlet -X, outlet turned `angle`° in XZ,
|
||
* 15–90° buildable range), so only the collar leg length differs.
|
||
*
|
||
* The junction stays put and the OTHER collar keeps its exact position +
|
||
* direction (it's mated to something), while the snapped collar swings to
|
||
* face `awayDir` — the elbow's `angle` adjusts to whatever turn that
|
||
* requires. Geometry: with the fixed collar's outward direction f and the
|
||
* desired free direction `awayDir`, the elbow's local inlet/outlet pair
|
||
* subtends 180° − angle, so the new turn is θ = 180° − ∠(f, away).
|
||
* Buildable only while θ stays in 15–90° — otherwise null.
|
||
*/
|
||
function planElbowRealignCore(
|
||
elbow: { fittingType: string; rotation: Point; angle: number; position: Point },
|
||
snappedPortId: string,
|
||
awayDir: Point,
|
||
leg: number,
|
||
): { angle: number; rotation: Point; collarPoint: Point } | null {
|
||
if (elbow.fittingType !== 'elbow') return null
|
||
if (snappedPortId !== 'inlet' && snappedPortId !== 'outlet') return null
|
||
|
||
const away = new Vector3(...awayDir)
|
||
if (away.lengthSq() < 1e-10) return null
|
||
away.normalize()
|
||
|
||
// Current world directions of both collars.
|
||
const currentRotation = new Quaternion().setFromEuler(
|
||
new Euler(elbow.rotation[0], elbow.rotation[1], elbow.rotation[2]),
|
||
)
|
||
const turnCur = (elbow.angle * Math.PI) / 180
|
||
const inletWorld = new Vector3(-1, 0, 0).applyQuaternion(currentRotation)
|
||
const outletWorld = new Vector3(Math.cos(turnCur), 0, Math.sin(turnCur)).applyQuaternion(
|
||
currentRotation,
|
||
)
|
||
const fixedWorld = snappedPortId === 'inlet' ? outletWorld : inletWorld
|
||
|
||
// New turn from the fixed collar / free collar pair. Unlike fresh-fitting
|
||
// creation (which butt-joins near-straight runs rather than minting a flat
|
||
// elbow), an EXISTING elbow may flatten all the way to 0° — a straight
|
||
// coupling — when its run is dragged into line, so only the upper bound
|
||
// guards here.
|
||
const spread = fixedWorld.angleTo(away)
|
||
const turnNew = Math.PI - spread
|
||
if (turnNew > MAX_TURN_RAD) return null
|
||
|
||
// Local outward pair at the new angle, ordered (fixed, free) to match
|
||
// the world pair.
|
||
const inletLocal = new Vector3(-1, 0, 0)
|
||
const outletLocal = new Vector3(Math.cos(turnNew), 0, Math.sin(turnNew))
|
||
const fixedLocal = snappedPortId === 'inlet' ? outletLocal : inletLocal
|
||
const freeLocal = snappedPortId === 'inlet' ? inletLocal : outletLocal
|
||
|
||
const localFrame = frame(fixedLocal, freeLocal)
|
||
const worldFrame = frame(fixedWorld, away)
|
||
// At (near-)straight the two collars are collinear, so the bend plane is
|
||
// undefined and `frame()` returns null. Map the fixed collar's local axis
|
||
// onto its world direction instead; the free collar (antiparallel) lands
|
||
// on `away` for free, and a straight coupling's roll is arbitrary.
|
||
const rotation =
|
||
localFrame && worldFrame
|
||
? new Quaternion().setFromRotationMatrix(worldFrame.multiply(localFrame.transpose()))
|
||
: new Quaternion().setFromUnitVectors(fixedLocal, fixedWorld)
|
||
const euler = new Euler().setFromQuaternion(rotation)
|
||
|
||
const collar = new Vector3(...elbow.position).addScaledVector(away, leg)
|
||
|
||
return {
|
||
angle: Math.max(0, Math.min(90, (turnNew * 180) / Math.PI)),
|
||
rotation: [euler.x, euler.y, euler.z],
|
||
collarPoint: [collar.x, collar.y, collar.z],
|
||
}
|
||
}
|
||
|
||
/** Re-aim a DUCT elbow whose open collar a new run just snapped onto. */
|
||
export function planElbowRealign(
|
||
elbow: DuctFittingNode,
|
||
snappedPortId: string,
|
||
awayDir: Point,
|
||
): ElbowRealignPlan | null {
|
||
const core = planElbowRealignCore(elbow, snappedPortId, awayDir, fittingLegLength(elbow.diameter))
|
||
if (!core) return null
|
||
return {
|
||
update: { id: elbow.id, data: { angle: core.angle, rotation: core.rotation } },
|
||
collarPoint: core.collarPoint,
|
||
}
|
||
}
|
||
|
||
/** Re-aim a DWV PIPE elbow — same geometry, pipe collar leg length. */
|
||
export function planPipeElbowRealign(
|
||
elbow: PipeFittingNode,
|
||
snappedPortId: string,
|
||
awayDir: Point,
|
||
): PipeElbowRealignPlan | null {
|
||
const core = planElbowRealignCore(
|
||
elbow,
|
||
snappedPortId,
|
||
awayDir,
|
||
pipeFittingLegLength(elbow.diameter),
|
||
)
|
||
if (!core) return null
|
||
return {
|
||
update: { id: elbow.id, data: { angle: core.angle, rotation: core.rotation } },
|
||
collarPoint: core.collarPoint,
|
||
}
|
||
}
|
||
|
||
// ─── Tee branch re-aim (run dragged off an existing tee's branch) ────
|
||
|
||
export type TeeBranchRealignPlan = {
|
||
/** Patch for the existing tee: new branch lean angle. The run axis and
|
||
* the tee's orientation stay fixed (inlet / outlet stay mated to the
|
||
* trunk) — only `branchAngle` changes. */
|
||
update: { id: DuctFittingNode['id']; data: { branchAngle: number } }
|
||
/** Where the branch collar lands at the new angle — the dragged run's
|
||
* mated end rides here. */
|
||
collarPoint: Point
|
||
}
|
||
|
||
/**
|
||
* Re-aim a duct TEE's branch to follow a run dragged off its branch collar.
|
||
*
|
||
* Unlike the elbow (which re-orients its whole body), a tee's run legs stay
|
||
* mated to the trunk, so the body orientation is FIXED: the branch can only
|
||
* swing within the tee's local XZ plane (local +X = run axis, +Z = the
|
||
* square branch direction). `awayDir` (junction → dragged end) is projected
|
||
* onto that plane and read as the lean angle off +X — 90° square, <90°
|
||
* leaning downstream toward the outlet, >90° upstream toward the inlet —
|
||
* clamped to the schema's buildable 45–135° lateral range.
|
||
*/
|
||
export function planTeeBranchRealign(
|
||
tee: DuctFittingNode,
|
||
awayDir: Point,
|
||
): TeeBranchRealignPlan | null {
|
||
if (tee.fittingType !== 'tee') return null
|
||
const away = new Vector3(...awayDir)
|
||
if (away.lengthSq() < 1e-10) return null
|
||
away.normalize()
|
||
|
||
const rot = new Quaternion().setFromEuler(
|
||
new Euler(tee.rotation[0], tee.rotation[1], tee.rotation[2]),
|
||
)
|
||
const runAxis = new Vector3(1, 0, 0).applyQuaternion(rot)
|
||
const squareDir = new Vector3(0, 0, 1).applyQuaternion(rot)
|
||
const ax = away.dot(runAxis)
|
||
const az = away.dot(squareDir)
|
||
// Drag straight along the run axis (no square component) leaves the lean
|
||
// undefined — hold the frame.
|
||
if (Math.abs(ax) < 1e-9 && Math.abs(az) < 1e-9) return null
|
||
|
||
const branchAngleDeg = Math.min(135, Math.max(45, (Math.atan2(az, ax) * 180) / Math.PI))
|
||
const phi = (branchAngleDeg * Math.PI) / 180
|
||
const branchDir = runAxis
|
||
.clone()
|
||
.multiplyScalar(Math.cos(phi))
|
||
.addScaledVector(squareDir, Math.sin(phi))
|
||
.normalize()
|
||
const collar = new Vector3(...tee.position).addScaledVector(
|
||
branchDir,
|
||
fittingLegLength(tee.diameter2),
|
||
)
|
||
|
||
return {
|
||
update: { id: tee.id, data: { branchAngle: branchAngleDeg } },
|
||
collarPoint: [collar.x, collar.y, collar.z],
|
||
}
|
||
}
|
||
|
||
// ─── DWV pipe joints ─────────────────────────────────────────────────
|
||
|
||
export type PipeElbowPlan = {
|
||
fitting: PipeFittingNode
|
||
collarPoint: Point
|
||
trimmedPortPoint: Point
|
||
}
|
||
|
||
/**
|
||
* Elbow (bend) joining an existing DWV run's open port to a new run —
|
||
* same corner geometry as the duct elbow, minted as a pipe fitting.
|
||
*/
|
||
export function planPipeElbowAtPort(
|
||
port: ScenePort,
|
||
awayDir: Point,
|
||
diameterIn: number,
|
||
pipeMaterial: PipeFittingNode['pipeMaterial'] = 'pvc',
|
||
): PipeElbowPlan | null {
|
||
const joint = planCornerJoint(port, awayDir, pipeFittingLegLength(diameterIn))
|
||
if (!joint) return null
|
||
|
||
const fitting = PipeFittingNode.parse({
|
||
object: 'node',
|
||
parentId: null,
|
||
visible: true,
|
||
metadata: {},
|
||
name: 'Bend',
|
||
fittingType: 'elbow',
|
||
angle: joint.angleDeg,
|
||
diameter: diameterIn,
|
||
diameter2: diameterIn,
|
||
pipeMaterial,
|
||
system: port.system === 'vent' ? 'vent' : 'waste',
|
||
position: joint.junction,
|
||
rotation: joint.rotation,
|
||
})
|
||
|
||
return {
|
||
fitting,
|
||
collarPoint: joint.collarPoint,
|
||
trimmedPortPoint: joint.trimmedPortPoint,
|
||
}
|
||
}
|
||
|
||
export type PipeBranchTapPlan = {
|
||
/** Parsed wye / sanitary tee, junction ON the tap point. */
|
||
fitting: PipeFittingNode
|
||
/** The branch collar — where the new run starts. */
|
||
branchCollar: Point
|
||
/** Tapped run rewritten to END one run-leg before the tap. */
|
||
runUpdate: { id: PipeSegmentNode['id']; data: { path: Point[] } }
|
||
/** New run carrying the rest of the tapped run. */
|
||
runTail: PipeSegmentNode
|
||
}
|
||
|
||
/**
|
||
* Plan the branch fitting that taps a new run into the SIDE of an
|
||
* existing DWV run — a **sanitary tee**: the branch enters SQUARE off the
|
||
* run (same T as the duct tee tap), facing the drawn branch's side.
|
||
*
|
||
* The run splits like a duct tee tap: original keeps the upstream half,
|
||
* a new node carries the downstream half, both trimmed one run-leg from
|
||
* the tap point.
|
||
*/
|
||
export function planPipeBranchTap(
|
||
run: PipeSegmentNode,
|
||
hit: RunBodyHit,
|
||
awayDir: Point,
|
||
branchDiameterIn: number,
|
||
): PipeBranchTapPlan | null {
|
||
const a = run.path[hit.segmentIndex]
|
||
const b = run.path[hit.segmentIndex + 1]
|
||
if (!a || !b) return null
|
||
const axis = new Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2])
|
||
if (axis.lengthSq() < 1e-10) return null
|
||
axis.normalize()
|
||
|
||
// Branch axis: the drawn direction projected square to the run, so the
|
||
// tee enters perpendicular regardless of the lead-in angle.
|
||
const away = new Vector3(...awayDir)
|
||
const branchDir = away.clone().addScaledVector(axis, -away.dot(axis))
|
||
if (branchDir.lengthSq() < 1e-6) return null
|
||
branchDir.normalize()
|
||
|
||
const legRun = pipeFittingLegLength(run.diameter)
|
||
const legBranch = pipeFittingLegLength(branchDiameterIn)
|
||
const P = new Vector3(...hit.point)
|
||
const upstream = P.distanceTo(new Vector3(...a))
|
||
const downstream = P.distanceTo(new Vector3(...b))
|
||
const MIN_STUB = 0.05
|
||
if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null
|
||
|
||
// Local +X (run) → axis, local +Z (branch) → branchDir. Both pairs are
|
||
// perpendicular, so the basis transfer is exact and the santee's square
|
||
// +Z branch lands on branchDir.
|
||
const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1))
|
||
const worldFrame = frame(axis, branchDir)
|
||
if (!localFrame || !worldFrame) return null
|
||
const rotation = new Quaternion().setFromRotationMatrix(
|
||
worldFrame.multiply(localFrame.transpose()),
|
||
)
|
||
const euler = new Euler().setFromQuaternion(rotation)
|
||
|
||
const inletTrim = P.clone().addScaledVector(axis, -legRun)
|
||
const outletTrim = P.clone().addScaledVector(axis, legRun)
|
||
const collar = P.clone().addScaledVector(branchDir, legBranch)
|
||
|
||
const fitting = PipeFittingNode.parse({
|
||
object: 'node',
|
||
parentId: null,
|
||
visible: true,
|
||
metadata: {},
|
||
name: 'Sanitary tee',
|
||
fittingType: 'sanitary-tee',
|
||
diameter: run.diameter,
|
||
diameter2: branchDiameterIn,
|
||
pipeMaterial: run.pipeMaterial,
|
||
system: run.system,
|
||
position: [P.x, P.y, P.z],
|
||
rotation: [euler.x, euler.y, euler.z],
|
||
})
|
||
|
||
const upstreamPath: Point[] = [
|
||
...run.path.slice(0, hit.segmentIndex + 1).map((p) => [...p] as Point),
|
||
[inletTrim.x, inletTrim.y, inletTrim.z],
|
||
]
|
||
const tailPath: Point[] = [
|
||
[outletTrim.x, outletTrim.y, outletTrim.z],
|
||
...run.path.slice(hit.segmentIndex + 1).map((p) => [...p] as Point),
|
||
]
|
||
|
||
const runTail = PipeSegmentNode.parse({
|
||
object: 'node',
|
||
parentId: null,
|
||
visible: true,
|
||
metadata: {},
|
||
name: run.name ?? 'Drain',
|
||
path: tailPath,
|
||
diameter: run.diameter,
|
||
pipeMaterial: run.pipeMaterial,
|
||
system: run.system,
|
||
})
|
||
|
||
return {
|
||
fitting,
|
||
branchCollar: [collar.x, collar.y, collar.z],
|
||
runUpdate: { id: run.id, data: { path: upstreamPath } },
|
||
runTail,
|
||
}
|
||
}
|
||
|
||
export type PipeCrossTapPlan = {
|
||
/** Parsed cross node, junction ON the crossing point, run legs along
|
||
* the run and two opposed branch legs along the drawn run. */
|
||
fitting: PipeFittingNode
|
||
/** Branch collar on the START side of the drawn run — the first half
|
||
* of the drawn pipe ENDS here. */
|
||
branchCollarNear: Point
|
||
/** Branch collar on the END side of the drawn run — the second half
|
||
* of the drawn pipe STARTS here. */
|
||
branchCollarFar: Point
|
||
/** Tapped run rewritten to END one run-leg before the crossing. */
|
||
runUpdate: { id: PipeSegmentNode['id']; data: { path: Point[] } }
|
||
/** New run carrying the rest of the tapped run. */
|
||
runTail: PipeSegmentNode
|
||
}
|
||
|
||
/**
|
||
* Plan the four-way DWV cross where a drawn run passes straight THROUGH
|
||
* the SIDE of an existing run — the pipe sibling of `planCrossAtRunBody`.
|
||
* The run splits at the crossing (original keeps the upstream half, a new
|
||
* node carries the downstream half, both pulled one run-leg back). The
|
||
* drawn run is split by the CALLER into two halves meeting the cross's
|
||
* opposed branch collars — `branchCollarNear` toward the drawn start,
|
||
* `branchCollarFar` along the drawn end. Returns null when the crossing
|
||
* is too near a run end or the drawn run is parallel to the run.
|
||
*/
|
||
export function planPipeCrossAtRunBody(
|
||
run: PipeSegmentNode,
|
||
hit: RunBodyHit,
|
||
awayDir: Point,
|
||
branchDiameterIn: number,
|
||
): PipeCrossTapPlan | null {
|
||
const a = run.path[hit.segmentIndex]
|
||
const b = run.path[hit.segmentIndex + 1]
|
||
if (!a || !b) return null
|
||
const axis = new Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2])
|
||
if (axis.lengthSq() < 1e-10) return null
|
||
axis.normalize()
|
||
|
||
// Branch axis: the drawn direction projected square to the run.
|
||
const away = new Vector3(...awayDir)
|
||
const branchDir = away.clone().addScaledVector(axis, -away.dot(axis))
|
||
if (branchDir.lengthSq() < 1e-6) return null
|
||
branchDir.normalize()
|
||
|
||
const legRun = pipeFittingLegLength(run.diameter)
|
||
const legBranch = pipeFittingLegLength(branchDiameterIn)
|
||
const P = new Vector3(...hit.point)
|
||
const upstream = P.distanceTo(new Vector3(...a))
|
||
const downstream = P.distanceTo(new Vector3(...b))
|
||
const MIN_STUB = 0.05
|
||
if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null
|
||
|
||
// Local +X (run) → axis, local +Z (the branch +Z leg) → branchDir.
|
||
const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1))
|
||
const worldFrame = frame(axis, branchDir)
|
||
if (!localFrame || !worldFrame) return null
|
||
const rotation = new Quaternion().setFromRotationMatrix(
|
||
worldFrame.multiply(localFrame.transpose()),
|
||
)
|
||
const euler = new Euler().setFromQuaternion(rotation)
|
||
|
||
const inletTrim = P.clone().addScaledVector(axis, -legRun)
|
||
const outletTrim = P.clone().addScaledVector(axis, legRun)
|
||
// +Z branch faces along branchDir = the drawn END side; -Z branch2
|
||
// faces the drawn START side.
|
||
const collarFar = P.clone().addScaledVector(branchDir, legBranch)
|
||
const collarNear = P.clone().addScaledVector(branchDir, -legBranch)
|
||
|
||
const fitting = PipeFittingNode.parse({
|
||
object: 'node',
|
||
parentId: null,
|
||
visible: true,
|
||
metadata: {},
|
||
name: 'Cross',
|
||
fittingType: 'cross',
|
||
diameter: run.diameter,
|
||
diameter2: branchDiameterIn,
|
||
pipeMaterial: run.pipeMaterial,
|
||
system: run.system,
|
||
position: [P.x, P.y, P.z],
|
||
rotation: [euler.x, euler.y, euler.z],
|
||
})
|
||
|
||
const upstreamPath: Point[] = [
|
||
...run.path.slice(0, hit.segmentIndex + 1).map((p) => [...p] as Point),
|
||
[inletTrim.x, inletTrim.y, inletTrim.z],
|
||
]
|
||
const tailPath: Point[] = [
|
||
[outletTrim.x, outletTrim.y, outletTrim.z],
|
||
...run.path.slice(hit.segmentIndex + 1).map((p) => [...p] as Point),
|
||
]
|
||
|
||
const runTail = PipeSegmentNode.parse({
|
||
object: 'node',
|
||
parentId: null,
|
||
visible: true,
|
||
metadata: {},
|
||
name: run.name ?? 'Drain',
|
||
path: tailPath,
|
||
diameter: run.diameter,
|
||
pipeMaterial: run.pipeMaterial,
|
||
system: run.system,
|
||
})
|
||
|
||
return {
|
||
fitting,
|
||
branchCollarNear: [collarNear.x, collarNear.y, collarNear.z],
|
||
branchCollarFar: [collarFar.x, collarFar.y, collarFar.z],
|
||
runUpdate: { id: run.id, data: { path: upstreamPath } },
|
||
runTail,
|
||
}
|
||
}
|