feat(installation): auto-explode unnamed assemblies

This commit is contained in:
WuYongJie 2026-08-26 18:29:15 +08:00
parent 879a73e480
commit 5da6c34758
30 changed files with 3852 additions and 196 deletions

View File

@ -23,7 +23,10 @@ import {
buildInstallationCameraViewKey,
captureInstallationCameraView,
} from "./lib/installationCameraView";
import { collectInstallationModelNodeSnapshots } from "./lib/installationModelGeometry";
import {
collectInstallationModelNodeSnapshots,
resetInstallationModelGeometry,
} from "./lib/installationModelGeometry";
import { normalizeInstallationStepDrafts } from "./lib/installationStepDraftBuilder";
import { buildInstallationStepMaterialShareSnapshotsByStepId } from "./lib/installationStepMaterialShareSnapshots";
import {
@ -1141,6 +1144,29 @@ export default function InstallationDemoWorkbenchClient({
[],
);
const resetExplosionGeometry = useCallback(() => {
partCatalog.resetAllPartTransforms();
const engine = previewEngineRef.current ?? previewEngine;
if (!engine) {
setModelNodeSnapshots([]);
return;
}
const nodeNames = resetInstallationModelGeometry(engine);
syncModelGeometry(engine, nodeNames);
}, [partCatalog, previewEngine, syncModelGeometry]);
const prepareModelReplacement = useCallback(async () => {
resetExplosionGeometry();
previewEngineRef.current = null;
setPreviewEngine(null);
setModelNodeNames([]);
setModelNodeSnapshots([]);
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
}, [resetExplosionGeometry]);
const selectNumberingPart = useCallback(
(partId: string) => {
numberingCatalog.setSelectedPartId(partId);
@ -1665,9 +1691,13 @@ export default function InstallationDemoWorkbenchClient({
const handleUploadModel = useCallback(
(file: File) => {
if (!catalog.access.canEdit || !selectedDrawing) return;
void upload.uploadModelForDrawing(selectedDrawing.id, file);
void upload.uploadModelForDrawing(
selectedDrawing.id,
file,
prepareModelReplacement,
);
},
[catalog.access.canEdit, selectedDrawing, upload],
[catalog.access.canEdit, prepareModelReplacement, selectedDrawing, upload],
);
const prepareNumberingSnapshotBatchForShare = useCallback(async (
@ -2567,11 +2597,6 @@ export default function InstallationDemoWorkbenchClient({
numberingCatalog.setSelectedPartId(null);
}}
onModelNodesChange={(nextNodeNames) => {
const previewEngine = previewEngineRef.current;
if (previewEngine) {
syncModelGeometry(previewEngine, nextNodeNames);
return;
}
setModelNodeNames(nextNodeNames);
setModelNodeSnapshots([]);
}}
@ -2972,7 +2997,7 @@ export default function InstallationDemoWorkbenchClient({
explosionSelectedNodeNames,
);
}}
onResetAllParts={partCatalog.resetAllPartTransforms}
onResetAllParts={resetExplosionGeometry}
onSelectNumberingPart={selectNumberingPart}
onNumberingCategoryChange={(nextCategory) => {
setNumberingCategory(nextCategory);

View File

@ -61,7 +61,7 @@ export function ExplosionEditorPanel({
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={!canEdit || parts.length === 0}
disabled={!canEdit}
onClick={onApplyAutoExplode}
className="flex h-8 items-center gap-1.5 rounded-[6px] bg-[#2d3335] px-3 text-[11px] font-black text-white transition disabled:opacity-35"
>

View File

@ -0,0 +1,79 @@
import { act, renderHook } from '@testing-library/react'
import { uploadInstallationModelFile } from '../lib/installationModelUpload'
import {
fetchInstallationDemoConfig,
saveInstallationDemoConfig,
} from '../lib/installationDemoConfigStorage'
import {
readStoredUploadedModels,
writeStoredUploadedModels,
} from '../lib/installationUploadedModelStorage'
import { useInstallationModelUploadController } from './useInstallationModelUploadController'
jest.mock('../lib/installationModelUpload', () => ({
uploadInstallationModelFile: jest.fn(),
}))
jest.mock('../lib/installationDemoConfigStorage', () => ({
fetchInstallationDemoConfig: jest.fn(),
saveInstallationDemoConfig: jest.fn(),
}))
jest.mock('../lib/installationUploadedModelStorage', () => ({
readStoredUploadedModels: jest.fn(),
writeStoredUploadedModels: jest.fn(),
}))
const mockedUploadInstallationModelFile = jest.mocked(
uploadInstallationModelFile,
)
const mockedFetchInstallationDemoConfig = jest.mocked(
fetchInstallationDemoConfig,
)
const mockedSaveInstallationDemoConfig = jest.mocked(
saveInstallationDemoConfig,
)
const mockedReadStoredUploadedModels = jest.mocked(readStoredUploadedModels)
const mockedWriteStoredUploadedModels = jest.mocked(writeStoredUploadedModels)
describe('useInstallationModelUploadController', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedReadStoredUploadedModels.mockReturnValue({})
mockedFetchInstallationDemoConfig.mockResolvedValue(null)
})
it('clears the previous model state before linking the uploaded model', async () => {
const order: string[] = []
mockedUploadInstallationModelFile.mockImplementation(async () => {
order.push('upload')
return {
fileName: 'replacement.obj',
modelUrl: '/api/storage/object?key=models/replacement.obj',
}
})
mockedSaveInstallationDemoConfig.mockImplementation(() => {
order.push('save')
})
const onBeforeModelLinked = jest.fn(async () => {
order.push('reset')
})
const { result } = renderHook(() =>
useInstallationModelUploadController(null, true),
)
await act(async () => {
await result.current.uploadModelForDrawing(
'drawing-1',
new File(['model'], 'replacement.obj'),
onBeforeModelLinked,
)
})
expect(order).toEqual(['upload', 'reset', 'save'])
expect(result.current.uploadedModels['drawing-1']).toMatchObject({
drawingId: 'drawing-1',
fileName: 'replacement.obj',
modelUrl: '/api/storage/object?key=models/replacement.obj',
})
expect(mockedWriteStoredUploadedModels).toHaveBeenCalled()
})
})

View File

@ -71,35 +71,43 @@ export function useInstallationModelUploadController(drawingId: string | null, c
}
}, [canEdit, drawingId, uploadedModels])
const uploadModelForDrawing = useCallback(async (drawingId: string, file: File) => {
if (!canEdit) return false
setUploadingDrawingId(drawingId)
setUploadMessage(null)
const uploadModelForDrawing = useCallback(
async (
drawingId: string,
file: File,
onBeforeModelLinked?: () => void | Promise<void>,
) => {
if (!canEdit) return false
setUploadingDrawingId(drawingId)
setUploadMessage(null)
try {
const uploadedModel = await uploadInstallationModelFile(file)
const modelRecord: InstallationUploadedModel = {
drawingId,
fileName: uploadedModel.fileName,
modelUrl: uploadedModel.modelUrl,
uploadedAt: new Date().toISOString(),
try {
const uploadedModel = await uploadInstallationModelFile(file)
await onBeforeModelLinked?.()
const modelRecord: InstallationUploadedModel = {
drawingId,
fileName: uploadedModel.fileName,
modelUrl: uploadedModel.modelUrl,
uploadedAt: new Date().toISOString(),
}
setUploadedModels((current) => ({
...current,
[drawingId]: modelRecord,
}))
saveInstallationDemoConfig(drawingId, {
uploadedModel: modelRecord as unknown as Record<string, unknown>,
})
setUploadMessage('Model uploaded and linked to the current drawing')
return true
} catch (error) {
setUploadMessage(error instanceof Error ? error.message : 'Model upload failed')
return false
} finally {
setUploadingDrawingId(null)
}
setUploadedModels((current) => ({
...current,
[drawingId]: modelRecord,
}))
saveInstallationDemoConfig(drawingId, {
uploadedModel: modelRecord as unknown as Record<string, unknown>,
})
setUploadMessage('Model uploaded and linked to the current drawing')
return true
} catch (error) {
setUploadMessage(error instanceof Error ? error.message : 'Model upload failed')
return false
} finally {
setUploadingDrawingId(null)
}
}, [canEdit])
},
[canEdit],
)
return {
uploadedModels,

View File

@ -18,9 +18,11 @@ import {
nextPartCode,
patchPartNodeTransform,
replacePartNodeNames,
resolvePartCatalogForAutoExplode,
resolvePartQuantityFromNodeNames,
} from '../lib/installationPartCatalogHelpers'
import {
clearStoredAutoExplodeTemplateForDrawing,
hasMeaningfulPartTransform,
readStoredAutoExplodeTemplateForDrawing,
writeStoredAutoExplodeTemplateForDrawing,
@ -312,6 +314,7 @@ export function useInstallationPartCatalog(
)
const resetAllPartTransforms = useCallback(() => {
clearStoredAutoExplodeTemplateForDrawing(drawingId)
replaceParts(
parts.map((part) => ({
...part,
@ -320,7 +323,7 @@ export function useInstallationPartCatalog(
nodeTransforms: {},
})),
)
}, [parts, replaceParts])
}, [drawingId, parts, replaceParts])
const resetPartTransformsByNodeNames = useCallback(
(nodeNames: string[]) => {
@ -344,10 +347,12 @@ export function useInstallationPartCatalog(
)
const applyAutoExplode = useCallback(() => {
const modelParts = parts.filter((part) => getPartNodeNames(part).length > 0)
const sourceParts = resolvePartCatalogForAutoExplode(parts, nodeSnapshots)
const modelParts = sourceParts.filter((part) => getPartNodeNames(part).length > 0)
if (modelParts.length === 0) return
const autoExplodeOffsets = buildAutoExplodeOffsetsForParts(modelParts, nodeSnapshots)
const autoExplodeNodeOffsets = buildAutoExplodeNodeOffsetsForParts(modelParts, nodeSnapshots)
const layoutParts: InstallationPartItem[] = parts.map((part): InstallationPartItem => {
const layoutParts: InstallationPartItem[] = sourceParts.map((part): InstallationPartItem => {
const modelIndex = modelParts.findIndex((item) => item.id === part.id)
if (modelIndex < 0) return part
const nextOffset = autoExplodeOffsets[modelIndex] ?? ([0, 0, 0] as InstallationVector3)

View File

@ -0,0 +1,55 @@
import type { InstallationModelNodeSnapshot } from '../types'
import {
buildInstallationAssemblyContactGraph,
getInstallationAssemblyContactNeighbors,
} from './installationAssemblyContactGraph'
function node(
nodeName: string,
center: [number, number, number],
size: [number, number, number],
): InstallationModelNodeSnapshot {
const half = size.map((value) => value / 2) as [number, number, number]
return {
nodeName,
center,
size,
min: [center[0] - half[0], center[1] - half[1], center[2] - half[2]],
max: [center[0] + half[0], center[1] + half[1], center[2] + half[2]],
}
}
describe('installationAssemblyContactGraph', () => {
it('connects a drawer bottom to its four walls without joining an unrelated panel', () => {
const graph = buildInstallationAssemblyContactGraph([
node('bottom', [0, 0, 0], [300, 4, 240]),
node('left', [-152, 40, 0], [4, 80, 240]),
node('right', [152, 40, 0], [4, 80, 240]),
node('front', [0, 40, 122], [300, 80, 4]),
node('back', [0, 40, -122], [300, 80, 4]),
node('unrelated', [500, 40, 0], [100, 80, 4]),
])
const neighbors = new Set(
getInstallationAssemblyContactNeighbors(graph, 'bottom').map(
({ node: neighbor }) => neighbor.nodeName,
),
)
expect(neighbors).toEqual(new Set(['left', 'right', 'front', 'back']))
})
it('keeps joint hardware connected to both structures it passes through', () => {
const graph = buildInstallationAssemblyContactGraph([
node('top', [0, 400, 0], [800, 30, 400]),
node('leg', [-300, 190, 0], [80, 400, 80]),
node('fastener', [-300, 390, 0], [10, 40, 10]),
])
const neighbors = new Set(
getInstallationAssemblyContactNeighbors(graph, 'fastener').map(
({ node: neighbor }) => neighbor.nodeName,
),
)
expect(neighbors).toEqual(new Set(['top', 'leg']))
})
})

View File

@ -0,0 +1,185 @@
import type {
InstallationModelNodeSnapshot,
InstallationVector3,
} from '../types'
export type InstallationAssemblyAxis = 0 | 1 | 2
export interface InstallationAssemblyBounds {
center: InstallationVector3
min: InstallationVector3
max: InstallationVector3
size: InstallationVector3
diagonal: number
}
export interface InstallationAssemblyContactEdge {
left: InstallationModelNodeSnapshot
right: InstallationModelNodeSnapshot
axis: InstallationAssemblyAxis
gap: number
overlap: number
}
export interface InstallationAssemblyContactGraph {
bounds: InstallationAssemblyBounds
edges: InstallationAssemblyContactEdge[]
edgesByNodeName: Map<string, InstallationAssemblyContactEdge[]>
}
const AXES: InstallationAssemblyAxis[] = [0, 1, 2]
export function calculateInstallationAssemblyBounds(
nodes: InstallationModelNodeSnapshot[],
): InstallationAssemblyBounds {
if (nodes.length === 0) {
return {
center: [0, 0, 0],
min: [0, 0, 0],
max: [0, 0, 0],
size: [1, 1, 1],
diagonal: Math.sqrt(3),
}
}
const min: InstallationVector3 = [...nodes[0].min]
const max: InstallationVector3 = [...nodes[0].max]
nodes.slice(1).forEach((node) => {
AXES.forEach((axis) => {
min[axis] = Math.min(min[axis], node.min[axis])
max[axis] = Math.max(max[axis], node.max[axis])
})
})
const size = AXES.map((axis) => Math.max(max[axis] - min[axis], 1)) as InstallationVector3
return {
min,
max,
size,
center: AXES.map((axis) => min[axis] + size[axis] / 2) as InstallationVector3,
diagonal: Math.hypot(size[0], size[1], size[2]),
}
}
function intervalGap(
leftMin: number,
leftMax: number,
rightMin: number,
rightMax: number,
) {
if (leftMax < rightMin) return rightMin - leftMax
if (rightMax < leftMin) return leftMin - rightMax
return 0
}
function intervalOverlap(
leftMin: number,
leftMax: number,
rightMin: number,
rightMax: number,
) {
return Math.max(0, Math.min(leftMax, rightMax) - Math.max(leftMin, rightMin))
}
function projectionOverlapRatio(
left: InstallationModelNodeSnapshot,
right: InstallationModelNodeSnapshot,
axis: InstallationAssemblyAxis,
) {
return (
intervalOverlap(left.min[axis], left.max[axis], right.min[axis], right.max[axis]) /
Math.max(Math.min(left.size[axis], right.size[axis]), 1)
)
}
function resolveContactEdge(
left: InstallationModelNodeSnapshot,
right: InstallationModelNodeSnapshot,
bounds: InstallationAssemblyBounds,
): InstallationAssemblyContactEdge | null {
const candidates = AXES.flatMap((axis) => {
const otherAxes = AXES.filter((candidate) => candidate !== axis)
const overlaps = otherAxes.map((otherAxis) =>
projectionOverlapRatio(left, right, otherAxis),
)
if (overlaps.some((overlap) => overlap < 0.12)) return []
const gap = intervalGap(
left.min[axis],
left.max[axis],
right.min[axis],
right.max[axis],
)
const tolerance = Math.max(
bounds.diagonal * 0.0035,
Math.min(left.size[axis], right.size[axis]) * 0.65,
1,
)
if (gap > tolerance) return []
const overlap = (overlaps[0] + overlaps[1]) / 2
return [{
left,
right,
axis,
gap,
overlap,
score: gap / tolerance + (1 - overlap),
}]
})
const best = candidates.sort((leftCandidate, rightCandidate) =>
leftCandidate.score - rightCandidate.score,
)[0]
return best
? {
left: best.left,
right: best.right,
axis: best.axis,
gap: best.gap,
overlap: best.overlap,
}
: null
}
export function buildInstallationAssemblyContactGraph(
nodes: InstallationModelNodeSnapshot[],
): InstallationAssemblyContactGraph {
const bounds = calculateInstallationAssemblyBounds(nodes)
const edges: InstallationAssemblyContactEdge[] = []
const edgesByNodeName = new Map<string, InstallationAssemblyContactEdge[]>()
nodes.forEach((node) => edgesByNodeName.set(node.nodeName, []))
nodes.forEach((left, leftIndex) => {
nodes.slice(leftIndex + 1).forEach((right) => {
const edge = resolveContactEdge(left, right, bounds)
if (!edge) return
edges.push(edge)
edgesByNodeName.get(left.nodeName)?.push(edge)
edgesByNodeName.get(right.nodeName)?.push(edge)
})
})
return { bounds, edges, edgesByNodeName }
}
export function getInstallationAssemblyContactNeighbors(
graph: InstallationAssemblyContactGraph,
nodeName: string,
) {
return (graph.edgesByNodeName.get(nodeName) ?? []).map((edge) => ({
edge,
node: edge.left.nodeName === nodeName ? edge.right : edge.left,
}))
}
export function resolveInstallationPanelNormalAxis(
node: InstallationModelNodeSnapshot,
): InstallationAssemblyAxis | null {
const dimensions = AXES.map((axis) => ({ axis, size: node.size[axis] }))
.sort((left, right) => left.size - right.size)
const smallest = dimensions[0]
const middle = dimensions[1]
if (!smallest || !middle) return null
return smallest.size <= Math.max(middle.size * 0.42, 1)
? smallest.axis
: null
}

View File

@ -3,38 +3,29 @@ import type {
InstallationPartItem,
InstallationVector3,
} from '../types'
import { buildUnnamedCabinetStructureOffsets } from './installationUnnamedStructureExplodePlanner'
import { buildUnnamedProductStructureOffsets } from './installationUnnamedProductStructurePlanner'
import {
buildInstallationHardwareOffset,
resolveInstallationHardwareClassification,
type InstallationExplodeBounds as BoundsLike,
type InstallationExplodeModelBounds as ModelBounds,
type InstallationExplodeNodeGeometry as NodeGeometry,
} from './installationHardwareExplodePlanner'
import {
buildInstallationAssemblyContactGraph,
getInstallationAssemblyContactNeighbors,
} from './installationAssemblyContactGraph'
type Axis = 0 | 1 | 2
type Direction = -1 | 1
type LayoutKind = 'side' | 'lateral' | 'vertical' | 'front' | 'back'
const ZERO_OFFSET: InstallationVector3 = [0, 0, 0]
const LATERAL_AXIS: Axis = 0
// ModelPreviewPanel converts Z-up OBJ/CAD assets into the platform's Y-up world
// before node bounds are collected. The planner always receives that final world space.
const VERTICAL_AXIS: Axis = 1
const DEPTH_AXIS: Axis = 2
const HARDWARE_REGEX =
/(screw|bolt|nut|washer|hardware|fastener|hinge|bracket|\u87ba\u4e1d|\u87ba\u6813|\u87ba\u6bcd|\u57ab\u7247|\u4e94\u91d1|\u56fa\u5b9a\u4ef6)/i
interface BoundsLike {
center: InstallationVector3
min: InstallationVector3
max: InstallationVector3
size: InstallationVector3
}
interface ModelBounds extends BoundsLike {
diagonal: number
}
interface NodeGeometry extends BoundsLike {
nodeName: string
part: InstallationPartItem
signature: string
isHardware: boolean
}
interface StructuralPlan {
geometry: NodeGeometry
kind: LayoutKind
@ -43,17 +34,6 @@ interface StructuralPlan {
offset: InstallationVector3
}
interface HardwareAnchor {
anchor: NodeGeometry
score: number
normalAxis: Axis
normalDirection: Direction
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum)
}
function getPartNodeNames(part: InstallationPartItem) {
return Array.from(
new Set(
@ -64,10 +44,6 @@ function getPartNodeNames(part: InstallationPartItem) {
)
}
function calculateVolume(bounds: BoundsLike) {
return bounds.size[0] * bounds.size[1] * bounds.size[2]
}
function isFlatOnAxis(geometry: BoundsLike, axis: Axis, ratio = 0.18) {
const otherAxes = ([0, 1, 2] as Axis[]).filter((item) => item !== axis)
const reference = Math.max(geometry.size[otherAxes[0]], geometry.size[otherAxes[1]], 1)
@ -102,23 +78,6 @@ function buildModelBounds(geometries: NodeGeometry[]): ModelBounds {
}
}
function isLikelyHardware(geometry: BoundsLike, model: ModelBounds) {
const dimensions = [...geometry.size].sort((left, right) => right - left)
const [largest = 0, middle = 0, smallest = 0] = dimensions
const shortestModelDimension = Math.max(Math.min(...model.size), 1)
const isThinMountingPlate =
largest <= Math.max(shortestModelDimension * 0.22, model.diagonal * 0.11) &&
middle <= Math.max(shortestModelDimension * 0.2, model.diagonal * 0.06) &&
smallest <= Math.max(shortestModelDimension * 0.045, model.diagonal * 0.02)
return (
(isThinMountingPlate ||
(largest <= Math.max(shortestModelDimension * 0.2, model.diagonal * 0.11) &&
middle <= Math.max(shortestModelDimension * 0.075, model.diagonal * 0.035) &&
smallest <= Math.max(shortestModelDimension * 0.045, model.diagonal * 0.02))) &&
calculateVolume(geometry) <= calculateVolume(model) * 0.0009
)
}
function createNodeGeometries(
parts: InstallationPartItem[],
snapshots: InstallationModelNodeSnapshot[],
@ -147,10 +106,7 @@ function createNodeGeometries(
const model = buildModelBounds(rawGeometries)
const geometries = rawGeometries.map((geometry) => ({
...geometry,
isHardware:
geometry.part.kind === 'hardware' ||
HARDWARE_REGEX.test(geometry.signature) ||
isLikelyHardware(geometry, model),
isHardware: resolveInstallationHardwareClassification(geometry, model),
}))
return { geometries, model }
@ -482,103 +438,6 @@ function buildDrawerOffsets(plans: StructuralPlan[], model: ModelBounds, frontDi
return planByNodeName
}
function resolveIntervalGap(
subjectMin: number,
subjectMax: number,
anchorMin: number,
anchorMax: number,
) {
if (subjectMax < anchorMin) return { gap: anchorMin - subjectMax, direction: -1 as Direction }
if (subjectMin > anchorMax) return { gap: subjectMin - anchorMax, direction: 1 as Direction }
return { gap: 0, direction: 0 as Direction | 0 }
}
function resolveHardwareAnchor(
hardware: NodeGeometry,
anchor: NodeGeometry,
model: ModelBounds,
): HardwareAnchor {
const candidates = ([0, 1, 2] as Axis[]).map((axis) => {
const otherAxes = ([0, 1, 2] as Axis[]).filter((item) => item !== axis)
const coverage = otherAxes.reduce<number>((total, otherAxis) => {
const overlap = resolveRangeOverlap(
hardware.min[otherAxis],
hardware.max[otherAxis],
anchor.min[otherAxis],
anchor.max[otherAxis],
)
return total + overlap / Math.max(Math.min(hardware.size[otherAxis], anchor.size[otherAxis]), 1)
}, 0) / otherAxes.length
const interval = resolveIntervalGap(
hardware.min[axis],
hardware.max[axis],
anchor.min[axis],
anchor.max[axis],
)
const direction =
interval.direction || (hardware.center[axis] >= anchor.center[axis] ? 1 : -1)
const normalizedGap = interval.gap / Math.max(model.size[axis], model.diagonal * 0.025, 1)
return {
axis,
direction: direction as Direction,
score: normalizedGap + (1 - clamp(coverage, 0, 1)) * 0.7,
}
})
const best = candidates.sort((left, right) => left.score - right.score)[0]
return {
anchor,
normalAxis: best.axis,
normalDirection: best.direction,
score: best.score,
}
}
function buildHardwareOffset(
hardware: NodeGeometry,
structuralParts: NodeGeometry[],
structuralOffsets: Map<string, InstallationVector3>,
model: ModelBounds,
): InstallationVector3 {
const anchors = structuralParts
.map((anchor) => resolveHardwareAnchor(hardware, anchor, model))
.sort((left, right) => left.score - right.score)
.slice(0, 2)
const first = anchors[0]
if (!first) return ZERO_OFFSET
const firstOffset = structuralOffsets.get(first.anchor.nodeName) ?? ZERO_OFFSET
const second = anchors[1]
const hasSecondAnchor =
second &&
second.normalAxis !== first.normalAxis &&
second.score <= first.score + 0.18
const gap = Math.min(
Math.max(hardware.size[first.normalAxis] * 0.55, model.diagonal * 0.004),
model.diagonal * 0.028,
)
if (hasSecondAnchor) {
const secondOffset = structuralOffsets.get(second.anchor.nodeName) ?? ZERO_OFFSET
const midpoint: InstallationVector3 = [
(firstOffset[0] + secondOffset[0]) / 2,
(firstOffset[1] + secondOffset[1]) / 2,
(firstOffset[2] + secondOffset[2]) / 2,
]
return addOffsets(
midpoint,
createAxisOffset(first.normalAxis, first.normalDirection * gap),
)
}
return addOffsets(
firstOffset,
createAxisOffset(first.normalAxis, first.normalDirection * gap),
)
}
export function buildInstallationAssemblyNodeOffsets(
parts: InstallationPartItem[],
nodeSnapshots: InstallationModelNodeSnapshot[],
@ -588,6 +447,57 @@ export function buildInstallationAssemblyNodeOffsets(
const structuralParts = geometries.filter((geometry) => !geometry.isHardware && geometry.part.kind !== 'tool')
if (structuralParts.length === 0) return new Map<string, InstallationVector3>()
const contactGraph = buildInstallationAssemblyContactGraph(geometries)
const structuralNames = new Set(structuralParts.map((geometry) => geometry.nodeName))
const connectedAnchorNames = (hardware: NodeGeometry) =>
new Set(
getInstallationAssemblyContactNeighbors(contactGraph, hardware.nodeName)
.map(({ node }) => node.nodeName)
.filter((nodeName) => structuralNames.has(nodeName)),
)
const cabinetOffsets = buildUnnamedCabinetStructureOffsets(structuralParts, geometries)
if (cabinetOffsets) {
geometries
.filter((geometry) => geometry.isHardware)
.forEach((hardware) => {
if (cabinetOffsets.has(hardware.nodeName)) return
cabinetOffsets.set(
hardware.nodeName,
buildInstallationHardwareOffset(
hardware,
structuralParts,
cabinetOffsets,
model,
connectedAnchorNames(hardware),
true,
),
)
})
return cabinetOffsets
}
const productOffsets = buildUnnamedProductStructureOffsets(
structuralParts.map((geometry) => geometry),
)
if (productOffsets) {
geometries
.filter((geometry) => geometry.isHardware)
.forEach((hardware) => {
if (productOffsets.has(hardware.nodeName)) return
productOffsets.set(
hardware.nodeName,
buildInstallationHardwareOffset(
hardware,
structuralParts,
productOffsets,
model,
connectedAnchorNames(hardware),
),
)
})
return productOffsets
}
const frontDirection = resolveFrontDirection(structuralParts, model)
const plans = buildStructuralPlans(structuralParts, model)
@ -600,7 +510,16 @@ export function buildInstallationAssemblyNodeOffsets(
geometries
.filter((geometry) => geometry.isHardware)
.forEach((hardware) => {
offsets.set(hardware.nodeName, buildHardwareOffset(hardware, structuralParts, offsets, model))
offsets.set(
hardware.nodeName,
buildInstallationHardwareOffset(
hardware,
structuralParts,
offsets,
model,
connectedAnchorNames(hardware),
),
)
})
planByNodeName.forEach((plan, nodeName) => {

View File

@ -125,6 +125,14 @@ export function writeStoredAutoExplodeTemplateForDrawing(
writeStoredAutoExplodeTemplates(current)
}
export function clearStoredAutoExplodeTemplateForDrawing(drawingId: string | null) {
if (!drawingId) return
const current = readStoredAutoExplodeTemplates()
if (!(drawingId in current)) return
delete current[drawingId]
writeStoredAutoExplodeTemplates(current)
}
export function applyStoredAutoExplodeTemplateToParts(
parts: InstallationPartItem[],
template: StoredAutoExplodeTemplateItem[],

View File

@ -0,0 +1,60 @@
import type { InstallationModelNodeSnapshot } from '../types'
import { applyInstallationConnectedPanelSeparation } from './installationConnectedPanelSeparationPlanner'
function node(
nodeName: string,
center: [number, number, number],
size: [number, number, number],
): InstallationModelNodeSnapshot {
const half = size.map((value) => value / 2) as [number, number, number]
return {
nodeName,
center,
size,
min: [center[0] - half[0], center[1] - half[1], center[2] - half[2]],
max: [center[0] + half[0], center[1] + half[1], center[2] + half[2]],
}
}
describe('applyInstallationConnectedPanelSeparation', () => {
it('moves segmented shelves away from the center divider without changing their row', () => {
const nodes = [
node('left-side', [-400, 300, 0], [20, 600, 300]),
node('center-divider', [0, 300, 0], [20, 600, 300]),
node('right-side', [400, 300, 0], [20, 600, 300]),
node('left-shelf', [-205, 220, 0], [370, 20, 280]),
node('right-shelf', [205, 220, 0], [370, 20, 280]),
]
const offsets = new Map<string, [number, number, number]>([
['left-shelf', [0, -60, 0]],
['right-shelf', [0, -60, 0]],
['center-divider', [0, 0, 0]],
])
applyInstallationConnectedPanelSeparation(nodes, offsets, {
gapByAxis: { 0: 32 },
})
expect(offsets.get('left-shelf')).toEqual([-32, -60, 0])
expect(offsets.get('right-shelf')).toEqual([32, -60, 0])
expect(offsets.get('center-divider')).toEqual([0, 0, 0])
})
it('does not pull a full-width deck sideways', () => {
const nodes = [
node('left-side', [-400, 300, 0], [20, 600, 300]),
node('center-divider', [0, 300, 0], [20, 600, 300]),
node('right-side', [400, 300, 0], [20, 600, 300]),
node('full-deck', [0, 220, 0], [780, 20, 280]),
]
const offsets = new Map<string, [number, number, number]>([
['full-deck', [0, 60, 0]],
])
applyInstallationConnectedPanelSeparation(nodes, offsets, {
gapByAxis: { 0: 32 },
})
expect(offsets.get('full-deck')).toEqual([0, 60, 0])
})
})

View File

@ -0,0 +1,102 @@
import type {
InstallationModelNodeSnapshot,
InstallationVector3,
} from '../types'
import {
buildInstallationAssemblyContactGraph,
resolveInstallationPanelNormalAxis,
} from './installationAssemblyContactGraph'
type Axis = 0 | 1 | 2
interface ConnectedPanelSeparationOptions {
gapByAxis?: Partial<Record<Axis, number>>
}
const LATERAL_AXIS: Axis = 0
const VERTICAL_AXIS: Axis = 1
function crossesReference(
node: InstallationModelNodeSnapshot,
axis: Axis,
reference: number,
) {
return node.min[axis] <= reference && node.max[axis] >= reference
}
function resolveSeparationGap(
axis: Axis,
size: InstallationVector3,
diagonal: number,
options: ConnectedPanelSeparationOptions,
) {
return (
options.gapByAxis?.[axis] ??
Math.max(size[axis] * 0.025, diagonal * 0.012)
)
}
/**
* Adds the second movement axis for segmented shelves. A shelf keeps its row
* offset, then leaves the center divider just enough to expose their joint.
*/
export function applyInstallationConnectedPanelSeparation(
nodes: InstallationModelNodeSnapshot[],
baseOffsets: Map<string, InstallationVector3>,
options: ConnectedPanelSeparationOptions = {},
) {
if (nodes.length === 0) return baseOffsets
const graph = buildInstallationAssemblyContactGraph(nodes)
const bounds = graph.bounds
const lateralGap = resolveSeparationGap(
LATERAL_AXIS,
bounds.size,
bounds.diagonal,
options,
)
const candidates = new Map<string, -1 | 1>()
graph.edges.forEach((edge) => {
if (edge.axis !== LATERAL_AXIS) return
const pairs = [
{ branch: edge.left, anchor: edge.right },
{ branch: edge.right, anchor: edge.left },
]
pairs.forEach(({ branch, anchor }) => {
if (
resolveInstallationPanelNormalAxis(branch) !== VERTICAL_AXIS ||
resolveInstallationPanelNormalAxis(anchor) !== LATERAL_AXIS
) {
return
}
if (
branch.size[LATERAL_AXIS] > bounds.size[LATERAL_AXIS] * 0.72 ||
crossesReference(branch, LATERAL_AXIS, bounds.center[LATERAL_AXIS])
) {
return
}
const signedBranchDistance =
branch.center[LATERAL_AXIS] - bounds.center[LATERAL_AXIS]
const signedAnchorDistance =
anchor.center[LATERAL_AXIS] - bounds.center[LATERAL_AXIS]
if (
Math.abs(signedBranchDistance) < bounds.size[LATERAL_AXIS] * 0.08 ||
Math.abs(signedAnchorDistance) >= Math.abs(signedBranchDistance)
) {
return
}
candidates.set(branch.nodeName, signedBranchDistance < 0 ? -1 : 1)
})
})
candidates.forEach((direction, nodeName) => {
const current = baseOffsets.get(nodeName) ?? [0, 0, 0]
baseOffsets.set(nodeName, [
current[LATERAL_AXIS] + direction * lateralGap,
current[VERTICAL_AXIS],
current[2],
])
})
return baseOffsets
}

View File

@ -0,0 +1,167 @@
import type { InstallationModelNodeSnapshot } from '../types'
import { buildInstallationDrawerAssemblyPlan } from './installationDrawerAssemblyPlanner'
function node(
nodeName: string,
center: [number, number, number],
size: [number, number, number],
): InstallationModelNodeSnapshot {
const half = size.map((value) => value / 2) as [number, number, number]
return {
nodeName,
center,
size,
min: [center[0] - half[0], center[1] - half[1], center[2] - half[2]],
max: [center[0] + half[0], center[1] + half[1], center[2] + half[2]],
}
}
function drawerNodes(prefix: string, centerX: number, centerY = 400) {
return [
node(`${prefix}-face`, [centerX, centerY + 8, 160], [320, 160, 12]),
node(`${prefix}-bottom`, [centerX, centerY - 40, 30], [294, 3, 260]),
node(`${prefix}-back`, [centerX, centerY + 8, -100], [306, 112, 12]),
node(`${prefix}-left`, [centerX - 148, centerY, 30], [12, 96, 250]),
node(`${prefix}-right`, [centerX + 148, centerY, 30], [12, 96, 250]),
]
}
const model = {
size: [1400, 1000, 340] as [number, number, number],
diagonal: Math.hypot(1400, 1000, 340),
}
function buildPlan(nodes: InstallationModelNodeSnapshot[]) {
return buildInstallationDrawerAssemblyPlan(
nodes,
{
model,
frontDirection: 1,
facadeTargetSignedDepth: 700,
},
)
}
describe('buildInstallationDrawerAssemblyPlan', () => {
it('uses the drawer bottom as the local reference for all four directions', () => {
const plan = buildPlan(drawerNodes('drawer', 0))
const bottom = plan.offsets.get('drawer-bottom') ?? [0, 0, 0]
const face = plan.offsets.get('drawer-face') ?? [0, 0, 0]
const back = plan.offsets.get('drawer-back') ?? [0, 0, 0]
const left = plan.offsets.get('drawer-left') ?? [0, 0, 0]
const right = plan.offsets.get('drawer-right') ?? [0, 0, 0]
expect(plan.groups).toHaveLength(1)
expect(face[2]).toBeGreaterThan(bottom[2])
expect(back[2]).toBeLessThan(bottom[2])
expect(left[0]).toBeLessThan(bottom[0])
expect(right[0]).toBeGreaterThan(bottom[0])
expect([face[1], back[1], left[1], right[1]]).toEqual([
bottom[1],
bottom[1],
bottom[1],
bottom[1],
])
})
it('keeps multiple drawers independent and increases their original spacing', () => {
const nodes = [
...drawerNodes('left', -340),
...drawerNodes('middle', 0),
...drawerNodes('right', 340),
]
const plan = buildPlan(nodes)
const finalBottomX = ['left', 'middle', 'right'].map((prefix) => {
const bottom = nodes.find((item) => item.nodeName === `${prefix}-bottom`)
return (bottom?.center[0] ?? 0) + (plan.offsets.get(`${prefix}-bottom`)?.[0] ?? 0)
})
expect(plan.groups).toHaveLength(3)
expect(finalBottomX[1] - finalBottomX[0]).toBeGreaterThan(340)
expect(finalBottomX[2] - finalBottomX[1]).toBeGreaterThan(340)
expect(plan.offsets.get('left-face')?.[0]).toBe(
plan.offsets.get('left-bottom')?.[0],
)
expect(plan.offsets.get('right-face')?.[0]).toBe(
plan.offsets.get('right-bottom')?.[0],
)
})
it('adds component spacing along the original vertical arrangement too', () => {
const nodes = [
...drawerNodes('lower', 0, 300),
...drawerNodes('upper', 0, 470),
]
const plan = buildPlan(nodes)
const lowerOffset = plan.offsets.get('lower-bottom')?.[1] ?? 0
const upperOffset = plan.offsets.get('upper-bottom')?.[1] ?? 0
expect(plan.groups).toHaveLength(2)
expect(lowerOffset).toBeLessThan(0)
expect(upperOffset).toBeGreaterThan(0)
})
it('matches each drawer bottom to the nearest facade even when nodes are shuffled', () => {
const left = drawerNodes('left', -200)
const right = drawerNodes('right', 200)
const nodes = [
right[0],
left[1],
left[0],
right[1],
right[2],
left[2],
left[3],
right[3],
right[4],
left[4],
]
const plan = buildPlan(nodes)
const groupByBottom = new Map(
plan.groups.map((group) => [group.bottom.nodeName, group]),
)
expect(groupByBottom.get('left-bottom')?.face.nodeName).toBe('left-face')
expect(groupByBottom.get('left-bottom')?.back.nodeName).toBe('left-back')
expect(groupByBottom.get('right-bottom')?.face.nodeName).toBe('right-face')
expect(groupByBottom.get('right-bottom')?.back.nodeName).toBe('right-back')
})
it('keeps adjacent exploded drawer envelopes from crossing', () => {
const nodes = [
...drawerNodes('left', -200),
...drawerNodes('right', 200),
]
const plan = buildPlan(nodes)
const finalLeftDrawerMax =
(nodes.find((item) => item.nodeName === 'left-right')?.max[0] ?? 0) +
(plan.offsets.get('left-right')?.[0] ?? 0)
const finalRightDrawerMin =
(nodes.find((item) => item.nodeName === 'right-left')?.min[0] ?? 0) +
(plan.offsets.get('right-left')?.[0] ?? 0)
expect(finalLeftDrawerMax).toBeLessThan(finalRightDrawerMin)
expect(finalRightDrawerMin - finalLeftDrawerMax).toBeGreaterThan(0)
})
it('discovers drawer members from contact topology without a facade prefilter', () => {
const nodes = [
...drawerNodes('drawer', 0),
node('unrelated-front-panel', [500, 400, 160], [360, 500, 12]),
node('unrelated-shelf', [500, 100, 0], [360, 12, 260]),
]
const plan = buildPlan(nodes)
expect(plan.groups).toHaveLength(1)
expect(plan.memberNames).toEqual(
new Set([
'drawer-face',
'drawer-bottom',
'drawer-back',
'drawer-left',
'drawer-right',
]),
)
expect(plan.offsets.has('unrelated-front-panel')).toBe(false)
})
})

View File

@ -0,0 +1,286 @@
import type {
InstallationModelNodeSnapshot,
InstallationVector3,
} from '../types'
import {
buildInstallationAssemblyContactGraph,
getInstallationAssemblyContactNeighbors,
resolveInstallationPanelNormalAxis,
type InstallationAssemblyContactGraph,
} from './installationAssemblyContactGraph'
type Axis = 0 | 1 | 2
type Direction = -1 | 1
const LATERAL_AXIS: Axis = 0
const VERTICAL_AXIS: Axis = 1
const DEPTH_AXIS: Axis = 2
export interface InstallationDrawerGroup {
face: InstallationModelNodeSnapshot
bottom: InstallationModelNodeSnapshot
back: InstallationModelNodeSnapshot
leftSide: InstallationModelNodeSnapshot
rightSide: InstallationModelNodeSnapshot
}
interface DrawerModelMetrics {
size: InstallationVector3
diagonal: number
}
interface DrawerAssemblyOptions {
model: DrawerModelMetrics
frontDirection: Direction
facadeTargetSignedDepth: number
}
export interface InstallationDrawerAssemblyPlan {
groups: InstallationDrawerGroup[]
memberNames: Set<string>
offsets: Map<string, InstallationVector3>
}
function findDrawerGroup(
bottom: InstallationModelNodeSnapshot,
candidates: InstallationModelNodeSnapshot[],
graph: InstallationAssemblyContactGraph,
model: DrawerModelMetrics,
frontDirection: Direction,
): InstallationDrawerGroup | null {
const candidateNames = new Set(candidates.map((candidate) => candidate.nodeName))
const nearby = getInstallationAssemblyContactNeighbors(graph, bottom.nodeName)
.map(({ node }) => node)
.filter((candidate) => candidateNames.has(candidate.nodeName))
const maximumWallHeight = Math.max(
bottom.size[DEPTH_AXIS] * 0.85,
model.diagonal * 0.025,
)
const sideWalls = nearby
.filter(
(candidate) =>
resolveInstallationPanelNormalAxis(candidate) === LATERAL_AXIS &&
candidate.size[DEPTH_AXIS] >= bottom.size[DEPTH_AXIS] * 0.72 &&
candidate.size[VERTICAL_AXIS] <= maximumWallHeight &&
candidate.center[VERTICAL_AXIS] >= bottom.center[VERTICAL_AXIS] &&
Math.abs(candidate.center[LATERAL_AXIS] - bottom.center[LATERAL_AXIS]) >=
bottom.size[LATERAL_AXIS] * 0.3,
)
.sort((left, right) => left.center[LATERAL_AXIS] - right.center[LATERAL_AXIS])
const leftSide = sideWalls.findLast(
(candidate) => candidate.center[LATERAL_AXIS] < bottom.center[LATERAL_AXIS],
)
const rightSide = sideWalls.find(
(candidate) => candidate.center[LATERAL_AXIS] > bottom.center[LATERAL_AXIS],
)
const depthWalls = nearby
.filter(
(candidate) =>
resolveInstallationPanelNormalAxis(candidate) === DEPTH_AXIS &&
candidate.size[LATERAL_AXIS] >= bottom.size[LATERAL_AXIS] * 0.88 &&
candidate.size[LATERAL_AXIS] <= bottom.size[LATERAL_AXIS] * 1.2 &&
Math.abs(candidate.center[LATERAL_AXIS] - bottom.center[LATERAL_AXIS]) <=
bottom.size[LATERAL_AXIS] * 0.18 &&
candidate.size[VERTICAL_AXIS] <= maximumWallHeight &&
candidate.center[VERTICAL_AXIS] >= bottom.center[VERTICAL_AXIS] &&
Math.abs(candidate.center[DEPTH_AXIS] - bottom.center[DEPTH_AXIS]) >=
bottom.size[DEPTH_AXIS] * 0.3,
)
.sort(
(left, right) =>
frontDirection * (right.center[DEPTH_AXIS] - left.center[DEPTH_AXIS]),
)
const face = depthWalls[0]
const back = depthWalls.find(
(candidate) =>
candidate.nodeName !== face?.nodeName &&
frontDirection * (face.center[DEPTH_AXIS] - candidate.center[DEPTH_AXIS]) >=
bottom.size[DEPTH_AXIS] * 0.55,
)
return face && leftSide && rightSide && back
? { face, bottom, back, leftSide, rightSide }
: null
}
function layerOffsets(
groups: InstallationDrawerGroup[],
axis: typeof LATERAL_AXIS | typeof VERTICAL_AXIS,
gap: number,
sideSpacing: number,
) {
if (groups.length < 2) return new Map<string, number>()
const referenceSpan =
groups.reduce((total, group) => total + group.bottom.size[axis], 0) / groups.length
const tolerance = Math.max(referenceSpan * 0.32, 1)
const layers = groups
.slice()
.sort((left, right) => left.bottom.center[axis] - right.bottom.center[axis])
.reduce<Array<{ center: number; groups: InstallationDrawerGroup[] }>>(
(result, group) => {
const matching = result.find(
(layer) => Math.abs(layer.center - group.bottom.center[axis]) <= tolerance,
)
if (matching) {
matching.groups.push(group)
matching.center =
matching.groups.reduce((total, item) => total + item.bottom.center[axis], 0) /
matching.groups.length
} else {
result.push({ center: group.bottom.center[axis], groups: [group] })
}
return result
},
[],
)
.sort((left, right) => left.center - right.center)
const layerIntervals = layers.map((layer) => {
const intervals = layer.groups.map((group) => {
const members = [
{ node: group.face, amount: 0 },
{ node: group.bottom, amount: 0 },
{ node: group.back, amount: 0 },
{
node: group.leftSide,
amount: axis === LATERAL_AXIS ? -sideSpacing : 0,
},
{
node: group.rightSide,
amount: axis === LATERAL_AXIS ? sideSpacing : 0,
},
]
return {
min: Math.min(...members.map(({ node, amount }) => node.min[axis] + amount)),
max: Math.max(...members.map(({ node, amount }) => node.max[axis] + amount)),
}
})
return {
...layer,
min: Math.min(...intervals.map((interval) => interval.min)),
max: Math.max(...intervals.map((interval) => interval.max)),
shift: 0,
}
})
layerIntervals.slice(1).forEach((layer, index) => {
const previous = layerIntervals[index]
layer.shift = Math.max(0, previous.max + previous.shift + gap - layer.min)
})
const recenter =
((layerIntervals[0]?.shift ?? 0) +
(layerIntervals[layerIntervals.length - 1]?.shift ?? 0)) /
2
const result = new Map<string, number>()
layerIntervals.forEach((layer) => {
const amount = layer.shift - recenter
layer.groups.forEach((group) => result.set(group.face.nodeName, amount))
})
return result
}
function addOffsets(
left: InstallationVector3,
right: InstallationVector3,
): InstallationVector3 {
return [left[0] + right[0], left[1] + right[1], left[2] + right[2]]
}
function resolveDrawerGroups(
structuralNodes: InstallationModelNodeSnapshot[],
options: DrawerAssemblyOptions,
) {
const graph = buildInstallationAssemblyContactGraph(structuralNodes)
const claimedNames = new Set<string>()
return structuralNodes
.filter(
(node) =>
resolveInstallationPanelNormalAxis(node) === VERTICAL_AXIS &&
node.size[VERTICAL_AXIS] <=
Math.max(Math.min(node.size[LATERAL_AXIS], node.size[DEPTH_AXIS]) * 0.12, 1) &&
node.size[LATERAL_AXIS] >= node.size[DEPTH_AXIS] * 0.55,
)
.slice()
.sort(
(left, right) =>
left.center[VERTICAL_AXIS] - right.center[VERTICAL_AXIS] ||
left.center[LATERAL_AXIS] - right.center[LATERAL_AXIS],
)
.flatMap((bottom) => {
const group = findDrawerGroup(
bottom,
structuralNodes.filter((node) => !claimedNames.has(node.nodeName)),
graph,
options.model,
options.frontDirection,
)
if (!group) return []
const names = [
group.face.nodeName,
group.bottom.nodeName,
group.back.nodeName,
group.leftSide.nodeName,
group.rightSide.nodeName,
]
if (names.some((name) => claimedNames.has(name))) return []
names.forEach((name) => claimedNames.add(name))
return [group]
})
}
export function buildInstallationDrawerAssemblyPlan(
structuralNodes: InstallationModelNodeSnapshot[],
options: DrawerAssemblyOptions,
): InstallationDrawerAssemblyPlan {
const { model, frontDirection, facadeTargetSignedDepth } = options
const groups = resolveDrawerGroups(structuralNodes, options)
const offsets = new Map<string, InstallationVector3>()
const memberNames = new Set<string>()
if (groups.length === 0) return { groups, memberNames, offsets }
const faceSpacing = Math.max(model.size[DEPTH_AXIS] * 0.18, model.diagonal * 0.035)
const rearSpacing = Math.max(model.size[DEPTH_AXIS] * 0.16, model.diagonal * 0.03)
const sideSpacing = Math.max(model.size[LATERAL_AXIS] * 0.035, model.diagonal * 0.018)
const componentGap = Math.max(model.diagonal * 0.012, 1)
const lateralOffsets = layerOffsets(
groups,
LATERAL_AXIS,
componentGap,
sideSpacing,
)
const verticalOffsets = layerOffsets(
groups,
VERTICAL_AXIS,
componentGap,
sideSpacing,
)
groups.forEach((group) => {
const base: InstallationVector3 = [
lateralOffsets.get(group.face.nodeName) ?? 0,
verticalOffsets.get(group.face.nodeName) ?? 0,
frontDirection *
(facadeTargetSignedDepth -
frontDirection * group.face.center[DEPTH_AXIS] -
faceSpacing),
]
const assignments: Array<[
InstallationModelNodeSnapshot,
InstallationVector3,
]> = [
[group.bottom, base],
[group.face, addOffsets(base, [0, 0, frontDirection * faceSpacing])],
[group.back, addOffsets(base, [0, 0, -frontDirection * rearSpacing])],
[group.leftSide, addOffsets(base, [-sideSpacing, 0, 0])],
[group.rightSide, addOffsets(base, [sideSpacing, 0, 0])],
]
assignments.forEach(([node, offset]) => {
memberNames.add(node.nodeName)
offsets.set(node.nodeName, offset)
})
})
return { groups, memberNames, offsets }
}

View File

@ -0,0 +1,251 @@
import type {
InstallationPartItem,
InstallationVector3,
} from '../types'
export type InstallationExplodeAxis = 0 | 1 | 2
type Direction = -1 | 1
export interface InstallationExplodeBounds {
center: InstallationVector3
min: InstallationVector3
max: InstallationVector3
size: InstallationVector3
}
export interface InstallationExplodeModelBounds extends InstallationExplodeBounds {
diagonal: number
}
export interface InstallationExplodeNodeGeometry extends InstallationExplodeBounds {
nodeName: string
part: InstallationPartItem
signature: string
isHardware: boolean
}
interface HardwareAnchor {
anchor: InstallationExplodeNodeGeometry
score: number
normalAxis: InstallationExplodeAxis
normalDirection: Direction
}
const ZERO_OFFSET: InstallationVector3 = [0, 0, 0]
const HARDWARE_REGEX =
/(screw|bolt|nut|washer|hardware|fastener|hinge|bracket|\u87ba\u4e1d|\u87ba\u6813|\u87ba\u6bcd|\u57ab\u7247|\u4e94\u91d1|\u56fa\u5b9a\u4ef6)/i
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum)
}
function calculateVolume(bounds: InstallationExplodeBounds) {
return bounds.size[0] * bounds.size[1] * bounds.size[2]
}
function isLikelyHardware(
geometry: InstallationExplodeBounds,
model: InstallationExplodeModelBounds,
) {
const dimensions = [...geometry.size].sort((left, right) => right - left)
const [largest = 0, middle = 0, smallest = 0] = dimensions
const shortestModelDimension = Math.max(Math.min(...model.size), 1)
const isThinMountingPlate =
largest <= Math.max(shortestModelDimension * 0.22, model.diagonal * 0.11) &&
middle <= Math.max(shortestModelDimension * 0.2, model.diagonal * 0.06) &&
smallest <= Math.max(shortestModelDimension * 0.045, model.diagonal * 0.02)
return (
(isThinMountingPlate ||
(largest <= Math.max(shortestModelDimension * 0.2, model.diagonal * 0.11) &&
middle <= Math.max(shortestModelDimension * 0.075, model.diagonal * 0.035) &&
smallest <= Math.max(shortestModelDimension * 0.045, model.diagonal * 0.02))) &&
calculateVolume(geometry) <= calculateVolume(model) * 0.0009
)
}
export function resolveInstallationHardwareClassification(
geometry: InstallationExplodeNodeGeometry,
model: InstallationExplodeModelBounds,
) {
return (
geometry.part.kind === 'hardware' ||
HARDWARE_REGEX.test(geometry.signature) ||
isLikelyHardware(geometry, model)
)
}
function resolveRangeOverlap(
leftMin: number,
leftMax: number,
rightMin: number,
rightMax: number,
) {
return Math.max(0, Math.min(leftMax, rightMax) - Math.max(leftMin, rightMin))
}
function resolveIntervalGap(
subjectMin: number,
subjectMax: number,
anchorMin: number,
anchorMax: number,
) {
if (subjectMax < anchorMin) {
return { gap: anchorMin - subjectMax, direction: -1 as Direction }
}
if (subjectMin > anchorMax) {
return { gap: subjectMin - anchorMax, direction: 1 as Direction }
}
return { gap: 0, direction: 0 as Direction | 0 }
}
function resolveHardwareAxis(
hardware: InstallationExplodeNodeGeometry,
): InstallationExplodeAxis | null {
const dimensions = hardware.size
.map((size, axis) => ({ axis: axis as InstallationExplodeAxis, size }))
.sort((left, right) => right.size - left.size)
const longest = dimensions[0]
const middle = dimensions[1]
if (!longest || !middle || longest.size < Math.max(middle.size * 1.35, 1)) {
return null
}
return longest.axis
}
function resolveHardwareAnchor(
hardware: InstallationExplodeNodeGeometry,
anchor: InstallationExplodeNodeGeometry,
model: InstallationExplodeModelBounds,
preferredAxis: InstallationExplodeAxis | null,
): HardwareAnchor {
const axes =
preferredAxis === null
? ([0, 1, 2] as InstallationExplodeAxis[])
: [preferredAxis]
const candidates = axes.map((axis) => {
const otherAxes = ([0, 1, 2] as InstallationExplodeAxis[]).filter(
(item) => item !== axis,
)
const coverage =
otherAxes.reduce<number>((total, otherAxis) => {
const overlap = resolveRangeOverlap(
hardware.min[otherAxis],
hardware.max[otherAxis],
anchor.min[otherAxis],
anchor.max[otherAxis],
)
return (
total +
overlap /
Math.max(
Math.min(hardware.size[otherAxis], anchor.size[otherAxis]),
1,
)
)
}, 0) / otherAxes.length
const interval = resolveIntervalGap(
hardware.min[axis],
hardware.max[axis],
anchor.min[axis],
anchor.max[axis],
)
const direction =
interval.direction || (hardware.center[axis] >= anchor.center[axis] ? 1 : -1)
const normalizedGap =
interval.gap / Math.max(model.size[axis], model.diagonal * 0.025, 1)
return {
axis,
direction: direction as Direction,
score: normalizedGap + (1 - clamp(coverage, 0, 1)) * 0.7,
}
})
const best = candidates.sort((left, right) => left.score - right.score)[0]
return {
anchor,
normalAxis: best.axis,
normalDirection: best.direction,
score: best.score,
}
}
function createAxisOffset(
axis: InstallationExplodeAxis,
amount: number,
): InstallationVector3 {
const offset: InstallationVector3 = [0, 0, 0]
offset[axis] = amount
return offset
}
function addOffsets(
left: InstallationVector3,
right: InstallationVector3,
): InstallationVector3 {
return [left[0] + right[0], left[1] + right[1], left[2] + right[2]]
}
export function buildInstallationHardwareOffset(
hardware: InstallationExplodeNodeGeometry,
structuralParts: InstallationExplodeNodeGeometry[],
structuralOffsets: Map<string, InstallationVector3>,
model: InstallationExplodeModelBounds,
connectedAnchorNames: Set<string> = new Set(),
bridgeSeparatedVerticalAnchors = false,
): InstallationVector3 {
const preferredAxis = resolveHardwareAxis(hardware)
const connectedAnchors = structuralParts.filter((anchor) =>
connectedAnchorNames.has(anchor.nodeName),
)
const anchorPool = connectedAnchors.length > 0 ? connectedAnchors : structuralParts
const anchors = anchorPool
.map((anchor) => resolveHardwareAnchor(hardware, anchor, model, preferredAxis))
.sort(
(left, right) =>
left.score - right.score ||
calculateVolume(left.anchor) - calculateVolume(right.anchor),
)
const first = anchors[0]
if (!first) return ZERO_OFFSET
const firstOffset = structuralOffsets.get(first.anchor.nodeName) ?? ZERO_OFFSET
const second = anchors.find(
(candidate) =>
candidate.anchor.nodeName !== first.anchor.nodeName &&
candidate.normalAxis === first.normalAxis &&
candidate.normalDirection !== first.normalDirection,
)
if (second && bridgeSeparatedVerticalAnchors && first.normalAxis === 1) {
const secondOffset = structuralOffsets.get(second.anchor.nodeName) ?? ZERO_OFFSET
const anchorsSeparate =
Math.abs(firstOffset[first.normalAxis] - secondOffset[first.normalAxis]) >
model.diagonal * 0.002
if (anchorsSeparate) {
return [
(firstOffset[0] + secondOffset[0]) / 2,
(firstOffset[1] + secondOffset[1]) / 2,
(firstOffset[2] + secondOffset[2]) / 2,
]
}
}
const separationDirection =
second &&
Math.abs(
second.anchor.center[first.normalAxis] -
first.anchor.center[first.normalAxis],
) > Math.max(hardware.size[first.normalAxis], model.diagonal * 0.003)
? first.anchor.center[first.normalAxis] <
second.anchor.center[first.normalAxis]
? -1
: 1
: first.normalDirection
const gap = Math.min(
Math.max(hardware.size[first.normalAxis] * 0.55, model.diagonal * 0.004),
model.diagonal * 0.028,
)
return addOffsets(
firstOffset,
createAxisOffset(first.normalAxis, separationDirection * gap),
)
}

View File

@ -0,0 +1,58 @@
import type { InstallationModelNodeSnapshot } from '../types'
import { buildInstallationLocalHorizontalPartitionPlan } from './installationLocalHorizontalPartitionPlanner'
function node(
nodeName: string,
center: [number, number, number],
size: [number, number, number],
): InstallationModelNodeSnapshot {
const half = size.map((value) => value / 2) as [number, number, number]
return {
nodeName,
center,
size,
min: [center[0] - half[0], center[1] - half[1], center[2] - half[2]],
max: [center[0] + half[0], center[1] + half[1], center[2] + half[2]],
}
}
describe('buildInstallationLocalHorizontalPartitionPlan', () => {
it('spreads a same-height short-partition row laterally around its reference', () => {
const plan = buildInstallationLocalHorizontalPartitionPlan(
[
node('left', [-395.5, 900, 0], [12, 130, 314]),
node('middle', [0, 900, 0], [12, 130, 314]),
node('right', [395.5, 900, 0], [12, 130, 314]),
node('unrelated', [0, 600, 0], [12, 130, 314]),
],
{
center: [0, 580, 0],
size: [1384, 1160, 340],
diagonal: Math.hypot(1384, 1160, 340),
},
120,
)
expect(plan.offsets.get('left')).toEqual([-120, 0, 0])
expect(plan.offsets.get('middle')).toEqual([0, 0, 0])
expect(plan.offsets.get('right')).toEqual([120, 0, 0])
expect(plan.offsets.has('unrelated')).toBe(false)
})
it('does not reinterpret full-height side panels as a local row', () => {
const plan = buildInstallationLocalHorizontalPartitionPlan(
[
node('left-side', [-395, 580, 0], [12, 900, 314]),
node('right-side', [395, 580, 0], [12, 900, 314]),
],
{
center: [0, 580, 0],
size: [1384, 1160, 340],
diagonal: Math.hypot(1384, 1160, 340),
},
120,
)
expect(plan.offsets.size).toBe(0)
})
})

View File

@ -0,0 +1,192 @@
import type {
InstallationModelNodeSnapshot,
InstallationVector3,
} from '../types'
import { resolveInstallationPanelNormalAxis } from './installationAssemblyContactGraph'
interface LocalHorizontalPartitionMetrics {
center: InstallationVector3
size: InstallationVector3
diagonal: number
}
interface PartitionRow {
members: InstallationModelNodeSnapshot[]
centerY: number
centerZ: number
height: number
depth: number
}
export interface LocalHorizontalPartitionPlan {
memberNames: Set<string>
offsets: Map<string, InstallationVector3>
}
const LATERAL_AXIS = 0
const VERTICAL_AXIS = 1
const DEPTH_AXIS = 2
function intervalOverlap(
leftMin: number,
leftMax: number,
rightMin: number,
rightMax: number,
) {
return Math.max(0, Math.min(leftMax, rightMax) - Math.max(leftMin, rightMin))
}
function depthOverlapRatio(
left: InstallationModelNodeSnapshot,
right: InstallationModelNodeSnapshot,
) {
return (
intervalOverlap(
left.min[DEPTH_AXIS],
left.max[DEPTH_AXIS],
right.min[DEPTH_AXIS],
right.max[DEPTH_AXIS],
) / Math.max(Math.min(left.size[DEPTH_AXIS], right.size[DEPTH_AXIS]), 1)
)
}
function dimensionRatio(left: number, right: number) {
return Math.max(left, right) / Math.max(Math.min(left, right), 1)
}
function belongsToRow(
node: InstallationModelNodeSnapshot,
row: PartitionRow,
verticalTolerance: number,
) {
const reference = row.members[0]
return (
Math.abs(node.center[VERTICAL_AXIS] - row.centerY) <= verticalTolerance &&
dimensionRatio(node.size[VERTICAL_AXIS], row.height) <= 1.35 &&
dimensionRatio(node.size[DEPTH_AXIS], row.depth) <= 1.25 &&
depthOverlapRatio(node, reference) >= 0.8
)
}
function appendToRow(row: PartitionRow, node: InstallationModelNodeSnapshot) {
row.members.push(node)
row.centerY =
row.members.reduce((total, member) => total + member.center[VERTICAL_AXIS], 0) /
row.members.length
row.centerZ =
row.members.reduce((total, member) => total + member.center[DEPTH_AXIS], 0) /
row.members.length
row.height =
row.members.reduce((total, member) => total + member.size[VERTICAL_AXIS], 0) /
row.members.length
row.depth =
row.members.reduce((total, member) => total + member.size[DEPTH_AXIS], 0) /
row.members.length
}
function buildPartitionRows(
nodes: InstallationModelNodeSnapshot[],
metrics: LocalHorizontalPartitionMetrics,
) {
const verticalTolerance = Math.max(
metrics.size[VERTICAL_AXIS] * 0.012,
metrics.diagonal * 0.004,
1,
)
const candidates = nodes.filter(
(node) =>
resolveInstallationPanelNormalAxis(node) === LATERAL_AXIS &&
node.size[VERTICAL_AXIS] >= metrics.size[VERTICAL_AXIS] * 0.025 &&
node.size[VERTICAL_AXIS] <= metrics.size[VERTICAL_AXIS] * 0.34 &&
node.size[DEPTH_AXIS] >= metrics.size[DEPTH_AXIS] * 0.45,
)
return candidates
.slice()
.sort(
(left, right) =>
left.center[VERTICAL_AXIS] - right.center[VERTICAL_AXIS] ||
left.center[LATERAL_AXIS] - right.center[LATERAL_AXIS],
)
.reduce<PartitionRow[]>((rows, node) => {
const row = rows.find((candidate) =>
belongsToRow(node, candidate, verticalTolerance),
)
if (row) {
appendToRow(row, node)
} else {
rows.push({
members: [node],
centerY: node.center[VERTICAL_AXIS],
centerZ: node.center[DEPTH_AXIS],
height: node.size[VERTICAL_AXIS],
depth: node.size[DEPTH_AXIS],
})
}
return rows
}, [])
.filter((row) => {
const lateralCenters = new Set(
row.members.map((member) =>
Math.round(member.center[LATERAL_AXIS] / Math.max(metrics.diagonal * 0.002, 1)),
),
)
const minX = Math.min(...row.members.map((member) => member.center[LATERAL_AXIS]))
const maxX = Math.max(...row.members.map((member) => member.center[LATERAL_AXIS]))
return lateralCenters.size >= 2 && maxX - minX >= metrics.size[LATERAL_AXIS] * 0.18
})
}
/**
* A repeated row of short X-normal partitions is a lateral structure even
* when the row sits high or low in the product. Spread the row around its
* original lateral median without changing its vertical or depth layer.
*/
export function buildInstallationLocalHorizontalPartitionPlan(
nodes: InstallationModelNodeSnapshot[],
metrics: LocalHorizontalPartitionMetrics,
lateralGap: number,
): LocalHorizontalPartitionPlan {
const memberNames = new Set<string>()
const offsets = new Map<string, InstallationVector3>()
const layerTolerance = Math.max(metrics.diagonal * 0.003, 1)
buildPartitionRows(nodes, metrics).forEach((row) => {
const layers = row.members
.slice()
.sort((left, right) => left.center[LATERAL_AXIS] - right.center[LATERAL_AXIS])
.reduce<InstallationModelNodeSnapshot[][]>((result, node) => {
const layer = result.find(
(members) =>
Math.abs(members[0].center[LATERAL_AXIS] - node.center[LATERAL_AXIS]) <=
layerTolerance,
)
if (layer) layer.push(node)
else result.push([node])
return result
}, [])
.sort(
(left, right) =>
left[0].center[LATERAL_AXIS] - right[0].center[LATERAL_AXIS],
)
const crossingIndex = layers.findIndex((layer) =>
layer.some(
(member) =>
member.min[LATERAL_AXIS] <= metrics.center[LATERAL_AXIS] &&
member.max[LATERAL_AXIS] >= metrics.center[LATERAL_AXIS],
),
)
const referenceIndex =
crossingIndex >= 0 ? crossingIndex : (layers.length - 1) / 2
layers.forEach((layer, index) => {
const amount = (index - referenceIndex) * lateralGap
layer.forEach((member) => {
memberNames.add(member.nodeName)
offsets.set(member.nodeName, [amount, 0, 0])
})
})
})
return { memberNames, offsets }
}

View File

@ -0,0 +1,68 @@
import type { InstallationModelNodeSnapshot } from '../types'
import { buildInstallationLocalVerticalLayerPlan } from './installationLocalVerticalLayerPlanner'
function node(
nodeName: string,
center: [number, number, number],
size: [number, number, number],
): InstallationModelNodeSnapshot {
const half = size.map((value) => value / 2) as [number, number, number]
return {
nodeName,
center,
size,
min: [center[0] - half[0], center[1] - half[1], center[2] - half[2]],
max: [center[0] + half[0], center[1] + half[1], center[2] + half[2]],
}
}
const metrics = {
size: [1600, 815, 340] as [number, number, number],
diagonal: Math.hypot(1600, 815, 340),
}
describe('buildInstallationLocalVerticalLayerPlan', () => {
it('uses both projections to spread a continuous three-layer region vertically', () => {
const plan = buildInstallationLocalVerticalLayerPlan(
[
node('upper', [0, 829, 73], [1570, 12, 168]),
node('reference', [0, 798, 148], [1570, 50, 12]),
node('lower', [0, 767, 127], [1570, 12, 86]),
],
metrics,
60,
)
expect(plan.offsets.get('upper')).toEqual([0, 60, 0])
expect(plan.offsets.get('reference')).toEqual([0, 0, 0])
expect(plan.offsets.get('lower')).toEqual([0, -60, 0])
})
it('does not infer a local center when the lower contact layer is absent', () => {
const plan = buildInstallationLocalVerticalLayerPlan(
[
node('upper', [0, 842, 70], [1370, 12, 168]),
node('reference', [0, 811, 148], [1370, 50, 12]),
],
metrics,
60,
)
expect(plan.memberNames.size).toBe(0)
expect(plan.offsets.size).toBe(0)
})
it('rejects parts that touch in one view but do not overlap in depth', () => {
const plan = buildInstallationLocalVerticalLayerPlan(
[
node('upper', [0, 829, -80], [1570, 12, 80]),
node('reference', [0, 798, 148], [1570, 50, 12]),
node('lower', [0, 767, -80], [1570, 12, 80]),
],
metrics,
60,
)
expect(plan.memberNames.size).toBe(0)
})
})

View File

@ -0,0 +1,162 @@
import type {
InstallationModelNodeSnapshot,
InstallationVector3,
} from '../types'
type Axis = 0 | 1 | 2
interface LocalLayerMetrics {
size: InstallationVector3
diagonal: number
}
interface LocalVerticalLayerGroup {
upper: InstallationModelNodeSnapshot
reference: InstallationModelNodeSnapshot
lower: InstallationModelNodeSnapshot
}
export interface LocalVerticalLayerPlan {
memberNames: Set<string>
offsets: Map<string, InstallationVector3>
}
const LATERAL_AXIS: Axis = 0
const VERTICAL_AXIS: Axis = 1
const DEPTH_AXIS: Axis = 2
function isFlatOnAxis(
node: InstallationModelNodeSnapshot,
axis: Axis,
ratio = 0.16,
) {
const otherAxes = ([0, 1, 2] as Axis[]).filter((candidate) => candidate !== axis)
return (
node.size[axis] <=
Math.max(node.size[otherAxes[0]], node.size[otherAxes[1]], 1) * ratio
)
}
function intervalOverlap(
leftMin: number,
leftMax: number,
rightMin: number,
rightMax: number,
) {
return Math.max(0, Math.min(leftMax, rightMax) - Math.max(leftMin, rightMin))
}
function overlapRatio(
left: InstallationModelNodeSnapshot,
right: InstallationModelNodeSnapshot,
axis: Axis,
) {
return (
intervalOverlap(left.min[axis], left.max[axis], right.min[axis], right.max[axis]) /
Math.max(Math.min(left.size[axis], right.size[axis]), 1)
)
}
function surfacesTouch(
lowerSurface: number,
upperSurface: number,
tolerance: number,
) {
return Math.abs(lowerSurface - upperSurface) <= tolerance
}
function findHorizontalNeighbor(
reference: InstallationModelNodeSnapshot,
candidates: InstallationModelNodeSnapshot[],
direction: -1 | 1,
tolerance: number,
) {
return candidates
.filter((candidate) => {
if (!isFlatOnAxis(candidate, VERTICAL_AXIS, 0.12)) return false
const touchesVertically =
direction > 0
? surfacesTouch(reference.max[VERTICAL_AXIS], candidate.min[VERTICAL_AXIS], tolerance)
: surfacesTouch(candidate.max[VERTICAL_AXIS], reference.min[VERTICAL_AXIS], tolerance)
return (
touchesVertically &&
overlapRatio(reference, candidate, LATERAL_AXIS) >= 0.88 &&
overlapRatio(reference, candidate, DEPTH_AXIS) >= 0.8
)
})
.sort(
(left, right) =>
right.size[LATERAL_AXIS] * right.size[DEPTH_AXIS] -
left.size[LATERAL_AXIS] * left.size[DEPTH_AXIS],
)[0]
}
function findLocalVerticalLayerGroups(
nodes: InstallationModelNodeSnapshot[],
metrics: LocalLayerMetrics,
) {
const tolerance = Math.max(metrics.diagonal * 0.004, 1)
const horizontalCandidates = nodes.filter((node) =>
isFlatOnAxis(node, VERTICAL_AXIS, 0.12),
)
const usedNames = new Set<string>()
const groups: LocalVerticalLayerGroup[] = []
nodes
.filter(
(node) =>
isFlatOnAxis(node, DEPTH_AXIS, 0.12) &&
node.size[LATERAL_AXIS] >= metrics.size[LATERAL_AXIS] * 0.18 &&
node.size[VERTICAL_AXIS] >= metrics.size[VERTICAL_AXIS] * 0.025 &&
node.size[VERTICAL_AXIS] <= metrics.size[VERTICAL_AXIS] * 0.2,
)
.forEach((reference) => {
if (usedNames.has(reference.nodeName)) return
const upper = findHorizontalNeighbor(
reference,
horizontalCandidates,
1,
tolerance,
)
const lower = findHorizontalNeighbor(
reference,
horizontalCandidates,
-1,
tolerance,
)
if (!upper || !lower) return
if (usedNames.has(upper.nodeName) || usedNames.has(lower.nodeName)) return
groups.push({ upper, reference, lower })
usedNames.add(upper.nodeName)
usedNames.add(reference.nodeName)
usedNames.add(lower.nodeName)
})
return groups
}
/**
* Resolves local upper/reference/lower structures from two orthogonal
* projections. The front projection must overlap laterally, while the side
* projection must show continuous edge contact on both sides of the reference.
*/
export function buildInstallationLocalVerticalLayerPlan(
nodes: InstallationModelNodeSnapshot[],
metrics: LocalLayerMetrics,
verticalGap: number,
): LocalVerticalLayerPlan {
const memberNames = new Set<string>()
const offsets = new Map<string, InstallationVector3>()
findLocalVerticalLayerGroups(nodes, metrics).forEach((group) => {
memberNames.add(group.upper.nodeName)
memberNames.add(group.reference.nodeName)
memberNames.add(group.lower.nodeName)
offsets.set(group.upper.nodeName, [0, verticalGap, 0])
offsets.set(group.reference.nodeName, [0, 0, 0])
offsets.set(group.lower.nodeName, [0, -verticalGap, 0])
})
return { memberNames, offsets }
}

View File

@ -0,0 +1,32 @@
import * as THREE from 'three'
import type { ThreeDEngine } from '@/lib/3d/engine'
import { resetInstallationModelGeometry } from './installationModelGeometry'
describe('resetInstallationModelGeometry', () => {
it('restores original node positions and rotations before geometry is sampled again', () => {
const root = new THREE.Group()
const node = new THREE.Object3D()
node.name = 'asset_1'
node.position.set(40, 50, 60)
node.quaternion.setFromEuler(new THREE.Euler(0.3, 0.4, 0.5))
root.add(node)
const engine = {
getNamedNodeNames: jest.fn(() => ['asset_1']),
setNodePositionOffsets: jest.fn(),
resolveObjectByNodeName: jest.fn((nodeName: string) =>
nodeName === 'asset_1' ? node : null,
),
getNodeOriginalLocalTransform: jest.fn(() => ({
position: [1, 2, 3] as [number, number, number],
rotation: [0, 0, 0, 1] as [number, number, number, number],
})),
getModelRoot: jest.fn(() => root),
} as unknown as ThreeDEngine
expect(resetInstallationModelGeometry(engine)).toEqual(['asset_1'])
expect(engine.setNodePositionOffsets).toHaveBeenCalledWith([])
expect(node.position.toArray()).toEqual([1, 2, 3])
expect(node.quaternion.toArray()).toEqual([0, 0, 0, 1])
})
})

View File

@ -49,3 +49,24 @@ export function collectInstallationModelNodeSnapshots(
})
.filter((snapshot): snapshot is InstallationModelNodeSnapshot => Boolean(snapshot))
}
export function resetInstallationModelGeometry(
engine: ThreeDEngine,
nodeNames: string[] = engine.getNamedNodeNames(),
) {
const resolvedNodeNames = normalizeNodeNames(nodeNames)
engine.setNodePositionOffsets([])
resolvedNodeNames.forEach((nodeName) => {
const object = engine.resolveObjectByNodeName(nodeName)
const originalTransform = engine.getNodeOriginalLocalTransform(nodeName)
if (!object || !originalTransform) return
object.position.set(...originalTransform.position)
object.quaternion.set(...originalTransform.rotation)
object.updateMatrix()
})
engine.getModelRoot()?.updateMatrixWorld(true)
return resolvedNodeNames
}

View File

@ -0,0 +1,144 @@
import type { InstallationModelNodeSnapshot } from '../types'
import { buildInstallationOrientationLayerOffsets } from './installationOrientationLayerPlanner'
function node(
nodeName: string,
center: [number, number, number],
size: [number, number, number],
): InstallationModelNodeSnapshot {
const half = size.map((value) => value / 2) as [number, number, number]
return {
nodeName,
center,
size,
min: [center[0] - half[0], center[1] - half[1], center[2] - half[2]],
max: [center[0] + half[0], center[1] + half[1], center[2] + half[2]],
}
}
describe('buildInstallationOrientationLayerOffsets', () => {
it('keeps all seven vertical projection levels separate', () => {
const offsets = buildInstallationOrientationLayerOffsets(
[
node('bottom', [0, -90, 0], [420, 10, 220]),
node('lower-bay', [0, 0, 0], [12, 140, 200]),
node('lower-deck', [0, 90, 0], [420, 10, 220]),
node('middle-post', [0, 150, 0], [12, 90, 200]),
node('upper-deck', [0, 210, 0], [420, 10, 220]),
node('upper-bay', [0, 260, 0], [12, 70, 200]),
node('top', [0, 330, 0], [420, 10, 220]),
],
{ gapByAxis: { 1: 40 } },
)
expect(offsets.get('bottom')).toEqual([0, -120, 0])
expect(offsets.get('lower-bay')).toEqual([0, -80, 0])
expect(offsets.get('lower-deck')).toEqual([0, -40, 0])
expect(offsets.get('middle-post')).toEqual([0, 0, 0])
expect(offsets.get('upper-deck')).toEqual([0, 40, 0])
expect(offsets.get('upper-bay')).toEqual([0, 80, 0])
expect(offsets.get('top')).toEqual([0, 120, 0])
})
it('separates front-view columns while keeping equal planes aligned', () => {
const offsets = buildInstallationOrientationLayerOffsets(
[
node('left-outer', [-300, 0, 0], [10, 420, 220]),
node('left-inner-a', [-150, -100, 0], [10, 180, 220]),
node('left-inner-b', [-150, 110, 0], [10, 160, 220]),
node('center', [0, 0, 0], [10, 420, 220]),
node('right-inner', [150, 0, 0], [10, 300, 220]),
node('right-outer', [300, 0, 0], [10, 420, 220]),
],
{ gapByAxis: { 0: 50 } },
)
expect(offsets.get('left-outer')).toEqual([-100, 0, 0])
expect(offsets.get('left-inner-a')).toEqual([-50, 0, 0])
expect(offsets.get('left-inner-b')).toEqual([-50, 0, 0])
expect(offsets.get('center')).toEqual([0, 0, 0])
expect(offsets.get('right-inner')).toEqual([50, 0, 0])
expect(offsets.get('right-outer')).toEqual([100, 0, 0])
})
it('keeps a high short upright row on the lateral axis', () => {
const offsets = buildInstallationOrientationLayerOffsets(
[
node('left-short', [-150, 260, 0], [10, 70, 180]),
node('center-short', [0, 260, 0], [10, 70, 180]),
node('right-short', [150, 260, 0], [10, 70, 180]),
],
{ gapByAxis: { 0: 50, 1: 40 } },
)
expect(offsets.get('left-short')).toEqual([-50, 0, 0])
expect(offsets.get('center-short')).toEqual([0, 0, 0])
expect(offsets.get('right-short')).toEqual([50, 0, 0])
})
it('uses the depth axis for front and rear face layers', () => {
const offsets = buildInstallationOrientationLayerOffsets(
[
node('rear', [0, 0, -120], [420, 300, 8]),
node('middle', [0, 0, 0], [420, 300, 8]),
node('front-a', [-100, 0, 120], [200, 300, 8]),
node('front-b', [100, 0, 120], [200, 300, 8]),
],
{ gapByAxis: { 2: 60 } },
)
expect(offsets.get('rear')).toEqual([0, 0, -60])
expect(offsets.get('middle')).toEqual([0, 0, 0])
expect(offsets.get('front-a')).toEqual([0, 0, 60])
expect(offsets.get('front-b')).toEqual([0, 0, 60])
})
it('groups differently shaped supports that touch the same bottom panel', () => {
const offsets = buildInstallationOrientationLayerOffsets(
[
node('left-side', [-390, 300, 0], [20, 600, 400]),
node('right-side', [390, 300, 0], [20, 600, 400]),
node('bottom', [0, 0, 0], [800, 20, 400]),
node('top', [0, 600, 0], [800, 20, 400]),
node('angled-left-front', [-300, -84.4, 120], [130, 148.8, 130]),
node('angled-left-rear', [-300, -84.4, -120], [130, 148.8, 130]),
node('angled-right-front', [300, -84.4, 120], [130, 148.8, 130]),
node('angled-right-rear', [300, -84.4, -120], [130, 148.8, 130]),
node('straight-center', [0, -85, 0], [55, 150, 55]),
],
{ gapByAxis: { 0: 50, 1: 40 } },
)
const supportNames = [
'angled-left-front',
'angled-left-rear',
'angled-right-front',
'angled-right-rear',
'straight-center',
]
const supportOffsets = supportNames.map((name) => offsets.get(name))
expect(new Set(supportOffsets.map((offset) => JSON.stringify(offset))).size).toBe(1)
expect(supportOffsets[0]?.[0]).toBe(0)
expect(supportOffsets[0]?.[1]).toBeLessThan(offsets.get('bottom')?.[1] ?? 0)
expect(supportOffsets[0]?.[2]).toBe(0)
})
it('does not treat members beneath an upper deck as bottom supports', () => {
const nodes = [
node('bottom', [0, 0, 0], [800, 20, 400]),
node('upper-deck', [0, 400, 0], [800, 20, 400]),
node('upper-rail', [0, 365, 180], [700, 50, 12]),
node('left-side', [-390, 200, 0], [20, 400, 400]),
node('right-side', [390, 200, 0], [20, 400, 400]),
]
const withUpperRail = buildInstallationOrientationLayerOffsets(nodes)
const withoutUpperRail = buildInstallationOrientationLayerOffsets(
nodes.filter((item) => item.nodeName !== 'upper-rail'),
)
expect(withUpperRail.get('upper-rail')?.[2]).not.toBe(0)
expect(withUpperRail.get('bottom')).toEqual(withoutUpperRail.get('bottom'))
expect(withUpperRail.get('upper-deck')).toEqual(withoutUpperRail.get('upper-deck'))
})
})

View File

@ -0,0 +1,402 @@
import type {
InstallationModelNodeSnapshot,
InstallationVector3,
} from '../types'
import {
buildInstallationAssemblyContactGraph,
getInstallationAssemblyContactNeighbors,
resolveInstallationPanelNormalAxis,
type InstallationAssemblyContactGraph,
} from './installationAssemblyContactGraph'
import { applyInstallationConnectedPanelSeparation } from './installationConnectedPanelSeparationPlanner'
type Axis = 0 | 1 | 2
interface Bounds {
center: InstallationVector3
size: InstallationVector3
diagonal: number
}
interface AxisMember {
node: InstallationModelNodeSnapshot
axis: Axis
}
interface AxisLayer {
members: AxisMember[]
center: number
min: number
max: number
span: number
}
interface OrientationLayerOptions {
gapByAxis?: Partial<Record<Axis, number>>
}
const AXES: Axis[] = [0, 1, 2]
function calculateBounds(nodes: InstallationModelNodeSnapshot[]): Bounds {
const min: InstallationVector3 = [...nodes[0].min]
const max: InstallationVector3 = [...nodes[0].max]
nodes.slice(1).forEach((node) => {
AXES.forEach((axis) => {
min[axis] = Math.min(min[axis], node.min[axis])
max[axis] = Math.max(max[axis], node.max[axis])
})
})
const size = AXES.map((axis) => Math.max(max[axis] - min[axis], 1)) as InstallationVector3
return {
size,
center: AXES.map((axis) => min[axis] + size[axis] / 2) as InstallationVector3,
diagonal: Math.hypot(size[0], size[1], size[2]),
}
}
function isPanelNormalAxis(node: InstallationModelNodeSnapshot, axis: Axis) {
return resolveInstallationPanelNormalAxis(node) === axis
}
function resolvePrimaryAxis(
node: InstallationModelNodeSnapshot,
bounds: Bounds,
nodes: InstallationModelNodeSnapshot[],
): AxisMember | null {
const normalAxis = resolveInstallationPanelNormalAxis(node)
if (normalAxis === 2) {
const depthPosition = Math.abs(node.center[2] - bounds.center[2])
if (depthPosition >= bounds.size[2] * 0.08) return { node, axis: 2 }
}
if (normalAxis === 1) return { node, axis: 1 }
if (normalAxis === 0) {
const shortUpright = node.size[1] <= bounds.size[1] * 0.34
// A short X-normal panel belongs to a vertical layer only when the
// geometry proves it is bracketed by horizontal panels. A same-level
// lateral peer takes precedence, so a left/center/right row is never
// reinterpreted as an upper/lower layer merely because it is off-center.
if (shortUpright && !hasLateralPeer(node, nodes, bounds) && hasVerticalBrackets(node, nodes, bounds)) {
return { node, axis: 1 }
}
return { node, axis: 0 }
}
const normalizedPositions = AXES.map((axis) => ({
axis,
position:
Math.abs(node.center[axis] - bounds.center[axis]) /
Math.max(bounds.size[axis] / 2, 1),
span: node.size[axis] / bounds.size[axis],
}))
.filter((candidate) => candidate.span <= 0.62 && candidate.position >= 0.12)
.sort((left, right) => right.position - left.position)
const primary = normalizedPositions[0]
return primary ? { node, axis: primary.axis } : null
}
function layerTolerance(axis: Axis, bounds: Bounds) {
return Math.max(bounds.size[axis] * 0.006, bounds.diagonal * 0.002, 1)
}
function intervalOverlap(
leftMin: number,
leftMax: number,
rightMin: number,
rightMax: number,
) {
return Math.max(0, Math.min(leftMax, rightMax) - Math.max(leftMin, rightMin))
}
function projectionOverlapRatio(
left: InstallationModelNodeSnapshot,
right: InstallationModelNodeSnapshot,
axis: Axis,
) {
return (
intervalOverlap(left.min[axis], left.max[axis], right.min[axis], right.max[axis]) /
Math.max(Math.min(left.size[axis], right.size[axis]), 1)
)
}
function intervalGap(
leftMin: number,
leftMax: number,
rightMin: number,
rightMax: number,
) {
if (leftMax < rightMin) return rightMin - leftMax
if (rightMax < leftMin) return leftMin - rightMax
return 0
}
function hasLateralPeer(
node: InstallationModelNodeSnapshot,
nodes: InstallationModelNodeSnapshot[],
bounds: Bounds,
) {
const rowTolerance = Math.max(bounds.size[1] * 0.045, bounds.diagonal * 0.012, 1)
const depthTolerance = Math.max(bounds.size[2] * 0.08, bounds.diagonal * 0.02, 1)
const peers = nodes.filter(
(candidate) =>
candidate.nodeName !== node.nodeName &&
resolveInstallationPanelNormalAxis(candidate) === 0 &&
Math.abs(candidate.center[1] - node.center[1]) <= rowTolerance &&
Math.abs(candidate.center[2] - node.center[2]) <= depthTolerance &&
projectionOverlapRatio(node, candidate, 1) >= 0.7 &&
projectionOverlapRatio(node, candidate, 2) >= 0.7,
)
if (peers.length === 0) return false
const minX = Math.min(node.min[0], ...peers.map((candidate) => candidate.min[0]))
const maxX = Math.max(node.max[0], ...peers.map((candidate) => candidate.max[0]))
return maxX - minX >= Math.max(bounds.size[0] * 0.12, node.size[0] * 2.5, 1)
}
function hasVerticalBrackets(
node: InstallationModelNodeSnapshot,
nodes: InstallationModelNodeSnapshot[],
bounds: Bounds,
) {
const candidates = nodes.filter(
(candidate) =>
candidate.nodeName !== node.nodeName &&
resolveInstallationPanelNormalAxis(candidate) === 1 &&
projectionOverlapRatio(node, candidate, 0) >= 0.55 &&
projectionOverlapRatio(node, candidate, 2) >= 0.65,
)
const toleranceFor = (candidate: InstallationModelNodeSnapshot) =>
Math.max(
bounds.diagonal * 0.055,
Math.min(node.size[1], candidate.size[1]) * 0.45,
1,
)
const hasAbove = candidates.some(
(candidate) =>
candidate.center[1] > node.center[1] &&
intervalGap(node.min[1], node.max[1], candidate.min[1], candidate.max[1]) <=
toleranceFor(candidate),
)
const hasBelow = candidates.some(
(candidate) =>
candidate.center[1] < node.center[1] &&
intervalGap(node.min[1], node.max[1], candidate.min[1], candidate.max[1]) <=
toleranceFor(candidate),
)
return hasAbove && hasBelow
}
interface BottomSupportGroup {
panel: InstallationModelNodeSnapshot
supportNames: Set<string>
}
function resolveBottomSupportGroup(
nodes: InstallationModelNodeSnapshot[],
bounds: Bounds,
graph: InstallationAssemblyContactGraph,
): BottomSupportGroup | null {
const horizontalPanels = nodes
.filter(
(node) =>
isPanelNormalAxis(node, 1) &&
node.size[0] >= bounds.size[0] * 0.45 &&
node.size[2] >= bounds.size[2] * 0.35,
)
.sort((left, right) => left.center[1] - right.center[1])
const panel = horizontalPanels[0]
if (!panel || panel.center[1] >= bounds.center[1]) return null
const nodeNames = new Set(nodes.map((node) => node.nodeName))
const supports = getInstallationAssemblyContactNeighbors(graph, panel.nodeName)
.filter(
({ edge, node }) =>
nodeNames.has(node.nodeName) &&
edge.axis === 1 &&
node.center[1] < panel.center[1] &&
projectionOverlapRatio(node, panel, 0) >= 0.2 &&
projectionOverlapRatio(node, panel, 2) >= 0.2,
)
.map(({ node }) => node)
return supports.length > 0
? {
panel,
supportNames: new Set(supports.map((node) => node.nodeName)),
}
: null
}
function belongsToLayer(
member: AxisMember,
layer: AxisLayer,
tolerance: number,
) {
const axis = member.axis
const memberSpan = Math.max(member.node.size[axis], 1)
const spanRatio = Math.max(memberSpan, layer.span) / Math.max(Math.min(memberSpan, layer.span), 1)
const alignedSurfaces =
Math.abs(member.node.min[axis] - layer.min) <= tolerance * 1.5 &&
Math.abs(member.node.max[axis] - layer.max) <= tolerance * 1.5
const alignedCenters =
Math.abs(member.node.center[axis] - layer.center) <= tolerance && spanRatio <= 2.2
return alignedSurfaces || alignedCenters
}
function appendToLayer(layer: AxisLayer, member: AxisMember) {
layer.members.push(member)
const axis = member.axis
layer.center =
layer.members.reduce((total, item) => total + item.node.center[axis], 0) /
layer.members.length
layer.min = Math.min(layer.min, member.node.min[axis])
layer.max = Math.max(layer.max, member.node.max[axis])
layer.span = Math.max(layer.max - layer.min, 1)
}
function groupLayers(
members: AxisMember[],
axis: Axis,
bounds: Bounds,
) {
const tolerance = layerTolerance(axis, bounds)
return members
.slice()
.sort((left, right) => left.node.center[axis] - right.node.center[axis])
.reduce<AxisLayer[]>((layers, member) => {
const matchingLayer = layers.find((layer) =>
belongsToLayer(member, layer, tolerance),
)
if (matchingLayer) {
appendToLayer(matchingLayer, member)
return layers
}
layers.push({
members: [member],
center: member.node.center[axis],
min: member.node.min[axis],
max: member.node.max[axis],
span: Math.max(member.node.size[axis], 1),
})
return layers
}, [])
.sort((left, right) => left.center - right.center)
}
function resolveGap(
axis: Axis,
bounds: Bounds,
options: OrientationLayerOptions,
) {
return (
options.gapByAxis?.[axis] ??
Math.max(bounds.size[axis] * 0.075, bounds.diagonal * 0.035)
)
}
function layerStep(index: number, count: number) {
const lowerMiddle = Math.floor((count - 1) / 2)
const upperMiddle = Math.ceil((count - 1) / 2)
if (lowerMiddle === upperMiddle && index === lowerMiddle) return 0
if (index <= lowerMiddle) return index - lowerMiddle - (lowerMiddle === upperMiddle ? 0 : 1)
return index - upperMiddle + (lowerMiddle === upperMiddle ? 0 : 1)
}
function assignLayerOffsets(
members: AxisMember[],
axis: Axis,
bounds: Bounds,
options: OrientationLayerOptions,
offsets: Map<string, InstallationVector3>,
) {
const layers = groupLayers(members, axis, bounds)
if (layers.length === 1) {
const [layer] = layers
const crossesReference =
layer.min <= bounds.center[axis] && layer.max >= bounds.center[axis]
const direction = layer.center >= bounds.center[axis] ? 1 : -1
const amount = crossesReference ? 0 : direction * resolveGap(axis, bounds, options)
layer.members.forEach((member) => {
const offset: InstallationVector3 = [0, 0, 0]
offset[axis] = amount
offsets.set(member.node.nodeName, offset)
})
return
}
const gap = resolveGap(axis, bounds, options)
const crossingLayers = layers
.map((layer, index) => ({ layer, index }))
.filter(
({ layer }) =>
layer.min <= bounds.center[axis] && layer.max >= bounds.center[axis],
)
const anchorIndex = crossingLayers.sort(
(left, right) =>
Math.abs(left.layer.center - bounds.center[axis]) -
Math.abs(right.layer.center - bounds.center[axis]),
)[0]?.index
layers.forEach((layer, index) => {
const amount =
anchorIndex === undefined
? layerStep(index, layers.length) * gap
: (index - anchorIndex) * gap
layer.members.forEach((member) => {
const offset: InstallationVector3 = [0, 0, 0]
offset[axis] = amount
offsets.set(member.node.nodeName, offset)
})
})
}
/**
* Builds ordered projection layers on the product's three structural axes.
* Every node selects one primary layer axis; equal planes move together and
* distinct planes keep their original order while spreading from the median.
*/
export function buildInstallationOrientationLayerOffsets(
nodes: InstallationModelNodeSnapshot[],
options: OrientationLayerOptions = {},
) {
const offsets = new Map<string, InstallationVector3>()
if (nodes.length === 0) return offsets
const bounds = calculateBounds(nodes)
nodes.forEach((node) => offsets.set(node.nodeName, [0, 0, 0]))
const structuralThreshold = bounds.diagonal * 0.045
const structuralNodes = nodes.filter(
(node) => Math.max(...node.size) >= structuralThreshold,
)
const contactGraph = buildInstallationAssemblyContactGraph(structuralNodes)
const bottomSupportGroup = resolveBottomSupportGroup(
structuralNodes,
bounds,
contactGraph,
)
const bottomSupportNames = bottomSupportGroup?.supportNames ?? new Set<string>()
const members = structuralNodes
.filter((node) => !bottomSupportNames.has(node.nodeName))
.flatMap((node) => {
const member = resolvePrimaryAxis(node, bounds, structuralNodes)
return member ? [member] : []
})
AXES.forEach((axis) => {
assignLayerOffsets(
members.filter((member) => member.axis === axis),
axis,
bounds,
options,
offsets,
)
})
if (bottomSupportGroup) {
const panelOffset = offsets.get(bottomSupportGroup.panel.nodeName)?.[1] ?? 0
const supportGap = resolveGap(1, bounds, options)
bottomSupportGroup.supportNames.forEach((nodeName) => {
offsets.set(nodeName, [0, panelOffset - supportGap, 0])
})
}
return applyInstallationConnectedPanelSeparation(structuralNodes, offsets)
}

View File

@ -0,0 +1,102 @@
import type { InstallationModelNodeSnapshot } from '../types'
import {
buildInitialPartCatalogFromNodeSnapshots,
createPartItem,
resolvePartCatalogForAutoExplode,
} from './installationPartCatalogHelpers'
function snapshot(
nodeName: string,
size: [number, number, number] = [100, 20, 40],
): InstallationModelNodeSnapshot {
return {
nodeName,
center: [0, 0, 0],
min: [0, 0, 0],
max: size,
size,
}
}
describe('buildInitialPartCatalogFromNodeSnapshots', () => {
it('creates one sequentially numbered part for every unique geometry node', () => {
const parts = buildInitialPartCatalogFromNodeSnapshots([
snapshot('asset_003'),
snapshot('asset_007'),
snapshot('asset_003'),
])
expect(parts.map((part) => ({ code: part.code, name: part.name, nodeNames: part.nodeNames }))).toEqual([
{ code: '1', name: 'asset_003', nodeNames: ['asset_003'] },
{ code: '2', name: 'asset_007', nodeNames: ['asset_007'] },
])
expect(parts.every((part) => part.kind === 'part' && part.quantity === 1)).toBe(true)
})
it('ignores blank node names and nodes without usable geometry', () => {
const parts = buildInitialPartCatalogFromNodeSnapshots([
snapshot(''),
snapshot('empty-node', [0, 0, 0]),
snapshot('invalid-node', [Number.NaN, 20, 40]),
snapshot('panel-node', [400, 18, 800]),
])
expect(parts).toHaveLength(1)
expect(parts[0]).toMatchObject({ code: '1', name: 'panel-node', nodeName: 'panel-node' })
})
})
describe('resolvePartCatalogForAutoExplode', () => {
it('keeps a configured catalog when its grouped parts cover the current model exactly', () => {
const parts = [
createPartItem({
nodeNames: ['asset_001', 'asset_002'],
code: '1',
name: '组合板件',
}),
]
const resolved = resolvePartCatalogForAutoExplode(parts, [
snapshot('asset_001'),
snapshot('asset_002'),
])
expect(resolved).toBe(parts)
})
it('rebuilds stale model parts when nodes are missing and preserves loose items', () => {
const stalePart = createPartItem({
nodeName: 'asset_001',
code: '1',
name: '旧部件',
offset: [120, 0, 0],
})
const looseHardware = createPartItem({
code: 'A',
name: '独立五金',
kind: 'hardware',
})
const resolved = resolvePartCatalogForAutoExplode(
[stalePart, looseHardware],
[snapshot('asset_001'), snapshot('asset_002')],
)
expect(resolved.slice(0, 2).map((part) => ({
code: part.code,
name: part.name,
nodeNames: part.nodeNames,
offset: part.offset,
}))).toEqual([
{ code: '1', name: 'asset_001', nodeNames: ['asset_001'], offset: [0, 0, 0] },
{ code: '2', name: 'asset_002', nodeNames: ['asset_002'], offset: [0, 0, 0] },
])
expect(resolved[2]).toBe(looseHardware)
})
it('does not replace the saved catalog before model snapshots are available', () => {
const parts = [createPartItem({ nodeName: 'asset_001', code: '1', name: '现有部件' })]
expect(resolvePartCatalogForAutoExplode(parts, [])).toBe(parts)
})
})

View File

@ -201,6 +201,56 @@ export function resolvePartQuantityFromNodeNames(nodeNames: string[]): number {
return Math.max(1, nodeNames.filter((name) => name && name.trim()).length)
}
function hasUsableNodeGeometry(snapshot: InstallationModelNodeSnapshot) {
return (
snapshot.size.every((value) => Number.isFinite(value) && value >= 0) &&
snapshot.size.some((value) => value > 0.001)
)
}
export function buildInitialPartCatalogFromNodeSnapshots(
nodeSnapshots: InstallationModelNodeSnapshot[],
): InstallationPartItem[] {
const uniqueSnapshots = Array.from(
new Map(
nodeSnapshots
.filter((snapshot) => snapshot.nodeName.trim() && hasUsableNodeGeometry(snapshot))
.map((snapshot) => [snapshot.nodeName.trim(), snapshot]),
).keys(),
)
return uniqueSnapshots.map((nodeName, index) =>
createPartItem({
nodeName,
code: String(index + 1),
name: nodeName,
kind: 'part',
quantity: 1,
}),
)
}
export function resolvePartCatalogForAutoExplode(
parts: InstallationPartItem[],
nodeSnapshots: InstallationModelNodeSnapshot[],
): InstallationPartItem[] {
const modelParts = buildInitialPartCatalogFromNodeSnapshots(nodeSnapshots)
if (modelParts.length === 0) return parts
const modelNodeNames = new Set(modelParts.flatMap((part) => getPartNodeNames(part)))
const boundNodeNames = parts.flatMap((part) => getPartNodeNames(part))
const boundNodeNameSet = new Set(boundNodeNames)
const catalogMatchesModel =
boundNodeNames.length === boundNodeNameSet.size &&
boundNodeNameSet.size === modelNodeNames.size &&
Array.from(modelNodeNames).every((nodeName) => boundNodeNameSet.has(nodeName))
if (catalogMatchesModel) return parts
const looseParts = parts.filter((part) => getPartNodeNames(part).length === 0)
return [...modelParts, ...looseParts]
}
function resolveAutoExplodeRole(part: InstallationPartItem): InstallationAutoExplodeRole {
const signature = [part.code, part.name, ...getPartNodeNames(part)].join(' ')

View File

@ -0,0 +1,92 @@
import type {
InstallationModelNodeSnapshot,
InstallationVector3,
} from '../types'
interface CabinetShelfMetrics {
size: InstallationVector3
diagonal: number
}
const LATERAL_AXIS = 0
const VERTICAL_AXIS = 1
const DEPTH_AXIS = 2
function isInternalShelf(
node: InstallationModelNodeSnapshot,
metrics: CabinetShelfMetrics,
) {
const largestOtherDimension = Math.max(
node.size[LATERAL_AXIS],
node.size[DEPTH_AXIS],
1,
)
return (
node.size[VERTICAL_AXIS] <= largestOtherDimension * 0.1 &&
node.size[LATERAL_AXIS] >= metrics.size[LATERAL_AXIS] * 0.25 &&
node.size[LATERAL_AXIS] <= metrics.size[LATERAL_AXIS] * 0.65 &&
node.size[DEPTH_AXIS] >= metrics.size[DEPTH_AXIS] * 0.45
)
}
function groupShelfLevels(
shelves: InstallationModelNodeSnapshot[],
tolerance: number,
) {
return [...shelves]
.sort((left, right) => left.center[VERTICAL_AXIS] - right.center[VERTICAL_AXIS])
.reduce<InstallationModelNodeSnapshot[][]>((levels, shelf) => {
const currentLevel = levels.at(-1)
const levelCenter = currentLevel?.reduce(
(total, item) => total + item.center[VERTICAL_AXIS],
0,
)
const averageCenter = currentLevel?.length
? (levelCenter ?? 0) / currentLevel.length
: null
if (averageCenter === null || Math.abs(shelf.center[VERTICAL_AXIS] - averageCenter) > tolerance) {
levels.push([shelf])
} else if (currentLevel) {
currentLevel.push(shelf)
}
return levels
}, [])
}
export function buildUnnamedCabinetShelfOffsets(
nodes: InstallationModelNodeSnapshot[],
metrics: CabinetShelfMetrics,
) {
const shelves = nodes.filter((node) => isInternalShelf(node, metrics))
const levelTolerance = Math.max(
metrics.size[VERTICAL_AXIS] * 0.02,
metrics.diagonal * 0.008,
1,
)
const levels = groupShelfLevels(shelves, levelTolerance)
const lateralGap = Math.max(
metrics.size[LATERAL_AXIS] * 0.1,
metrics.diagonal * 0.045,
)
const offsets = new Map<string, InstallationVector3>()
// A shelf row is one product group. Keep the row's height and depth intact,
// then fan the left/right members away from the structural reference plane.
// With three members the middle one remains the reference member; with two,
// both members receive equal and opposite offsets.
levels.forEach((level) => {
const ordered = level
.slice()
.sort((left, right) => left.center[LATERAL_AXIS] - right.center[LATERAL_AXIS])
const middle = (ordered.length - 1) / 2
ordered.forEach((shelf, index) => {
offsets.set(shelf.nodeName, [
(index - middle) * lateralGap,
0,
0,
])
})
})
return offsets
}

View File

@ -0,0 +1,186 @@
import type {
InstallationModelNodeSnapshot,
InstallationVector3,
} from '../types'
type Direction = -1 | 1
export interface CabinetVisibilityMetrics {
center: InstallationVector3
size: InstallationVector3
diagonal: number
frontDirection: Direction
sideDistance: number
shellVerticalDistance: number
backDistance: number
}
const LATERAL_AXIS = 0
const VERTICAL_AXIS = 1
const DEPTH_AXIS = 2
function isFlatOnDepth(node: InstallationModelNodeSnapshot, ratio = 0.16) {
return (
node.size[DEPTH_AXIS] <=
Math.max(node.size[LATERAL_AXIS], node.size[VERTICAL_AXIS], 1) * ratio
)
}
function isRearNode(
node: InstallationModelNodeSnapshot,
metrics: CabinetVisibilityMetrics,
) {
return (
isFlatOnDepth(node, 0.16) &&
metrics.frontDirection *
(node.center[DEPTH_AXIS] - metrics.center[DEPTH_AXIS]) <=
-metrics.size[DEPTH_AXIS] * 0.38
)
}
function isVentAccessory(
node: InstallationModelNodeSnapshot,
metrics: CabinetVisibilityMetrics,
) {
const faceSize = Math.max(node.size[LATERAL_AXIS], node.size[VERTICAL_AXIS], 1)
return (
isRearNode(node, metrics) &&
Math.min(node.size[LATERAL_AXIS], node.size[VERTICAL_AXIS]) / faceSize >= 0.82 &&
node.size[LATERAL_AXIS] >= metrics.size[LATERAL_AXIS] * 0.025 &&
node.size[LATERAL_AXIS] <= metrics.size[LATERAL_AXIS] * 0.1 &&
node.size[VERTICAL_AXIS] >= metrics.size[VERTICAL_AXIS] * 0.035 &&
node.size[VERTICAL_AXIS] <= metrics.size[VERTICAL_AXIS] * 0.12 &&
node.size[DEPTH_AXIS] <= metrics.size[DEPTH_AXIS] * 0.04
)
}
function findVentPairs(
nodes: InstallationModelNodeSnapshot[],
metrics: CabinetVisibilityMetrics,
) {
const candidates = nodes.filter((node) => isVentAccessory(node, metrics))
const used = new Set<string>()
const pairs: Array<[
InstallationModelNodeSnapshot,
InstallationModelNodeSnapshot,
]> = []
candidates.forEach((node) => {
if (used.has(node.nodeName)) return
const partner = candidates.find((candidate) => {
if (candidate.nodeName === node.nodeName || used.has(candidate.nodeName)) return false
const centerTolerance = Math.max(node.size[DEPTH_AXIS], candidate.size[DEPTH_AXIS], 1)
const sizeRatio =
Math.min(node.size[LATERAL_AXIS], candidate.size[LATERAL_AXIS]) /
Math.max(node.size[LATERAL_AXIS], candidate.size[LATERAL_AXIS], 1)
return (
Math.abs(node.center[LATERAL_AXIS] - candidate.center[LATERAL_AXIS]) <=
centerTolerance &&
Math.abs(node.center[VERTICAL_AXIS] - candidate.center[VERTICAL_AXIS]) <=
centerTolerance &&
Math.abs(node.center[DEPTH_AXIS] - candidate.center[DEPTH_AXIS]) <=
metrics.size[DEPTH_AXIS] * 0.04 &&
sizeRatio >= 0.9
)
})
if (!partner) return
used.add(node.nodeName)
used.add(partner.nodeName)
pairs.push([node, partner])
})
return pairs
}
export function buildUnnamedCabinetVentPairOffsets(
nodes: InstallationModelNodeSnapshot[],
metrics: CabinetVisibilityMetrics,
) {
const offsets = new Map<string, InstallationVector3>()
const rearOffset = -metrics.frontDirection * metrics.backDistance
const rearLayerGap = Math.max(
metrics.size[DEPTH_AXIS] * 0.16,
metrics.diagonal * 0.025,
)
findVentPairs(nodes, metrics).forEach(([left, right]) => {
const frontMember =
metrics.frontDirection * (left.center[DEPTH_AXIS] - right.center[DEPTH_AXIS]) >= 0
? left
: right
const rearMember = frontMember === left ? right : left
offsets.set(frontMember.nodeName, [
0,
0,
rearOffset + metrics.frontDirection * rearLayerGap,
])
offsets.set(rearMember.nodeName, [
0,
0,
rearOffset - metrics.frontDirection * rearLayerGap,
])
})
return offsets
}
export function buildUnnamedCabinetVisibilityOffsets(
nodes: InstallationModelNodeSnapshot[],
metrics: CabinetVisibilityMetrics,
) {
const offsets = new Map<string, InstallationVector3>()
const rearOffset = -metrics.frontDirection * metrics.backDistance
const rearVerticalGap = Math.max(
metrics.size[VERTICAL_AXIS] * 0.05,
metrics.diagonal * 0.025,
)
const ventPairNames = new Set<string>()
buildUnnamedCabinetVentPairOffsets(nodes, metrics).forEach((offset, nodeName) => {
offsets.set(nodeName, offset)
ventPairNames.add(nodeName)
})
nodes
.filter((node) => isRearNode(node, metrics) && !ventPairNames.has(node.nodeName))
.forEach((node) => {
const isRearRail =
node.size[LATERAL_AXIS] >= metrics.size[LATERAL_AXIS] * 0.7 &&
node.size[VERTICAL_AXIS] <= metrics.size[VERTICAL_AXIS] * 0.16 &&
node.size[DEPTH_AXIS] > metrics.size[DEPTH_AXIS] * 0.025
const verticalDistance =
node.center[VERTICAL_AXIS] - metrics.center[VERTICAL_AXIS]
const verticalDirection =
Math.abs(verticalDistance) <= metrics.size[VERTICAL_AXIS] * 0.06
? 0
: verticalDistance > 0
? 1
: -1
offsets.set(node.nodeName, [
0,
isRearRail ? verticalDirection * rearVerticalGap : 0,
rearOffset,
])
})
nodes.forEach((node) => {
const edgeDepthRail =
node.size[LATERAL_AXIS] <= metrics.size[LATERAL_AXIS] * 0.05 &&
node.size[VERTICAL_AXIS] <= metrics.size[VERTICAL_AXIS] * 0.06 &&
node.size[DEPTH_AXIS] >= metrics.size[DEPTH_AXIS] * 0.7 &&
Math.abs(node.center[LATERAL_AXIS] - metrics.center[LATERAL_AXIS]) >=
metrics.size[LATERAL_AXIS] * 0.4
if (!edgeDepthRail) return
const lateralDirection =
node.center[LATERAL_AXIS] >= metrics.center[LATERAL_AXIS] ? 1 : -1
const verticalDirection =
node.center[VERTICAL_AXIS] >= metrics.center[VERTICAL_AXIS] ? 1 : -1
offsets.set(node.nodeName, [
lateralDirection * metrics.sideDistance,
verticalDirection * metrics.shellVerticalDistance,
0,
])
})
return offsets
}

View File

@ -0,0 +1,94 @@
import type {
InstallationModelNodeSnapshot,
InstallationPartItem,
} from '../types'
import { buildInstallationAssemblyNodeOffsets } from './installationAssemblyExplodeLayout'
import { buildUnnamedProductStructureOffsets } from './installationUnnamedProductStructurePlanner'
function node(
nodeName: string,
center: [number, number, number],
size: [number, number, number],
): InstallationModelNodeSnapshot {
const half = size.map((value) => value / 2) as [number, number, number]
return {
nodeName,
center,
size,
min: [center[0] - half[0], center[1] - half[1], center[2] - half[2]],
max: [center[0] + half[0], center[1] + half[1], center[2] + half[2]],
}
}
describe('buildUnnamedProductStructureOffsets', () => {
it('keeps structural reference members and expands ordered layers outward', () => {
const offsets = buildUnnamedProductStructureOffsets([
node('center-upright', [0, 0, 0], [12, 420, 220]),
node('left-upright', [-180, 0, 0], [10, 300, 180]),
node('right-upright', [180, 0, 0], [10, 300, 180]),
node('top', [0, 210, 0], [420, 10, 220]),
node('bottom', [0, -210, 0], [420, 10, 220]),
node('front', [0, 0, 140], [420, 200, 8]),
node('back', [0, 0, -140], [420, 200, 8]),
])
expect(offsets).not.toBeNull()
expect(offsets?.get('center-upright')).toEqual([0, 0, 0])
expect(offsets?.get('left-upright')?.[0]).toBeLessThan(0)
expect(offsets?.get('right-upright')?.[0]).toBeGreaterThan(0)
expect(offsets?.get('top')?.[1]).toBeGreaterThan(0)
expect(offsets?.get('bottom')?.[1]).toBeLessThan(0)
expect(offsets?.get('front')?.[2]).toBeGreaterThan(0)
expect(offsets?.get('back')?.[2]).toBeLessThan(0)
})
it('keeps a middle layer between its upper and lower members', () => {
const offsets = buildUnnamedProductStructureOffsets([
node('center', [0, 0, 0], [12, 300, 180]),
node('upper', [0, 120, 0], [320, 10, 180]),
node('middle', [0, 20, 0], [320, 10, 180]),
node('lower', [0, -120, 0], [320, 10, 180]),
node('left', [-180, 0, 0], [10, 280, 180]),
node('right', [180, 0, 0], [10, 280, 180]),
])
expect(offsets).not.toBeNull()
expect(offsets?.get('middle')?.[1]).toBe(0)
expect(offsets?.get('upper')?.[1]).toBeGreaterThan(0)
expect(offsets?.get('lower')?.[1]).toBeLessThan(0)
})
it('explodes a simple table from its center and keeps joint hardware with the leg', () => {
const snapshots = [
node('top', [0, 400, 0], [800, 30, 400]),
node('left-leg', [-300, 190, 0], [80, 400, 80]),
node('right-leg', [300, 190, 0], [80, 400, 80]),
node('center-rail', [0, 200, 0], [520, 60, 60]),
node('left-fastener', [-300, 390, 0], [10, 40, 10]),
]
const parts = snapshots.map<InstallationPartItem>((snapshot) => ({
id: snapshot.nodeName,
nodeName: snapshot.nodeName,
nodeNames: [snapshot.nodeName],
code: snapshot.nodeName,
name: snapshot.nodeName,
quantity: 1,
kind: snapshot.nodeName === 'left-fastener' ? 'hardware' : 'part',
offset: [0, 0, 0],
rotation: [0, 0, 0, 1],
nodeTransforms: {},
createdAt: '2026-08-25T00:00:00.000Z',
}))
const offsets = buildInstallationAssemblyNodeOffsets(parts, snapshots)
const legOffset = offsets.get('left-leg') ?? [0, 0, 0]
const hardwareOffset = offsets.get('left-fastener') ?? [0, 0, 0]
expect(offsets.get('top')?.[1]).toBeGreaterThan(0)
expect(offsets.get('left-leg')?.[0]).toBeLessThan(0)
expect(offsets.get('right-leg')?.[0]).toBeGreaterThan(0)
expect(offsets.get('center-rail')).toEqual([0, 0, 0])
expect(hardwareOffset[0]).toBe(legOffset[0])
expect(hardwareOffset[1]).toBeLessThan(legOffset[1])
expect(hardwareOffset[2]).toBe(legOffset[2])
})
})

View File

@ -0,0 +1,18 @@
import type { InstallationModelNodeSnapshot } from '../types'
import { buildInstallationOrientationLayerOffsets } from './installationOrientationLayerPlanner'
/**
* Builds a name-free layout for products that do not match the cabinet shell.
* The projection-layer planner keeps every original axis layer ordered instead
* of merging members only because they sit at a similar distance from center.
*/
export function buildUnnamedProductStructureOffsets(
nodes: InstallationModelNodeSnapshot[],
) {
if (nodes.length < 4) return null
const offsets = buildInstallationOrientationLayerOffsets(nodes)
const movedCount = [...offsets.values()].filter((offset) =>
offset.some((value) => Math.abs(value) > 0.01),
).length
return movedCount >= 2 ? offsets : null
}

View File

@ -0,0 +1,429 @@
import type { InstallationModelNodeSnapshot, InstallationPartItem } from '../types'
import { buildInstallationAssemblyNodeOffsets } from './installationAssemblyExplodeLayout'
import { buildUnnamedCabinetStructureOffsets } from './installationUnnamedStructureExplodePlanner'
function node(
nodeName: string,
center: [number, number, number],
size: [number, number, number],
): InstallationModelNodeSnapshot {
const half = size.map((value) => value / 2) as [number, number, number]
return {
nodeName,
center,
size,
min: [center[0] - half[0], center[1] - half[1], center[2] - half[2]],
max: [center[0] + half[0], center[1] + half[1], center[2] + half[2]],
}
}
function drawerNodes(prefix: string, centerX: number) {
return [
node(`${prefix}-face`, [centerX, 908, 163], [340, 168, 12]),
node(`${prefix}-bottom`, [centerX, 859.2, 29.5], [313, 2.5, 265]),
node(`${prefix}-back`, [centerX, 907.5, -104], [327, 117, 12]),
node(`${prefix}-left`, [centerX - 157.5, 896, 29.5], [12, 96, 255]),
node(`${prefix}-right`, [centerX + 157.5, 896, 29.5], [12, 96, 255]),
]
}
function cabinetNodes() {
return [
node('side-a', [-692, 580, 0], [15, 800, 340]),
node('side-b', [692, 580, 0], [15, 800, 340]),
node('cap-a', [0, 158, 0], [1340, 15, 340]),
node('cap-b', [0, 1003, 0], [1340, 15, 340]),
node('back-a', [0, 397, -162], [1380, 281, 3]),
node('back-b', [0, 764, -162], [1380, 281, 3]),
node('back-rail-low', [0, 213, -162], [1370, 96, 12]),
node('back-rail-mid', [0, 580, -162], [1370, 96, 12]),
node('back-rail-high', [0, 947, -162], [1370, 96, 12]),
node('vent-front', [345, 397, -160], [65, 65, 5]),
node('vent-rear', [345, 397, -164], [65, 65, 5]),
node('core', [0, 501, -7], [12, 671, 298]),
node('upper-a', [-345, 922, 0], [12, 147, 312]),
node('upper-b', [0, 922, 0], [12, 147, 312]),
node('upper-c', [345, 922, 0], [12, 147, 312]),
node('upper-deck', [0, 842, 70], [1370, 12, 168]),
node('shelf-low-a', [-346, 319, -13], [678, 12, 240]),
node('shelf-low-b', [346, 319, -13], [678, 12, 240]),
node('shelf-mid-a', [-346, 489, -13], [678, 12, 240]),
node('shelf-mid-b', [346, 489, -13], [678, 12, 240]),
node('shelf-high-a', [-346, 659, -13], [678, 12, 240]),
node('shelf-high-b', [346, 659, -13], [678, 12, 240]),
node('edge-low-a', [-685, 165, 0], [30, 30, 340]),
node('edge-low-b', [685, 165, 0], [30, 30, 340]),
node('edge-high-a', [-685, 995, 0], [30, 30, 340]),
node('edge-high-b', [685, 995, 0], [30, 30, 340]),
node('door-a', [-513, 549, 163], [340, 500, 12]),
node('door-b', [-171, 549, 163], [340, 500, 12]),
node('door-c', [171, 549, 163], [340, 500, 12]),
node('door-d', [513, 549, 163], [340, 500, 12]),
...drawerNodes('drawer-a', -513),
...drawerNodes('drawer-b', -171),
...drawerNodes('drawer-c', 171),
...drawerNodes('drawer-d', 513),
]
}
function cabinetNodesWithBottomSupports() {
return [
...cabinetNodes(),
node('support-left', [-520, 75.6, 100], [130, 148.8, 130]),
node('support-left-rear', [-520, 75.6, -100], [130, 148.8, 130]),
node('support-right', [520, 75.6, 100], [130, 148.8, 130]),
node('support-right-rear', [520, 75.6, -100], [130, 148.8, 130]),
node('support-center', [0, 75, 0], [55, 150, 55]),
]
}
function cabinetNodesWithLocalVerticalRegion() {
return [
...cabinetNodes(),
node('local-upper', [0, 829, 73], [1320, 12, 168]),
node('local-reference', [0, 798, 148], [1320, 50, 12]),
node('local-lower', [0, 767, 127], [1320, 12, 86]),
]
}
describe('buildUnnamedCabinetStructureOffsets', () => {
it('derives shell directions without names or catalog roles', () => {
const offsets = buildUnnamedCabinetStructureOffsets(cabinetNodes())
expect(offsets).not.toBeNull()
expect(offsets?.get('side-a')?.[0]).toBeLessThan(0)
expect(offsets?.get('side-b')?.[0]).toBeGreaterThan(0)
expect(offsets?.get('cap-a')?.[1]).toBeLessThan(0)
expect(offsets?.get('cap-b')?.[1]).toBeGreaterThan(0)
expect(offsets?.get('back-a')?.[2]).toBeLessThan(0)
expect(offsets?.get('core')).toEqual([0, 0, 0])
})
it('keeps recognizing the shell when feet extend below the bottom panel', () => {
const offsets = buildUnnamedCabinetStructureOffsets(cabinetNodesWithBottomSupports())
const supportNames = [
'support-left',
'support-left-rear',
'support-right',
'support-right-rear',
'support-center',
]
const supportOffsets = supportNames.map((name) => offsets?.get(name))
expect(offsets).not.toBeNull()
expect(offsets?.get('side-a')?.[0]).toBeLessThan(0)
expect(offsets?.get('side-b')?.[0]).toBeGreaterThan(0)
expect(offsets?.get('cap-a')?.[1]).toBeLessThan(0)
expect(offsets?.get('cap-b')?.[1]).toBeGreaterThan(0)
expect(new Set(supportOffsets.map((offset) => JSON.stringify(offset))).size).toBe(1)
expect(supportOffsets[0]?.[0]).toBe(0)
expect(supportOffsets[0]?.[1]).toBeLessThan(offsets?.get('cap-a')?.[1] ?? 0)
expect(supportOffsets[0]?.[2]).toBe(0)
})
it('moves a complete drawer forward around its local bottom reference', () => {
const offsets = buildUnnamedCabinetStructureOffsets(cabinetNodes())
const face = offsets?.get('drawer-a-face')
const back = offsets?.get('drawer-a-back')
const bottom = offsets?.get('drawer-a-bottom')
expect(face?.[2]).toBeGreaterThan(0)
expect(back?.[2]).toBeGreaterThan(0)
expect(bottom?.[2]).toBeGreaterThan(0)
expect(face?.[2]).toBeGreaterThan(bottom?.[2] ?? 0)
expect(back?.[2]).toBeLessThan(bottom?.[2] ?? 0)
expect(offsets?.get('drawer-a-left')?.[0]).toBeLessThan(bottom?.[0] ?? 0)
expect(offsets?.get('drawer-a-right')?.[0]).toBeGreaterThan(bottom?.[0] ?? 0)
expect(bottom?.[1]).toBe(0)
})
it('keeps every drawer independent and increases the space between them', () => {
const nodes = cabinetNodes()
const offsets = buildUnnamedCabinetStructureOffsets(cabinetNodes())
const drawerFaceOffsets = ['a', 'b', 'c', 'd'].map(
(suffix) => offsets?.get(`drawer-${suffix}-face`),
)
const finalBottomX = ['a', 'b', 'c', 'd'].map((suffix) => {
const bottom = nodes.find((item) => item.nodeName === `drawer-${suffix}-bottom`)
return (bottom?.center[0] ?? 0) + (offsets?.get(`drawer-${suffix}-bottom`)?.[0] ?? 0)
})
expect(drawerFaceOffsets.every((offset) => offset && offset[2] > 0)).toBe(true)
expect(finalBottomX[1] - finalBottomX[0]).toBeGreaterThan(342)
expect(finalBottomX[2] - finalBottomX[1]).toBeGreaterThan(342)
expect(finalBottomX[3] - finalBottomX[2]).toBeGreaterThan(342)
expect(offsets?.has('drawer-d-bottom')).toBe(true)
expect(offsets?.has('drawer-d-back')).toBe(true)
})
it('keeps structural groups together on the intended relative axes', () => {
const offsets = buildUnnamedCabinetStructureOffsets(cabinetNodes())
expect(offsets?.get('upper-a')?.[0]).toBeLessThan(0)
expect(offsets?.get('upper-b')).toEqual([0, 0, 0])
expect(offsets?.get('upper-c')?.[0]).toBeGreaterThan(0)
expect(offsets?.get('upper-a')?.[1]).toBe(0)
expect(offsets?.get('upper-c')?.[1]).toBe(0)
expect(offsets?.get('upper-a')?.[2]).toBe(0)
expect(offsets?.get('upper-c')?.[2]).toBe(0)
expect(offsets?.get('shelf-low-a')?.[1]).toBeLessThan(
offsets?.get('shelf-mid-a')?.[1] ?? 0,
)
expect(offsets?.get('shelf-mid-a')?.[1]).toBeLessThan(
offsets?.get('shelf-high-a')?.[1] ?? 0,
)
expect(offsets?.get('shelf-low-a')?.[1]).toBe(offsets?.get('shelf-low-b')?.[1])
expect(offsets?.get('shelf-low-a')?.[2]).toBe(offsets?.get('shelf-low-b')?.[2])
expect(offsets?.get('shelf-low-a')?.[0]).toBeLessThan(0)
expect(offsets?.get('shelf-low-b')?.[0]).toBeGreaterThan(0)
expect(offsets?.get('edge-low-a')?.[1]).toBeLessThan(0)
expect(offsets?.get('edge-high-a')?.[1]).toBeGreaterThan(0)
expect(offsets?.get('edge-low-a')?.[0]).toBeCloseTo(
offsets?.get('side-a')?.[0] ?? 0,
)
expect(offsets?.get('edge-high-a')?.[1]).toBeCloseTo(
offsets?.get('cap-b')?.[1] ?? 0,
)
expect(offsets?.get('upper-deck')?.[1]).toBeGreaterThan(
offsets?.get('shelf-high-a')?.[1] ?? 0,
)
expect(offsets?.get('upper-deck')?.[2]).toBe(0)
})
it('separates paired shelf levels vertically while keeping each level aligned', () => {
const offsets = buildUnnamedCabinetStructureOffsets(cabinetNodes())
expect(offsets?.get('shelf-low-a')?.[1]).toBe(offsets?.get('shelf-low-b')?.[1])
expect(offsets?.get('shelf-mid-a')?.[1]).toBe(offsets?.get('shelf-mid-b')?.[1])
expect(offsets?.get('shelf-high-a')?.[1]).toBe(offsets?.get('shelf-high-b')?.[1])
expect(offsets?.get('shelf-low-a')?.[1]).toBeLessThan(
offsets?.get('shelf-mid-a')?.[1] ?? 0,
)
expect(offsets?.get('shelf-mid-a')?.[1]).toBeLessThan(
offsets?.get('shelf-high-a')?.[1] ?? 0,
)
expect(offsets?.get('shelf-low-a')?.[0]).toBeLessThan(0)
expect(offsets?.get('shelf-low-b')?.[0]).toBeGreaterThan(0)
expect(offsets?.get('shelf-high-a')?.[0]).toBeLessThan(0)
expect(offsets?.get('shelf-high-b')?.[0]).toBeGreaterThan(0)
})
it('moves a full-width three-piece local region right as one assembly', () => {
const sourceNodes = cabinetNodesWithLocalVerticalRegion()
const offsets = buildUnnamedCabinetStructureOffsets(
sourceNodes,
)
const upper = offsets?.get('local-upper')
const reference = offsets?.get('local-reference')
const lower = offsets?.get('local-lower')
expect(upper?.[0]).toBeGreaterThan(0)
expect(reference?.[0]).toBe(upper?.[0])
expect(lower?.[0]).toBe(upper?.[0])
expect(upper?.[1]).toBe(0)
expect(reference?.[1]).toBe(0)
expect(lower?.[1]).toBe(0)
expect(upper?.[2]).toBe(0)
expect(reference?.[2]).toBe(0)
expect(lower?.[2]).toBe(0)
const modelWidth =
Math.max(...sourceNodes.map((item) => item.max[0])) -
Math.min(...sourceNodes.map((item) => item.min[0]))
expect(upper?.[0]).toBeGreaterThan(modelWidth * 0.2)
expect(offsets?.get('side-b')?.[0]).toBeGreaterThan(upper?.[0] ?? 0)
const baselineOffsets = buildUnnamedCabinetStructureOffsets(cabinetNodes())
expect(offsets?.get('side-a')).toEqual(baselineOffsets?.get('side-a'))
expect(offsets?.get('side-b')?.[0]).toBeGreaterThan(
baselineOffsets?.get('side-b')?.[0] ?? 0,
)
const sourceUpper = sourceNodes.find(
(node) => node.nodeName === 'local-upper',
)
const sourceReference = sourceNodes.find(
(node) => node.nodeName === 'local-reference',
)
const sourceLower = sourceNodes.find(
(node) => node.nodeName === 'local-lower',
)
expect(
(sourceUpper?.center[1] ?? 0) + (upper?.[1] ?? 0),
).toBeGreaterThan(
(sourceReference?.center[1] ?? 0) + (reference?.[1] ?? 0) + 1,
)
expect(
(sourceReference?.center[1] ?? 0) + (reference?.[1] ?? 0),
).toBeGreaterThan(
(sourceLower?.center[1] ?? 0) + (lower?.[1] ?? 0) + 1,
)
})
it('aligns rear boards by depth and separates rails vertically', () => {
const offsets = buildUnnamedCabinetStructureOffsets(cabinetNodes())
const backSheetDepth = offsets?.get('back-a')?.[2] ?? 0
expect(backSheetDepth).toBeLessThan(0)
expect(offsets?.get('back-rail-low')?.[2]).toBe(backSheetDepth)
expect(offsets?.get('back-rail-mid')?.[2]).toBe(backSheetDepth)
expect(offsets?.get('back-rail-high')?.[2]).toBe(backSheetDepth)
expect(offsets?.get('back-rail-low')?.[1]).toBeLessThan(0)
expect(offsets?.get('back-rail-mid')?.[1]).toBe(0)
expect(offsets?.get('back-rail-high')?.[1]).toBeGreaterThan(0)
expect(offsets?.get('vent-front')?.[2]).toBeGreaterThan(backSheetDepth)
expect(offsets?.get('vent-rear')?.[2]).toBeLessThan(backSheetDepth)
})
it('keeps paired vent offsets when the catalog classifies them as hardware', () => {
const snapshots = cabinetNodes()
const parts = snapshots.map<InstallationPartItem>((snapshot) => ({
id: snapshot.nodeName,
nodeName: snapshot.nodeName,
nodeNames: [snapshot.nodeName],
code: snapshot.nodeName,
name: snapshot.nodeName,
quantity: 1,
kind: snapshot.nodeName.startsWith('vent-') ? 'hardware' : 'part',
offset: [0, 0, 0],
rotation: [0, 0, 0, 1],
nodeTransforms: {},
createdAt: '2026-08-24T00:00:00.000Z',
}))
const offsets = buildInstallationAssemblyNodeOffsets(parts, snapshots)
const backSheetDepth = offsets.get('back-a')?.[2] ?? 0
expect(offsets.get('vent-front')?.[2]).toBeGreaterThan(backSheetDepth)
expect(offsets.get('vent-rear')?.[2]).toBeLessThan(backSheetDepth)
})
it('keeps elongated hardware below its board on the board hole axis', () => {
const snapshots = [
...cabinetNodes(),
node('shelf-fastener', [0, 819, 70], [8, 30, 8]),
]
const parts = snapshots.map<InstallationPartItem>((snapshot) => ({
id: snapshot.nodeName,
nodeName: snapshot.nodeName,
nodeNames: [snapshot.nodeName],
code: snapshot.nodeName,
name: snapshot.nodeName,
quantity: 1,
kind: snapshot.nodeName === 'shelf-fastener' ? 'hardware' : 'part',
offset: [0, 0, 0],
rotation: [0, 0, 0, 1],
nodeTransforms: {},
createdAt: '2026-08-25T00:00:00.000Z',
}))
const offsets = buildInstallationAssemblyNodeOffsets(parts, snapshots)
const shelfOffset = offsets.get('upper-deck') ?? [0, 0, 0]
const hardwareOffset = offsets.get('shelf-fastener') ?? [0, 0, 0]
expect(hardwareOffset[0]).toBe(shelfOffset[0])
expect(hardwareOffset[1]).toBeLessThan(shelfOffset[1])
expect(hardwareOffset[2]).toBe(shelfOffset[2])
})
it('makes drawer hardware follow its local side before leaving the hole', () => {
const snapshots = [
...cabinetNodes(),
node('drawer-fastener', [-663, 896, 40], [24, 8, 8]),
]
const parts = snapshots.map<InstallationPartItem>((snapshot) => ({
id: snapshot.nodeName,
nodeName: snapshot.nodeName,
nodeNames: [snapshot.nodeName],
code: snapshot.nodeName,
name: snapshot.nodeName,
quantity: 1,
kind: snapshot.nodeName === 'drawer-fastener' ? 'hardware' : 'part',
offset: [0, 0, 0],
rotation: [0, 0, 0, 1],
nodeTransforms: {},
createdAt: '2026-08-25T00:00:00.000Z',
}))
const offsets = buildInstallationAssemblyNodeOffsets(parts, snapshots)
const sideOffset = offsets.get('drawer-a-left') ?? [0, 0, 0]
const hardwareOffset = offsets.get('drawer-fastener') ?? [0, 0, 0]
expect(hardwareOffset[0]).toBeGreaterThan(sideOffset[0])
expect(hardwareOffset[1]).toBe(sideOffset[1])
expect(hardwareOffset[2]).toBe(sideOffset[2])
})
it('makes support hardware follow its lower product group before separating down', () => {
const snapshots = [
...cabinetNodesWithBottomSupports(),
node('support-fastener-left', [-520, 154, 100], [10, 12, 10]),
node('support-fastener-center', [0, 170, 96], [10, 40, 10]),
]
const parts = snapshots.map<InstallationPartItem>((snapshot) => ({
id: snapshot.nodeName,
nodeName: snapshot.nodeName,
nodeNames: [snapshot.nodeName],
code: snapshot.nodeName,
name: snapshot.nodeName,
quantity: 1,
kind: snapshot.nodeName.startsWith('support-fastener') ? 'hardware' : 'part',
offset: [0, 0, 0],
rotation: [0, 0, 0, 1],
nodeTransforms: {},
createdAt: '2026-08-24T00:00:00.000Z',
}))
const offsets = buildInstallationAssemblyNodeOffsets(parts, snapshots)
const supportOffset = offsets.get('support-left') ?? [0, 0, 0]
const fastenerOffset = offsets.get('support-fastener-left') ?? [0, 0, 0]
const centerFastenerOffset = offsets.get('support-fastener-center') ?? [0, 0, 0]
expect(supportOffset[1]).toBeLessThan(offsets.get('cap-a')?.[1] ?? 0)
expect(fastenerOffset[0]).toBe(supportOffset[0])
expect(fastenerOffset[2]).toBe(supportOffset[2])
const bottomOffset = offsets.get('cap-a') ?? [0, 0, 0]
expect(fastenerOffset[1]).toBeGreaterThan(supportOffset[1])
expect(fastenerOffset[1]).toBeLessThan(bottomOffset[1])
expect(centerFastenerOffset[0]).toBe(bottomOffset[0])
expect(centerFastenerOffset[1]).toBeGreaterThan(bottomOffset[1])
expect(centerFastenerOffset[2]).toBe(bottomOffset[2])
})
it('pulls doors to one front plane and separates their ordered facade intervals', () => {
const nodes = cabinetNodes()
const offsets = buildUnnamedCabinetStructureOffsets(nodes)
const door = nodes.find((item) => item.nodeName === 'door-a')
const drawerFace = nodes.find((item) => item.nodeName === 'drawer-a-face')
const doorFinalDepth =
(door?.center[2] ?? 0) + (offsets?.get('door-a')?.[2] ?? 0)
const drawerFinalDepth =
(drawerFace?.center[2] ?? 0) + (offsets?.get('drawer-a-face')?.[2] ?? 0)
const doorFinalLateral =
(door?.center[0] ?? 0) + (offsets?.get('door-a')?.[0] ?? 0)
const drawerFinalLateral =
(drawerFace?.center[0] ?? 0) + (offsets?.get('drawer-a-face')?.[0] ?? 0)
expect(offsets?.get('door-a')?.[2]).toBeGreaterThan(0)
expect(offsets?.get('door-d')?.[2]).toBeGreaterThan(0)
expect(doorFinalDepth).toBeCloseTo(drawerFinalDepth)
expect(doorFinalLateral).toBeCloseTo(drawerFinalLateral)
const finalDoorIntervals = ['a', 'b', 'c', 'd'].map((suffix) => {
const doorNode = nodes.find((item) => item.nodeName === `door-${suffix}`)
const lateralOffset = offsets?.get(`door-${suffix}`)?.[0] ?? 0
return {
min: (doorNode?.min[0] ?? 0) + lateralOffset,
max: (doorNode?.max[0] ?? 0) + lateralOffset,
}
})
finalDoorIntervals.slice(1).forEach((interval, index) => {
expect(interval.min).toBeGreaterThan(finalDoorIntervals[index].max)
})
})
it('returns null when the geometry does not form a cabinet shell', () => {
expect(
buildUnnamedCabinetStructureOffsets([
node('a', [0, 0, 0], [100, 20, 20]),
node('b', [0, 100, 0], [100, 20, 20]),
]),
).toBeNull()
})
})

View File

@ -0,0 +1,456 @@
import type {
InstallationModelNodeSnapshot,
InstallationVector3,
} from '../types'
import {
buildUnnamedCabinetVentPairOffsets,
buildUnnamedCabinetVisibilityOffsets,
} from './installationUnnamedCabinetVisibilityPlanner'
import { buildInstallationLocalVerticalLayerPlan } from './installationLocalVerticalLayerPlanner'
import { buildInstallationLocalHorizontalPartitionPlan } from './installationLocalHorizontalPartitionPlanner'
import { buildInstallationOrientationLayerOffsets } from './installationOrientationLayerPlanner'
import { buildInstallationDrawerAssemblyPlan } from './installationDrawerAssemblyPlanner'
type Axis = 0 | 1 | 2
type Direction = -1 | 1
const LATERAL_AXIS: Axis = 0
const VERTICAL_AXIS: Axis = 1
const DEPTH_AXIS: Axis = 2
interface ModelBounds {
center: InstallationVector3
min: InstallationVector3
max: InstallationVector3
size: InstallationVector3
diagonal: number
}
function calculateBounds(nodes: InstallationModelNodeSnapshot[]): ModelBounds {
const min: InstallationVector3 = [...nodes[0].min]
const max: InstallationVector3 = [...nodes[0].max]
nodes.slice(1).forEach((node) => {
min[0] = Math.min(min[0], node.min[0])
min[1] = Math.min(min[1], node.min[1])
min[2] = Math.min(min[2], node.min[2])
max[0] = Math.max(max[0], node.max[0])
max[1] = Math.max(max[1], node.max[1])
max[2] = Math.max(max[2], node.max[2])
})
const size: InstallationVector3 = [
Math.max(max[0] - min[0], 1),
Math.max(max[1] - min[1], 1),
Math.max(max[2] - min[2], 1),
]
return {
min,
max,
size,
center: [min[0] + size[0] / 2, min[1] + size[1] / 2, min[2] + size[2] / 2],
diagonal: Math.hypot(size[0], size[1], size[2]),
}
}
function isFlatOnAxis(
node: InstallationModelNodeSnapshot,
axis: Axis,
ratio = 0.16,
) {
const otherAxes = ([0, 1, 2] as Axis[]).filter((candidate) => candidate !== axis)
return node.size[axis] <= Math.max(node.size[otherAxes[0]], node.size[otherAxes[1]], 1) * ratio
}
function isOuterSidePanel(node: InstallationModelNodeSnapshot, model: ModelBounds) {
return (
isFlatOnAxis(node, LATERAL_AXIS, 0.12) &&
node.size[VERTICAL_AXIS] >= model.size[VERTICAL_AXIS] * 0.55 &&
node.size[DEPTH_AXIS] >= model.size[DEPTH_AXIS] * 0.55 &&
Math.abs(node.center[LATERAL_AXIS] - model.center[LATERAL_AXIS]) >=
model.size[LATERAL_AXIS] * 0.38
)
}
function isOuterHorizontalPanel(node: InstallationModelNodeSnapshot, model: ModelBounds) {
return (
isFlatOnAxis(node, VERTICAL_AXIS, 0.12) &&
node.size[LATERAL_AXIS] >= model.size[LATERAL_AXIS] * 0.7 &&
node.size[DEPTH_AXIS] >= model.size[DEPTH_AXIS] * 0.55
)
}
function deriveCabinetBodyBounds(
nodes: InstallationModelNodeSnapshot[],
overallModel: ModelBounds,
) {
const sidePanels = nodes.filter((node) => isOuterSidePanel(node, overallModel))
const horizontalShellPanels = nodes.filter((node) =>
isOuterHorizontalPanel(node, overallModel),
)
// Feet and small supports can extend below the cabinet shell. Build the
// role-detection frame from the shell anchors instead of the whole model.
if (sidePanels.length < 2 || horizontalShellPanels.length < 2) {
return overallModel
}
return calculateBounds([...sidePanels, ...horizontalShellPanels])
}
function isCabinetStructure(nodes: InstallationModelNodeSnapshot[], model: ModelBounds) {
if (
model.size[LATERAL_AXIS] <= model.size[DEPTH_AXIS] * 1.35 ||
model.size[VERTICAL_AXIS] <= model.size[DEPTH_AXIS] * 1.35
) {
return false
}
const sidePanels = nodes.filter((node) => isOuterSidePanel(node, model))
const horizontalShellPanels = nodes.filter((node) => isOuterHorizontalPanel(node, model))
const hasTop = horizontalShellPanels.some(
(node) =>
node.center[VERTICAL_AXIS] >= model.max[VERTICAL_AXIS] - model.size[VERTICAL_AXIS] * 0.12,
)
const hasBottom = horizontalShellPanels.some(
(node) =>
node.center[VERTICAL_AXIS] <= model.min[VERTICAL_AXIS] + model.size[VERTICAL_AXIS] * 0.12,
)
return sidePanels.length >= 2 && hasTop && hasBottom
}
function resolveFrontDirection(
nodes: InstallationModelNodeSnapshot[],
model: ModelBounds,
): Direction {
const facePanels = nodes.filter(
(node) =>
isFlatOnAxis(node, DEPTH_AXIS, 0.12) &&
node.size[LATERAL_AXIS] >= model.size[LATERAL_AXIS] * 0.08 &&
node.size[VERTICAL_AXIS] >= model.size[VERTICAL_AXIS] * 0.04,
)
const scoreDirection = (direction: Direction) =>
facePanels.reduce((score, node) => {
const signedDistance =
direction * (node.center[DEPTH_AXIS] - model.center[DEPTH_AXIS])
if (signedDistance < model.size[DEPTH_AXIS] * 0.4) return score
const segmentedFacade =
node.size[LATERAL_AXIS] <= model.size[LATERAL_AXIS] * 0.65 &&
node.size[VERTICAL_AXIS] <= model.size[VERTICAL_AXIS] * 0.7
const normalizedArea =
(node.size[LATERAL_AXIS] * node.size[VERTICAL_AXIS]) /
Math.max(model.size[LATERAL_AXIS] * model.size[VERTICAL_AXIS], 1)
return score + (segmentedFacade ? 2 : 0.35) + Math.sqrt(normalizedArea)
}, 0)
return scoreDirection(1) >= scoreDirection(-1) ? 1 : -1
}
function isFrontPanel(
node: InstallationModelNodeSnapshot,
model: ModelBounds,
frontDirection: Direction,
) {
return (
isFlatOnAxis(node, DEPTH_AXIS, 0.12) &&
frontDirection * (node.center[DEPTH_AXIS] - model.center[DEPTH_AXIS]) >=
model.size[DEPTH_AXIS] * 0.38
)
}
function buildOrderedVisibleOffsets(
nodes: InstallationModelNodeSnapshot[],
axis: Axis,
minimumGap: number,
) {
const ordered = nodes
.slice()
.sort((left, right) => left.center[axis] - right.center[axis])
.map((node) => ({ node, shift: 0 }))
ordered.slice(1).forEach((entry, index) => {
const previous = ordered[index]
entry.shift = Math.max(
0,
previous.node.max[axis] + previous.shift + minimumGap - entry.node.min[axis],
)
})
const recenter =
((ordered[0]?.shift ?? 0) + (ordered[ordered.length - 1]?.shift ?? 0)) / 2
return new Map(
ordered.map(({ node, shift }) => [node.nodeName, shift - recenter]),
)
}
function resolveLocalRightwardAssemblyMembers(
nodes: InstallationModelNodeSnapshot[],
memberNames: Set<string>,
model: ModelBounds,
) {
const members = nodes.filter((node) => memberNames.has(node.nodeName))
if (members.length !== 3) return []
// A full-width three-piece assembly can span consecutive local Y/Z layers.
// Treating it as an upper/reference/lower stack makes the middle piece
// collide with its neighbours; the assembly must travel right as one unit
// while its original Y/Z spacing remains intact.
return members.every(
(node) =>
Math.abs(node.center[LATERAL_AXIS] - model.center[LATERAL_AXIS]) <=
model.size[LATERAL_AXIS] * 0.08 &&
node.size[LATERAL_AXIS] >= model.size[LATERAL_AXIS] * 0.72,
)
? members
: []
}
function resolveRightwardAssemblyShift(
members: InstallationModelNodeSnapshot[],
model: ModelBounds,
minimumLayerGap: number,
) {
const groupMin = Math.min(...members.map((member) => member.min[LATERAL_AXIS]))
const groupMax = Math.max(...members.map((member) => member.max[LATERAL_AXIS]))
const groupSpan = Math.max(groupMax - groupMin, 1)
return Math.max(
minimumLayerGap,
Math.min(groupSpan, model.size[LATERAL_AXIS]) * 0.24,
model.size[DEPTH_AXIS] * 0.7,
model.diagonal * 0.1,
)
}
function insertRightwardAssemblyLayer(
offsets: Map<string, InstallationVector3>,
structuralNodes: InstallationModelNodeSnapshot[],
members: InstallationModelNodeSnapshot[],
shift: number,
) {
const memberNames = new Set(members.map((member) => member.nodeName))
// The assembly becomes a new positive-X layer. Existing layers on that side
// move out by the same amount so their original order cannot be crossed.
structuralNodes.forEach((node) => {
if (memberNames.has(node.nodeName)) return
const current = offsets.get(node.nodeName)
if (!current || current[LATERAL_AXIS] <= 0) return
offsets.set(node.nodeName, [current[0] + shift, current[1], current[2]])
})
members.forEach((member) => {
offsets.set(member.nodeName, [shift, 0, 0])
})
}
export function buildUnnamedCabinetStructureOffsets(
structuralNodes: InstallationModelNodeSnapshot[],
visibilityNodes: InstallationModelNodeSnapshot[] = structuralNodes,
): Map<string, InstallationVector3> | null {
if (structuralNodes.length < 6) return null
const overallModel = calculateBounds(structuralNodes)
const model = deriveCabinetBodyBounds(structuralNodes, overallModel)
if (!isCabinetStructure(structuralNodes, model)) return null
const offsets = new Map<string, InstallationVector3>()
const frontDirection = resolveFrontDirection(structuralNodes, model)
const sideDistance = Math.max(model.size[LATERAL_AXIS] * 0.24, model.diagonal * 0.12)
const shellVerticalDistance = Math.max(
model.size[VERTICAL_AXIS] * 0.16,
model.diagonal * 0.075,
)
const backDistance = Math.max(model.size[DEPTH_AXIS] * 0.9, model.diagonal * 0.16)
const nodeByName = new Map(
[...structuralNodes, ...visibilityNodes].map((node) => [node.nodeName, node]),
)
const assign = (node: InstallationModelNodeSnapshot, offset: InstallationVector3) => {
offsets.set(node.nodeName, offset)
}
const frontPanels = structuralNodes.filter((node) =>
isFrontPanel(node, model, frontDirection),
)
const facadeForwardDistance =
Math.max(model.size[DEPTH_AXIS] * 1.4, model.diagonal * 0.25) +
Math.max(model.size[DEPTH_AXIS] * 0.14, model.diagonal * 0.025)
const facadeTargetSignedDepth =
(frontDirection > 0 ? model.max[DEPTH_AXIS] : -model.min[DEPTH_AXIS]) +
facadeForwardDistance
const drawerPlan = buildInstallationDrawerAssemblyPlan(
structuralNodes,
{ model, frontDirection, facadeTargetSignedDepth },
)
const drawerGroups = drawerPlan.groups
const drawerFaceNames = new Set(drawerGroups.map((group) => group.face.nodeName))
const doors = frontPanels
.filter(
(node) =>
!drawerFaceNames.has(node.nodeName) &&
node.size[LATERAL_AXIS] <= model.size[LATERAL_AXIS] * 0.65 &&
node.size[VERTICAL_AXIS] >= model.size[VERTICAL_AXIS] * 0.25,
)
.sort((left, right) => left.center[LATERAL_AXIS] - right.center[LATERAL_AXIS])
const facadeGroupNames = new Set([
...doors.map((door) => door.nodeName),
...drawerGroups.flatMap((group) => [
group.face.nodeName,
group.bottom.nodeName,
group.back.nodeName,
group.leftSide.nodeName,
group.rightSide.nodeName,
]),
])
const orientationNodes = structuralNodes.filter((node) => {
if (facadeGroupNames.has(node.nodeName)) return false
const rearNode =
isFlatOnAxis(node, DEPTH_AXIS, 0.16) &&
frontDirection * (node.center[DEPTH_AXIS] - model.center[DEPTH_AXIS]) <=
-model.size[DEPTH_AXIS] * 0.38
const edgeDepthRail =
node.size[LATERAL_AXIS] <= model.size[LATERAL_AXIS] * 0.05 &&
node.size[VERTICAL_AXIS] <= model.size[VERTICAL_AXIS] * 0.06 &&
node.size[DEPTH_AXIS] >= model.size[DEPTH_AXIS] * 0.7 &&
Math.abs(node.center[LATERAL_AXIS] - model.center[LATERAL_AXIS]) >=
model.size[LATERAL_AXIS] * 0.4
return !rearNode && !edgeDepthRail
})
const verticalLayerGap = Math.max(
model.size[VERTICAL_AXIS] * 0.075,
model.diagonal * 0.04,
)
const localVerticalLayerPlan = buildInstallationLocalVerticalLayerPlan(
orientationNodes,
{ size: model.size, diagonal: model.diagonal },
verticalLayerGap,
)
const lateralLayerGap = Math.max(
model.size[LATERAL_AXIS] * 0.09,
model.diagonal * 0.045,
)
const localHorizontalPartitionPlan =
buildInstallationLocalHorizontalPartitionPlan(
orientationNodes,
model,
lateralLayerGap,
)
// Build the complete front-view row/column hierarchy before applying the
// cabinet-specific drawer, facade and rear overrides below. This keeps every
// original projection layer distinct, including short uprights between decks.
const orientationOffsets = buildInstallationOrientationLayerOffsets(
orientationNodes,
{
gapByAxis: {
[LATERAL_AXIS]: lateralLayerGap,
[VERTICAL_AXIS]: verticalLayerGap,
[DEPTH_AXIS]: Math.max(model.size[DEPTH_AXIS] * 0.18, model.diagonal * 0.06),
},
})
orientationOffsets.forEach((offset, nodeName) => {
offsets.set(nodeName, offset)
})
localVerticalLayerPlan.offsets.forEach((offset, nodeName) => {
offsets.set(nodeName, offset)
})
localHorizontalPartitionPlan.offsets.forEach((offset, nodeName) => {
offsets.set(nodeName, offset)
})
const localRightwardAssemblyMembers = resolveLocalRightwardAssemblyMembers(
orientationNodes,
localVerticalLayerPlan.memberNames,
model,
)
const localRightwardAssemblyShift =
localRightwardAssemblyMembers.length > 0
? resolveRightwardAssemblyShift(
localRightwardAssemblyMembers,
model,
lateralLayerGap,
)
: 0
if (localRightwardAssemblyShift > 0) {
insertRightwardAssemblyLayer(
offsets,
orientationNodes,
localRightwardAssemblyMembers,
localRightwardAssemblyShift,
)
}
const outerSideOffsets = structuralNodes
.filter((node) => isOuterSidePanel(node, model))
.map((node) => Math.abs(orientationOffsets.get(node.nodeName)?.[LATERAL_AXIS] ?? 0))
.filter((amount) => amount > 0)
const outerHorizontalOffsets = structuralNodes
.filter((node) => isOuterHorizontalPanel(node, model))
.map((node) => Math.abs(orientationOffsets.get(node.nodeName)?.[VERTICAL_AXIS] ?? 0))
.filter((amount) => amount > 0)
const visibilityMetrics = {
center: model.center,
size: model.size,
diagonal: model.diagonal,
frontDirection,
sideDistance: outerSideOffsets[0] ?? sideDistance,
shellVerticalDistance: outerHorizontalOffsets[0] ?? shellVerticalDistance,
backDistance,
}
buildUnnamedCabinetVisibilityOffsets(structuralNodes, visibilityMetrics).forEach(
(offset, nodeName) => {
const node = nodeByName.get(nodeName)
if (!node) return
assign(
node,
localRightwardAssemblyShift > 0 && offset[LATERAL_AXIS] > 0
? [
offset[LATERAL_AXIS] + localRightwardAssemblyShift,
offset[VERTICAL_AXIS],
offset[DEPTH_AXIS],
]
: offset,
)
},
)
buildUnnamedCabinetVentPairOffsets(visibilityNodes, visibilityMetrics).forEach((offset, nodeName) => {
const node = nodeByName.get(nodeName)
if (node) assign(node, offset)
})
drawerPlan.offsets.forEach((offset, nodeName) => offsets.set(nodeName, offset))
const doorLateralOffsets = buildOrderedVisibleOffsets(
doors,
LATERAL_AXIS,
Math.max(model.size[LATERAL_AXIS] * 0.025, model.diagonal * 0.02),
)
doors.forEach((door) => {
const matchingDrawer = drawerGroups
.slice()
.sort(
(left, right) =>
Math.abs(left.face.center[LATERAL_AXIS] - door.center[LATERAL_AXIS]) -
Math.abs(right.face.center[LATERAL_AXIS] - door.center[LATERAL_AXIS]),
)[0]
const sharesFacadeColumn =
matchingDrawer &&
Math.abs(
matchingDrawer.face.center[LATERAL_AXIS] - door.center[LATERAL_AXIS],
) <= Math.max(door.size[LATERAL_AXIS] * 0.55, model.size[LATERAL_AXIS] * 0.06)
const matchingDrawerOffset = matchingDrawer
? drawerPlan.offsets.get(matchingDrawer.face.nodeName)
: null
assign(door, [
sharesFacadeColumn && matchingDrawerOffset
? matchingDrawerOffset[LATERAL_AXIS]
: doorLateralOffsets.get(door.nodeName) ?? 0,
0,
frontDirection *
(facadeTargetSignedDepth - frontDirection * door.center[DEPTH_AXIS]),
])
})
return offsets
}