191 lines
5.7 KiB
TypeScript
191 lines
5.7 KiB
TypeScript
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
DuctSegmentNode,
|
|
type PortConnection,
|
|
} from '@pascal-app/core'
|
|
import { fittingLegLength } from '../duct-fitting/ports'
|
|
import type { DuctFittingNode } from '../duct-fitting/schema'
|
|
import {
|
|
type DuctProfile,
|
|
planElbowAtPort,
|
|
planElbowRealign,
|
|
profileDiameterIn,
|
|
} from './auto-fitting'
|
|
import type { ScenePort } from './ports'
|
|
|
|
type Point = [number, number, number]
|
|
|
|
const COINCIDENT_EPS_M = 0.05
|
|
const MIN_CONNECTOR_M = 0.05
|
|
|
|
export type RunTranslationOffsetPlan = {
|
|
ductPath: Point[]
|
|
fittings: DuctFittingNode[]
|
|
connectors: DuctSegmentNode[]
|
|
updates: { id: AnyNodeId; data: Partial<AnyNode> }[]
|
|
}
|
|
|
|
function distSq(a: Point | readonly number[], b: Point | readonly 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 sub(a: Point, b: Point): Point {
|
|
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
|
}
|
|
|
|
function neg(v: Point): Point {
|
|
return [-v[0], -v[1], -v[2]]
|
|
}
|
|
|
|
function unit(v: Point): Point | null {
|
|
const len = Math.hypot(v[0], v[1], v[2])
|
|
if (len < 1e-9) return null
|
|
return [v[0] / len, v[1] / len, v[2] / len]
|
|
}
|
|
|
|
function endpointOutwardDir(path: ReadonlyArray<readonly number[]>, idx: number): Point {
|
|
const last = path.length - 1
|
|
const [a, b] = idx === 0 ? [path[0]!, path[1]!] : [path[last]!, path[last - 1]!]
|
|
return unit([a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!]) ?? [1, 0, 0]
|
|
}
|
|
|
|
function portLike(position: Point, direction: Point, system: string): ScenePort {
|
|
return {
|
|
id: 'x',
|
|
nodeId: 'x' as AnyNodeId,
|
|
position,
|
|
direction,
|
|
diameter: 0,
|
|
system,
|
|
} as unknown as ScenePort
|
|
}
|
|
|
|
function connectorRun(from: Point, to: Point, duct: DuctSegmentNode): DuctSegmentNode {
|
|
return DuctSegmentNode.parse({
|
|
object: 'node',
|
|
parentId: null,
|
|
visible: true,
|
|
metadata: {},
|
|
name: duct.name ?? 'Duct run',
|
|
path: [from, to],
|
|
shape: duct.shape,
|
|
diameter: duct.diameter,
|
|
width: duct.width,
|
|
height: duct.height,
|
|
roll: duct.roll,
|
|
ductMaterial: duct.ductMaterial,
|
|
insulated: duct.insulated,
|
|
insulationR: duct.insulationR,
|
|
system: duct.system,
|
|
})
|
|
}
|
|
|
|
function elbowProfilePatch(profile: DuctProfile): Partial<DuctFittingNode> {
|
|
const diameter = profileDiameterIn(profile)
|
|
return {
|
|
shape: profile.shape,
|
|
width: profile.width,
|
|
height: profile.height,
|
|
diameter,
|
|
diameter2: diameter,
|
|
}
|
|
}
|
|
|
|
export function planRunTranslationOffsets(args: {
|
|
duct: DuctSegmentNode
|
|
translatedPath: Point[]
|
|
profile: DuctProfile
|
|
connections: PortConnection[]
|
|
scenePorts: ScenePort[]
|
|
nodesById: Record<string, AnyNode>
|
|
}): RunTranslationOffsetPlan | null {
|
|
const { duct, translatedPath, profile, connections, scenePorts, nodesById } = args
|
|
if (duct.path.length < 2 || translatedPath.length !== duct.path.length) return null
|
|
if (connections.length === 0) return null
|
|
|
|
const leg = fittingLegLength(profileDiameterIn(profile))
|
|
const minOffset = 2 * leg + MIN_CONNECTOR_M
|
|
const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M
|
|
const ductPath = translatedPath.map((p) => [...p] as Point)
|
|
const fittings: DuctFittingNode[] = []
|
|
const connectors: DuctSegmentNode[] = []
|
|
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
let routedAny = false
|
|
|
|
for (const endIdx of duct.path.length > 1 ? [0, duct.path.length - 1] : [0]) {
|
|
const startEnd = duct.path[endIdx]!
|
|
const movedEnd = translatedPath[endIdx]!
|
|
const delta = sub(movedEnd, startEnd)
|
|
const offsetDir = unit(delta)
|
|
if (!offsetDir || Math.hypot(delta[0], delta[1], delta[2]) < minOffset) continue
|
|
|
|
const partnerPort = scenePorts.find(
|
|
(sp) =>
|
|
distSq(sp.position, startEnd) <= eps2 &&
|
|
connections.some((conn) => conn.nodeId === sp.nodeId),
|
|
)
|
|
if (!partnerPort) continue
|
|
const conn = connections.find((c) => c.nodeId === partnerPort.nodeId)
|
|
if (!conn) continue
|
|
|
|
const ductPortDir = endpointOutwardDir(translatedPath, endIdx)
|
|
const top = planElbowAtPort(
|
|
portLike(movedEnd, ductPortDir, duct.system),
|
|
neg(offsetDir),
|
|
profile,
|
|
)
|
|
if (!top) return null
|
|
|
|
if (conn.kind === 'run') {
|
|
const bottom = planElbowAtPort(
|
|
portLike(
|
|
[startEnd[0], startEnd[1], startEnd[2]],
|
|
[partnerPort.direction[0], partnerPort.direction[1], partnerPort.direction[2]],
|
|
duct.system,
|
|
),
|
|
offsetDir,
|
|
profile,
|
|
)
|
|
if (!bottom) return null
|
|
fittings.push(bottom.fitting, top.fitting)
|
|
connectors.push(connectorRun(bottom.collarPoint, top.collarPoint, duct))
|
|
ductPath[endIdx] = top.trimmedPortPoint
|
|
|
|
const path = conn.startPath.map((p) => [...p] as Point)
|
|
const tip = path.findIndex((p) => distSq(p, startEnd) <= eps2)
|
|
if (tip !== -1) {
|
|
path[tip] = bottom.trimmedPortPoint
|
|
updates.push({ id: conn.nodeId, data: { path } as Partial<AnyNode> })
|
|
}
|
|
routedAny = true
|
|
continue
|
|
}
|
|
|
|
const partner = nodesById[conn.nodeId]
|
|
if (partner?.type !== 'duct-fitting') return null
|
|
const elbow = {
|
|
...(partner as DuctFittingNode),
|
|
...elbowProfilePatch(profile),
|
|
} as DuctFittingNode
|
|
if (elbow.fittingType !== 'elbow') return null
|
|
const realign = planElbowRealign(elbow, partnerPort.id, offsetDir)
|
|
if (!realign) return null
|
|
|
|
fittings.push(top.fitting)
|
|
connectors.push(connectorRun(realign.collarPoint, top.collarPoint, duct))
|
|
ductPath[endIdx] = top.trimmedPortPoint
|
|
updates.push({
|
|
id: elbow.id,
|
|
data: { ...elbowProfilePatch(profile), ...realign.update.data } as Partial<AnyNode>,
|
|
})
|
|
routedAny = true
|
|
}
|
|
|
|
if (!routedAny) return null
|
|
return { ductPath, fittings, connectors, updates }
|
|
}
|