feat(installation): auto-explode unnamed assemblies
This commit is contained in:
parent
879a73e480
commit
5da6c34758
@ -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);
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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']))
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
@ -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[],
|
||||
|
||||
@ -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])
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
}
|
||||
@ -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])
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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'))
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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(' ')
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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])
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user