272 lines
9.4 KiB
TypeScript
272 lines
9.4 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
DuctFittingNode,
|
|
DuctSegmentNode,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import { readAutoOffsetTag, withAutoOffsetTag } from '../shared/auto-offset-tag'
|
|
import { getDuctFittingPorts } from './ports'
|
|
|
|
let ductFittingParametrics: typeof import('./parametrics')['ductFittingParametrics']
|
|
|
|
type Point = [number, number, number]
|
|
|
|
function equivalentDiameterIn(widthIn: number, heightIn: number): number {
|
|
return 2 * Math.sqrt((widthIn * heightIn) / Math.PI)
|
|
}
|
|
|
|
function rectElbow() {
|
|
return DuctFittingNode.parse({
|
|
id: 'duct-fitting_resize' as AnyNodeId,
|
|
object: 'node',
|
|
parentId: null,
|
|
visible: true,
|
|
metadata: {},
|
|
name: 'Resize elbow',
|
|
fittingType: 'elbow',
|
|
shape: 'rect',
|
|
width: 14,
|
|
height: 8,
|
|
diameter: equivalentDiameterIn(14, 8),
|
|
diameter2: equivalentDiameterIn(14, 8),
|
|
ductMaterial: 'sheet-metal',
|
|
system: 'supply',
|
|
position: [0, 0, 0],
|
|
rotation: [0, 0, 0],
|
|
angle: 90,
|
|
})
|
|
}
|
|
|
|
function verticalRectRunFrom(point: Point, roll: number) {
|
|
return DuctSegmentNode.parse({
|
|
id: 'duct-segment_vertical' as AnyNodeId,
|
|
object: 'node',
|
|
parentId: null,
|
|
visible: true,
|
|
metadata: {},
|
|
name: 'Drawn vertical run',
|
|
path: [point, [point[0], point[1] + 3, point[2]]],
|
|
shape: 'rect',
|
|
width: 14,
|
|
height: 8,
|
|
diameter: equivalentDiameterIn(14, 8),
|
|
roll,
|
|
ductMaterial: 'sheet-metal',
|
|
insulationR: 0,
|
|
system: 'supply',
|
|
})
|
|
}
|
|
|
|
describe('ductFittingParametrics', () => {
|
|
beforeAll(async () => {
|
|
mock.module('@pascal-app/editor', () => ({
|
|
ActionButton: () => null,
|
|
}))
|
|
;({ ductFittingParametrics } = await import('./parametrics'))
|
|
})
|
|
|
|
beforeEach(() => {
|
|
useScene.setState({
|
|
nodes: {},
|
|
rootNodeIds: [],
|
|
dirtyNodes: new Set(),
|
|
collections: {},
|
|
readOnly: false,
|
|
} as never)
|
|
useScene.temporal.getState().clear()
|
|
})
|
|
|
|
test('resizing a fitting retrims connected ducts without changing their roll', () => {
|
|
const fitting = rectElbow()
|
|
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
|
const originalRoll = 0.37
|
|
const duct = verticalRectRunFrom([...outlet.position] as Point, originalRoll)
|
|
|
|
useScene.setState({
|
|
nodes: {
|
|
[fitting.id]: fitting as AnyNode,
|
|
[duct.id]: duct as AnyNode,
|
|
},
|
|
rootNodeIds: [fitting.id, duct.id],
|
|
dirtyNodes: new Set(),
|
|
collections: {},
|
|
readOnly: false,
|
|
} as never)
|
|
|
|
const patch = { width: 20 }
|
|
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
|
|
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
|
|
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
|
|
const ductUpdate = updates.find((u) => u.id === duct.id)
|
|
|
|
expect(ductUpdate).toBeDefined()
|
|
expect((ductUpdate?.data as Partial<DuctSegmentNode>).path).toBeDefined()
|
|
expect((ductUpdate?.data as Partial<DuctSegmentNode>).roll).toBeUndefined()
|
|
})
|
|
|
|
test('resizing a fitting refreshes a connected duct auto-offset base path', () => {
|
|
const fitting = rectElbow()
|
|
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
|
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
|
const taggedDuct = DuctSegmentNode.parse({
|
|
...duct,
|
|
metadata: withAutoOffsetTag(duct.metadata, {
|
|
group: 'aoff_resize',
|
|
dy: 1,
|
|
minted: ['duct-fitting_minted' as AnyNodeId],
|
|
base: [{ id: duct.id, data: { path: duct.path } }],
|
|
}),
|
|
})
|
|
|
|
useScene.setState({
|
|
nodes: {
|
|
[fitting.id]: fitting as AnyNode,
|
|
[taggedDuct.id]: taggedDuct as AnyNode,
|
|
},
|
|
rootNodeIds: [fitting.id, taggedDuct.id],
|
|
dirtyNodes: new Set(),
|
|
collections: {},
|
|
readOnly: false,
|
|
} as never)
|
|
|
|
const patch = { width: 20 }
|
|
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
|
|
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
|
|
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
|
|
const ductUpdate = updates.find((u) => u.id === taggedDuct.id)
|
|
const nextOutlet = getDuctFittingPorts(next).find((p) => p.id === 'outlet')!
|
|
const nextTag = readAutoOffsetTag({ metadata: ductUpdate?.data.metadata })
|
|
const basePath = nextTag?.base.find((b) => b.id === taggedDuct.id)?.data.path as
|
|
| Point[]
|
|
| undefined
|
|
|
|
expect(basePath?.[0]).toEqual([...nextOutlet.position])
|
|
})
|
|
|
|
test('deleting an elbow re-extends mated runs back onto the junction', () => {
|
|
const fitting = rectElbow()
|
|
const ports = getDuctFittingPorts(fitting)
|
|
const outlet = ports.find((p) => p.id === 'outlet')!
|
|
const inlet = ports.find((p) => p.id === 'inlet')!
|
|
// Two runs meeting the elbow's collars — the L-shape the elbow trimmed.
|
|
const outletRun = verticalRectRunFrom([...outlet.position] as Point, 0)
|
|
const inletRun = DuctSegmentNode.parse({
|
|
...verticalRectRunFrom([...inlet.position] as Point, 0),
|
|
id: 'duct-segment_inlet' as AnyNodeId,
|
|
path: [
|
|
[...inlet.position] as Point,
|
|
[inlet.position[0] - 3, inlet.position[1], inlet.position[2]],
|
|
],
|
|
})
|
|
|
|
const nodes: Record<AnyNodeId, AnyNode> = {
|
|
[fitting.id]: fitting as AnyNode,
|
|
[outletRun.id]: outletRun as AnyNode,
|
|
[inletRun.id]: inletRun as AnyNode,
|
|
}
|
|
|
|
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
|
const outletUpdate = updates.find((u) => u.id === outletRun.id)
|
|
const inletUpdate = updates.find((u) => u.id === inletRun.id)
|
|
|
|
// Both mated endpoints snap back to the junction (the original corner).
|
|
expect((outletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([
|
|
...fitting.position,
|
|
])
|
|
expect((inletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([...fitting.position])
|
|
})
|
|
|
|
test('delete repair matches the 5 cm live connectivity mate tolerance', () => {
|
|
const fitting = rectElbow()
|
|
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
|
const nearOutlet: Point = [outlet.position[0], outlet.position[1], outlet.position[2] + 0.04]
|
|
const outletRun = DuctSegmentNode.parse({
|
|
...verticalRectRunFrom(nearOutlet, 0),
|
|
id: 'duct-segment_outlet_gap' as AnyNodeId,
|
|
})
|
|
const nodes: Record<AnyNodeId, AnyNode> = {
|
|
[fitting.id]: fitting as AnyNode,
|
|
[outletRun.id]: outletRun as AnyNode,
|
|
}
|
|
|
|
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
|
const outletUpdate = updates.find((u) => u.id === outletRun.id)
|
|
|
|
expect((outletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([
|
|
...fitting.position,
|
|
])
|
|
})
|
|
|
|
test('deleting a generated elbow clears the owner duct auto-offset tag', () => {
|
|
const fitting = rectElbow()
|
|
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
|
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
|
const taggedDuct = DuctSegmentNode.parse({
|
|
...duct,
|
|
metadata: withAutoOffsetTag(duct.metadata, {
|
|
group: 'aoff_deleted_elbow',
|
|
dy: 1,
|
|
minted: [fitting.id],
|
|
base: [{ id: duct.id, data: { path: duct.path } }],
|
|
}),
|
|
})
|
|
const nodes: Record<AnyNodeId, AnyNode> = {
|
|
[fitting.id]: fitting as AnyNode,
|
|
[taggedDuct.id]: taggedDuct as AnyNode,
|
|
}
|
|
|
|
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
|
const finalDuctUpdate = updates.filter((u) => u.id === taggedDuct.id).at(-1)
|
|
|
|
expect(readAutoOffsetTag({ metadata: finalDuctUpdate?.data.metadata })).toBeNull()
|
|
})
|
|
|
|
test('deleting a tee leaves mated runs untouched', () => {
|
|
const tee = DuctFittingNode.parse({ ...rectElbow(), fittingType: 'tee' })
|
|
const outlet = getDuctFittingPorts(tee).find((p) => p.id === 'outlet')!
|
|
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
|
const nodes: Record<AnyNodeId, AnyNode> = {
|
|
[tee.id]: tee as AnyNode,
|
|
[duct.id]: duct as AnyNode,
|
|
}
|
|
|
|
expect(ductFittingParametrics.onDelete?.(tee, nodes) ?? []).toEqual([])
|
|
})
|
|
|
|
test('resizing a generated fitting clears the owner duct auto-offset tag', () => {
|
|
const fitting = rectElbow()
|
|
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
|
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
|
const taggedDuct = DuctSegmentNode.parse({
|
|
...duct,
|
|
metadata: withAutoOffsetTag(duct.metadata, {
|
|
group: 'aoff_generated_fit',
|
|
dy: 1,
|
|
minted: [fitting.id],
|
|
base: [{ id: duct.id, data: { path: duct.path } }],
|
|
}),
|
|
})
|
|
|
|
useScene.setState({
|
|
nodes: {
|
|
[fitting.id]: fitting as AnyNode,
|
|
[taggedDuct.id]: taggedDuct as AnyNode,
|
|
},
|
|
rootNodeIds: [fitting.id, taggedDuct.id],
|
|
dirtyNodes: new Set(),
|
|
collections: {},
|
|
readOnly: false,
|
|
} as never)
|
|
|
|
const patch = { width: 20 }
|
|
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
|
|
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
|
|
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
|
|
const finalDuctUpdate = updates.filter((u) => u.id === taggedDuct.id).at(-1)
|
|
|
|
expect(readAutoOffsetTag({ metadata: finalDuctUpdate?.data.metadata })).toBeNull()
|
|
})
|
|
})
|