altium-toolkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,742 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbEdgeFacingGlyphNormalizer } from './PcbEdgeFacingGlyphNormalizer.mjs'
6
+ import { PcbFootprintPrimitiveSelector } from './PcbFootprintPrimitiveSelector.mjs'
7
+ import { PcbScene3dPackages } from './PcbScene3dPackages.mjs'
8
+
9
+ /**
10
+ * Builds deterministic 3D scene data from the normalized PCB model.
11
+ */
12
+ export class PcbScene3dBuilder {
13
+ /**
14
+ * Builds a scene description for host 3D renderers.
15
+ * @param {{ pcb?: { boardOutline?: { widthMil?: number, heightMil?: number, minX?: number, minY?: number, segments?: Array<Record<string, number | string>> }, primitiveLayers?: { layerId: number, name: string }[], pads?: { x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[], tracks?: any[], arcs?: any[], fills?: any[], vias?: any[], polygons?: any[], embeddedModels?: any[], componentBodies?: { modelId?: string, checksum?: number | null, embedded?: boolean, name?: string, identifier?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, modelRotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number }[], components?: { designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null, source?: string, modelPath?: string }[] } }} documentModel
16
+ * @param {{ modelRegistry?: { resolveComponentModel: (component: any) => { name: string, relativePath: string, format: string } | null, resolveComponentBodyModel?: (componentBody: any) => { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null } | null, boardThicknessMil?: number }} [options]
17
+ * @returns {{ board: { widthMil: number, heightMil: number, thicknessMil: number, minX: number, minY: number, centerX: number, centerY: number, segments: Array<Record<string, number | string>> }, components: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, boardPositionMil: { x: number, y: number, z: number }, pattern: string, source: string, body: { family: string, sizeMil: { width: number, depth: number, height: number } }, externalModel: { name: string, relativePath: string, format: string } | null }[], externalPlacements: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, bodyRotationDeg: number, modelTransform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number }, externalModel: { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } }[], detail: { pads: any[], tracks: any[], arcs: any[], fills: any[], vias: any[], polygons: any[], silkscreen: { top: { fills: any[], tracks: any[], arcs: any[] }, bottom: { fills: any[], tracks: any[], arcs: any[] } } } }}
18
+ */
19
+ static build(documentModel, options = {}) {
20
+ const pcb = documentModel?.pcb || {}
21
+ const boardOutline = pcb.boardOutline || {}
22
+ const primitiveLayers = Array.isArray(pcb.primitiveLayers)
23
+ ? pcb.primitiveLayers
24
+ : []
25
+ const components = Array.isArray(pcb.components) ? pcb.components : []
26
+ const componentBodies = Array.isArray(pcb.componentBodies)
27
+ ? pcb.componentBodies
28
+ : []
29
+ const pads = Array.isArray(pcb.pads) ? pcb.pads : []
30
+ const tracks = Array.isArray(pcb.tracks) ? pcb.tracks : []
31
+ const arcs = Array.isArray(pcb.arcs) ? pcb.arcs : []
32
+ const fills = Array.isArray(pcb.fills) ? pcb.fills : []
33
+ const thicknessMil = Number(options.boardThicknessMil || 63) || 63
34
+ const modelRegistry = options.modelRegistry || null
35
+ const board = {
36
+ widthMil: Number(boardOutline.widthMil || 0),
37
+ heightMil: Number(boardOutline.heightMil || 0),
38
+ thicknessMil,
39
+ minX: Number(boardOutline.minX || 0),
40
+ minY: Number(boardOutline.minY || 0),
41
+ centerX:
42
+ Number(boardOutline.minX || 0) +
43
+ Number(boardOutline.widthMil || 0) / 2,
44
+ centerY:
45
+ Number(boardOutline.minY || 0) +
46
+ Number(boardOutline.heightMil || 0) / 2,
47
+ segments: Array.isArray(boardOutline.segments)
48
+ ? boardOutline.segments
49
+ : []
50
+ }
51
+ const bodyMatches = PcbScene3dBuilder.#resolveComponentBodyMatches(
52
+ componentBodies,
53
+ components
54
+ )
55
+
56
+ return {
57
+ board,
58
+ components: components.map((component) =>
59
+ PcbScene3dBuilder.#buildComponent(
60
+ component,
61
+ pads,
62
+ board,
63
+ thicknessMil,
64
+ modelRegistry
65
+ )
66
+ ),
67
+ externalPlacements: componentBodies
68
+ .map((componentBody, index) =>
69
+ PcbScene3dBuilder.#buildExternalPlacement(
70
+ componentBody,
71
+ bodyMatches[index],
72
+ board,
73
+ thicknessMil,
74
+ modelRegistry
75
+ )
76
+ )
77
+ .filter(Boolean),
78
+ detail: {
79
+ pads,
80
+ tracks,
81
+ arcs,
82
+ fills,
83
+ vias: Array.isArray(pcb.vias) ? pcb.vias : [],
84
+ polygons: Array.isArray(pcb.polygons) ? pcb.polygons : [],
85
+ silkscreen: {
86
+ top: PcbEdgeFacingGlyphNormalizer.normalize(
87
+ PcbFootprintPrimitiveSelector.select(
88
+ primitiveLayers,
89
+ fills,
90
+ tracks,
91
+ arcs,
92
+ 'top'
93
+ ),
94
+ boardOutline
95
+ ),
96
+ bottom: PcbEdgeFacingGlyphNormalizer.normalize(
97
+ PcbFootprintPrimitiveSelector.select(
98
+ primitiveLayers,
99
+ fills,
100
+ tracks,
101
+ arcs,
102
+ 'bottom'
103
+ ),
104
+ boardOutline
105
+ )
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Builds one procedural component scene entry.
113
+ * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null, source?: string, modelPath?: string }} component
114
+ * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads
115
+ * @param {{ centerX: number, centerY: number }} board
116
+ * @param {number} thicknessMil
117
+ * @param {{ resolveComponentModel: (component: any) => { name: string, relativePath: string, format: string } | null } | null} modelRegistry
118
+ * @returns {{ designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, boardPositionMil: { x: number, y: number, z: number }, pattern: string, source: string, body: { family: string, sizeMil: { width: number, depth: number, height: number } }, externalModel: { name: string, relativePath: string, format: string } | null }}
119
+ */
120
+ static #buildComponent(
121
+ component,
122
+ pads,
123
+ board,
124
+ thicknessMil,
125
+ modelRegistry
126
+ ) {
127
+ const mountSide =
128
+ String(component.layer || 'TOP').toUpperCase() === 'BOTTOM'
129
+ ? 'bottom'
130
+ : 'top'
131
+ const padSpan = PcbScene3dBuilder.#resolvePadSpan(component, pads)
132
+ const body = PcbScene3dPackages.resolve(component, padSpan)
133
+ const halfBoardThickness = thicknessMil / 2
134
+ const halfBodyHeight = body.sizeMil.height / 2
135
+ const z =
136
+ mountSide === 'bottom'
137
+ ? -(halfBoardThickness + halfBodyHeight)
138
+ : halfBoardThickness + halfBodyHeight
139
+
140
+ return {
141
+ designator: component.designator,
142
+ mountSide,
143
+ rotationDeg: Number(component.rotation || 0),
144
+ positionMil: {
145
+ x: Number(component.x || 0) - Number(board.centerX || 0),
146
+ y: Number(component.y || 0) - Number(board.centerY || 0),
147
+ z
148
+ },
149
+ boardPositionMil: {
150
+ x: Number(component.x || 0),
151
+ y: Number(component.y || 0),
152
+ z
153
+ },
154
+ pattern: String(component.pattern || ''),
155
+ source: String(component.source || ''),
156
+ body,
157
+ externalModel: modelRegistry
158
+ ? modelRegistry.resolveComponentModel(component)
159
+ : null
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Builds one explicit external-model placement from normalized component
165
+ * body metadata.
166
+ * @param {{ modelId?: string, checksum?: number | null, embedded?: boolean, name?: string, identifier?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, modelRotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number }} componentBody
167
+ * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent
168
+ * @param {{ centerX: number, centerY: number }} board
169
+ * @param {number} thicknessMil
170
+ * @param {{ resolveComponentBodyModel?: (componentBody: any) => { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null } | null} modelRegistry
171
+ * @returns {{ designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, bodyRotationDeg: number, modelTransform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number }, externalModel: { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } } | null}
172
+ */
173
+ static #buildExternalPlacement(
174
+ componentBody,
175
+ matchedComponent,
176
+ board,
177
+ thicknessMil,
178
+ modelRegistry
179
+ ) {
180
+ const resolvedModel =
181
+ modelRegistry?.resolveComponentBodyModel?.(componentBody) || null
182
+ if (!resolvedModel) {
183
+ return null
184
+ }
185
+
186
+ if (
187
+ !matchedComponent &&
188
+ !PcbScene3dBuilder.#isBodyPositionNearBoard(componentBody, board)
189
+ ) {
190
+ return null
191
+ }
192
+
193
+ const mountSide =
194
+ String(matchedComponent?.layer || 'TOP').toUpperCase() === 'BOTTOM'
195
+ ? 'bottom'
196
+ : 'top'
197
+ const halfBoardThickness = thicknessMil / 2
198
+ const sourcePosition =
199
+ PcbScene3dBuilder.#resolveExternalPlacementSourcePosition(
200
+ componentBody,
201
+ matchedComponent
202
+ )
203
+
204
+ return {
205
+ designator:
206
+ matchedComponent?.designator ||
207
+ String(
208
+ componentBody.identifier || componentBody.name || '3D model'
209
+ ),
210
+ mountSide,
211
+ rotationDeg: PcbScene3dBuilder.#resolveExternalPlacementRotation(
212
+ componentBody,
213
+ matchedComponent
214
+ ),
215
+ positionMil: {
216
+ x: Number(sourcePosition.x || 0) - Number(board.centerX || 0),
217
+ y: Number(sourcePosition.y || 0) - Number(board.centerY || 0),
218
+ z:
219
+ mountSide === 'bottom'
220
+ ? -halfBoardThickness
221
+ : halfBoardThickness
222
+ },
223
+ bodyPositionMil: {
224
+ x: Number(componentBody.positionMil?.x || 0),
225
+ y: Number(componentBody.positionMil?.y || 0)
226
+ },
227
+ bodyRotationDeg: Number(componentBody.rotationDeg || 0),
228
+ modelTransform: {
229
+ rotationDeg: {
230
+ x: Number(componentBody.modelRotationDeg?.x || 0),
231
+ y: Number(componentBody.modelRotationDeg?.y || 0),
232
+ z: Number(componentBody.modelRotationDeg?.z || 0)
233
+ },
234
+ dzMil: Number(componentBody.dzMil || 0)
235
+ },
236
+ externalModel: resolvedModel
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Resolves explicit body placements to component anchors using a unique
242
+ * nearest-neighbor pass plus an ordered-affinity fallback for repeated
243
+ * footprints whose body coordinates are offset from their owning
244
+ * components.
245
+ * @param {{ modelId?: string, name?: string, identifier?: string, positionMil?: { x?: number, y?: number } }[]} componentBodies
246
+ * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components
247
+ * @returns {({ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string } | null)[]}
248
+ */
249
+ static #resolveComponentBodyMatches(componentBodies, components) {
250
+ const matches = new Array(componentBodies.length).fill(null)
251
+ const assignedBodyIndexes = new Set()
252
+ const assignedComponentIndexes = new Set()
253
+ const closeCandidates = []
254
+
255
+ componentBodies.forEach((componentBody, bodyIndex) => {
256
+ components.forEach((component, componentIndex) => {
257
+ const distance =
258
+ PcbScene3dBuilder.#distanceBetweenBodyAndComponent(
259
+ componentBody,
260
+ component
261
+ )
262
+
263
+ if (distance <= 600) {
264
+ closeCandidates.push({
265
+ bodyIndex,
266
+ componentIndex,
267
+ distance
268
+ })
269
+ }
270
+ })
271
+ })
272
+
273
+ closeCandidates
274
+ .sort((left, right) => left.distance - right.distance)
275
+ .forEach(({ bodyIndex, componentIndex }) => {
276
+ if (
277
+ assignedBodyIndexes.has(bodyIndex) ||
278
+ assignedComponentIndexes.has(componentIndex)
279
+ ) {
280
+ return
281
+ }
282
+
283
+ matches[bodyIndex] = components[componentIndex]
284
+ assignedBodyIndexes.add(bodyIndex)
285
+ assignedComponentIndexes.add(componentIndex)
286
+ })
287
+
288
+ const groupedBodyIndexes = new Map()
289
+ componentBodies.forEach((componentBody, bodyIndex) => {
290
+ const groupKey =
291
+ PcbScene3dBuilder.#resolveBodyGroupKey(componentBody)
292
+ if (!groupedBodyIndexes.has(groupKey)) {
293
+ groupedBodyIndexes.set(groupKey, [])
294
+ }
295
+
296
+ groupedBodyIndexes.get(groupKey).push(bodyIndex)
297
+ })
298
+
299
+ groupedBodyIndexes.forEach((bodyIndexes) => {
300
+ const unresolvedCount = bodyIndexes.filter(
301
+ (bodyIndex) => !matches[bodyIndex]
302
+ ).length
303
+ if (!unresolvedCount) {
304
+ return
305
+ }
306
+
307
+ const referenceBody = componentBodies[bodyIndexes[0]]
308
+ const candidateComponentIndexes = components
309
+ .map((component, componentIndex) => ({
310
+ component,
311
+ componentIndex
312
+ }))
313
+ .filter(
314
+ ({ componentIndex, component }) =>
315
+ (matches.indexOf(components[componentIndex]) === -1 ||
316
+ bodyIndexes.includes(
317
+ matches.indexOf(components[componentIndex])
318
+ )) &&
319
+ PcbScene3dBuilder.#scoreBodyComponentAffinity(
320
+ referenceBody,
321
+ component
322
+ ) > 0
323
+ )
324
+ .map(({ componentIndex }) => componentIndex)
325
+
326
+ const orderedPairs =
327
+ PcbScene3dBuilder.#pairBodyGroupByOrderedAffinity(
328
+ bodyIndexes,
329
+ candidateComponentIndexes,
330
+ componentBodies,
331
+ components
332
+ )
333
+
334
+ if (orderedPairs.length !== bodyIndexes.length) {
335
+ return
336
+ }
337
+
338
+ bodyIndexes.forEach((bodyIndex) => {
339
+ matches[bodyIndex] = null
340
+ assignedBodyIndexes.delete(bodyIndex)
341
+ })
342
+
343
+ orderedPairs.forEach(([bodyIndex, componentIndex]) => {
344
+ matches[bodyIndex] = components[componentIndex]
345
+ assignedBodyIndexes.add(bodyIndex)
346
+ })
347
+ })
348
+
349
+ return matches
350
+ }
351
+
352
+ /**
353
+ * Pairs one unresolved repeated body group with a repeated component group
354
+ * by preserving the dominant ordering axis and choosing the pairing that
355
+ * yields the most consistent translation offset.
356
+ * @param {number[]} bodyIndexes
357
+ * @param {number[]} componentIndexes
358
+ * @param {{ positionMil?: { x?: number, y?: number } }[]} componentBodies
359
+ * @param {{ x: number, y: number }[]} components
360
+ * @returns {[number, number][]}
361
+ */
362
+ static #pairBodyGroupByOrderedAffinity(
363
+ bodyIndexes,
364
+ componentIndexes,
365
+ componentBodies,
366
+ components
367
+ ) {
368
+ if (
369
+ !Array.isArray(bodyIndexes) ||
370
+ !Array.isArray(componentIndexes) ||
371
+ componentIndexes.length < bodyIndexes.length
372
+ ) {
373
+ return []
374
+ }
375
+
376
+ const orderingAxis = PcbScene3dBuilder.#resolveOrderingAxis(
377
+ bodyIndexes,
378
+ componentIndexes,
379
+ componentBodies,
380
+ components
381
+ )
382
+ const sortedBodyIndexes = [...bodyIndexes].sort(
383
+ (leftIndex, rightIndex) =>
384
+ Number(
385
+ componentBodies[leftIndex]?.positionMil?.[orderingAxis] || 0
386
+ ) -
387
+ Number(
388
+ componentBodies[rightIndex]?.positionMil?.[orderingAxis] ||
389
+ 0
390
+ )
391
+ )
392
+ const sortedComponentIndexes = [...componentIndexes].sort(
393
+ (leftIndex, rightIndex) =>
394
+ Number(components[leftIndex]?.[orderingAxis] || 0) -
395
+ Number(components[rightIndex]?.[orderingAxis] || 0)
396
+ )
397
+ let bestOrderedComponents = []
398
+ let bestScore = Number.POSITIVE_INFINITY
399
+
400
+ ;[
401
+ sortedComponentIndexes,
402
+ [...sortedComponentIndexes].reverse()
403
+ ].forEach((orderedComponents) => {
404
+ for (
405
+ let startIndex = 0;
406
+ startIndex <=
407
+ orderedComponents.length - sortedBodyIndexes.length;
408
+ startIndex += 1
409
+ ) {
410
+ const candidateOrdering = orderedComponents.slice(
411
+ startIndex,
412
+ startIndex + sortedBodyIndexes.length
413
+ )
414
+ const score = PcbScene3dBuilder.#scoreOrderedPairing(
415
+ sortedBodyIndexes,
416
+ candidateOrdering,
417
+ componentBodies,
418
+ components
419
+ )
420
+
421
+ if (score < bestScore) {
422
+ bestScore = score
423
+ bestOrderedComponents = candidateOrdering
424
+ }
425
+ }
426
+ })
427
+
428
+ return sortedBodyIndexes.map((bodyIndex, pairIndex) => [
429
+ bodyIndex,
430
+ bestOrderedComponents[pairIndex]
431
+ ])
432
+ }
433
+
434
+ /**
435
+ * Scores one ordered body/component pairing by how consistent the implied
436
+ * XY translation is across the whole group.
437
+ * @param {number[]} bodyIndexes
438
+ * @param {number[]} componentIndexes
439
+ * @param {{ positionMil?: { x?: number, y?: number } }[]} componentBodies
440
+ * @param {{ x: number, y: number }[]} components
441
+ * @returns {number}
442
+ */
443
+ static #scoreOrderedPairing(
444
+ bodyIndexes,
445
+ componentIndexes,
446
+ componentBodies,
447
+ components
448
+ ) {
449
+ const deltas = bodyIndexes.map((bodyIndex, pairIndex) => ({
450
+ dx:
451
+ Number(components[componentIndexes[pairIndex]]?.x || 0) -
452
+ Number(componentBodies[bodyIndex]?.positionMil?.x || 0),
453
+ dy:
454
+ Number(components[componentIndexes[pairIndex]]?.y || 0) -
455
+ Number(componentBodies[bodyIndex]?.positionMil?.y || 0)
456
+ }))
457
+ const averageDx =
458
+ deltas.reduce((sum, delta) => sum + delta.dx, 0) / deltas.length
459
+ const averageDy =
460
+ deltas.reduce((sum, delta) => sum + delta.dy, 0) / deltas.length
461
+
462
+ return deltas.reduce(
463
+ (sum, delta) =>
464
+ sum +
465
+ Math.abs(delta.dx - averageDx) +
466
+ Math.abs(delta.dy - averageDy),
467
+ 0
468
+ )
469
+ }
470
+
471
+ /**
472
+ * Chooses the dominant ordering axis for one repeated model/component
473
+ * group.
474
+ * @param {number[]} bodyIndexes
475
+ * @param {number[]} componentIndexes
476
+ * @param {{ positionMil?: { x?: number, y?: number } }[]} componentBodies
477
+ * @param {{ x: number, y: number }[]} components
478
+ * @returns {'x' | 'y'}
479
+ */
480
+ static #resolveOrderingAxis(
481
+ bodyIndexes,
482
+ componentIndexes,
483
+ componentBodies,
484
+ components
485
+ ) {
486
+ const bodyXs = bodyIndexes.map((index) =>
487
+ Number(componentBodies[index]?.positionMil?.x || 0)
488
+ )
489
+ const bodyYs = bodyIndexes.map((index) =>
490
+ Number(componentBodies[index]?.positionMil?.y || 0)
491
+ )
492
+ const componentXs = componentIndexes.map((index) =>
493
+ Number(components[index]?.x || 0)
494
+ )
495
+ const componentYs = componentIndexes.map((index) =>
496
+ Number(components[index]?.y || 0)
497
+ )
498
+ const xSpread =
499
+ Math.max(...bodyXs, ...componentXs) -
500
+ Math.min(...bodyXs, ...componentXs)
501
+ const ySpread =
502
+ Math.max(...bodyYs, ...componentYs) -
503
+ Math.min(...bodyYs, ...componentYs)
504
+
505
+ return xSpread >= ySpread ? 'x' : 'y'
506
+ }
507
+
508
+ /**
509
+ * Returns the component anchor that should be used for one resolved body
510
+ * placement.
511
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody
512
+ * @param {{ x: number, y: number } | null} matchedComponent
513
+ * @returns {{ x: number, y: number }}
514
+ */
515
+ static #resolveExternalPlacementSourcePosition(
516
+ componentBody,
517
+ matchedComponent
518
+ ) {
519
+ if (matchedComponent) {
520
+ return {
521
+ x: Number(matchedComponent.x || 0),
522
+ y: Number(matchedComponent.y || 0)
523
+ }
524
+ }
525
+
526
+ return {
527
+ x: Number(componentBody?.positionMil?.x || 0),
528
+ y: Number(componentBody?.positionMil?.y || 0)
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Resolves the authored placement rotation for one explicit external
534
+ * model, combining the matched component orientation with any additional
535
+ * 2D model rotation offset carried by the body metadata.
536
+ * @param {{ rotationDeg?: number }} componentBody
537
+ * @param {{ rotation?: number } | null} matchedComponent
538
+ * @returns {number}
539
+ */
540
+ static #resolveExternalPlacementRotation(componentBody, matchedComponent) {
541
+ return PcbScene3dBuilder.#normalizeAngle(
542
+ Number(matchedComponent?.rotation || 0) +
543
+ Number(componentBody?.rotationDeg || 0)
544
+ )
545
+ }
546
+
547
+ /**
548
+ * Resolves a rough pad-span box around one component.
549
+ * @param {{ x: number, y: number }} component
550
+ * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads
551
+ * @returns {{ width: number, depth: number }}
552
+ */
553
+ static #resolvePadSpan(component, pads) {
554
+ const nearbyPads = pads.filter((pad) =>
555
+ PcbScene3dBuilder.#isPadNearComponent(component, pad)
556
+ )
557
+
558
+ if (!nearbyPads.length) {
559
+ return { width: 0, depth: 0 }
560
+ }
561
+
562
+ const xs = []
563
+ const ys = []
564
+
565
+ for (const pad of nearbyPads) {
566
+ const size = PcbScene3dBuilder.#resolvePadSize(pad)
567
+ xs.push(pad.x - size.width / 2, pad.x + size.width / 2)
568
+ ys.push(pad.y - size.depth / 2, pad.y + size.depth / 2)
569
+ }
570
+
571
+ return {
572
+ width: Math.max(...xs) - Math.min(...xs),
573
+ depth: Math.max(...ys) - Math.min(...ys)
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Returns true when one pad lies inside the component's local search area.
579
+ * @param {{ x: number, y: number }} component
580
+ * @param {{ x: number, y: number }} pad
581
+ * @returns {boolean}
582
+ */
583
+ static #isPadNearComponent(component, pad) {
584
+ return (
585
+ Math.abs(Number(pad.x || 0) - Number(component.x || 0)) <= 160 &&
586
+ Math.abs(Number(pad.y || 0) - Number(component.y || 0)) <= 160
587
+ )
588
+ }
589
+
590
+ /**
591
+ * Resolves one visible pad size.
592
+ * @param {{ sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }} pad
593
+ * @returns {{ width: number, depth: number }}
594
+ */
595
+ static #resolvePadSize(pad) {
596
+ return {
597
+ width:
598
+ Number(pad.sizeTopX || pad.sizeMidX || pad.sizeBottomX || 24) ||
599
+ 24,
600
+ depth:
601
+ Number(pad.sizeTopY || pad.sizeMidY || pad.sizeBottomY || 24) ||
602
+ 24
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Returns the euclidean distance between one body anchor and one component
608
+ * anchor.
609
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody
610
+ * @param {{ x: number, y: number }} component
611
+ * @returns {number}
612
+ */
613
+ static #distanceBetweenBodyAndComponent(componentBody, component) {
614
+ return Math.hypot(
615
+ Number(component?.x || 0) -
616
+ Number(componentBody?.positionMil?.x || 0),
617
+ Number(component?.y || 0) -
618
+ Number(componentBody?.positionMil?.y || 0)
619
+ )
620
+ }
621
+
622
+ /**
623
+ * Resolves the grouping key for repeated component-body matching.
624
+ * @param {{ modelId?: string, name?: string, identifier?: string }} componentBody
625
+ * @returns {string}
626
+ */
627
+ static #resolveBodyGroupKey(componentBody) {
628
+ return PcbScene3dBuilder.#normalizeLookupToken(
629
+ componentBody?.modelId ||
630
+ componentBody?.name ||
631
+ componentBody?.identifier
632
+ )
633
+ }
634
+
635
+ /**
636
+ * Scores how strongly one component record appears to belong to one body
637
+ * record based on shared model/footprint tokens.
638
+ * @param {{ name?: string, identifier?: string }} componentBody
639
+ * @param {{ pattern?: string, source?: string, modelPath?: string }} component
640
+ * @returns {number}
641
+ */
642
+ static #scoreBodyComponentAffinity(componentBody, component) {
643
+ const bodyTokens = PcbScene3dBuilder.#collectMeaningfulTokens([
644
+ componentBody?.identifier,
645
+ String(componentBody?.name || '').replace(/\.[^.]+$/, '')
646
+ ])
647
+ const componentTokens = PcbScene3dBuilder.#collectMeaningfulTokens([
648
+ component?.pattern,
649
+ component?.source,
650
+ component?.modelPath
651
+ ])
652
+ let score = 0
653
+
654
+ bodyTokens.forEach((token) => {
655
+ if (componentTokens.has(token)) {
656
+ score += token.length
657
+ }
658
+ })
659
+
660
+ return score
661
+ }
662
+
663
+ /**
664
+ * Collects normalized model tokens from free-form strings.
665
+ * @param {(string | undefined)[]} values
666
+ * @returns {Set<string>}
667
+ */
668
+ static #collectMeaningfulTokens(values) {
669
+ const tokens = new Set()
670
+
671
+ ;(Array.isArray(values) ? values : []).forEach((value) => {
672
+ String(value || '')
673
+ .toLowerCase()
674
+ .split(/[^a-z0-9]+/g)
675
+ .forEach((fragment) => {
676
+ ;(fragment.match(/[a-z]+|\d+/g) || []).forEach((token) => {
677
+ if (PcbScene3dBuilder.#isMeaningfulToken(token)) {
678
+ tokens.add(token)
679
+ }
680
+ })
681
+ })
682
+ })
683
+
684
+ return tokens
685
+ }
686
+
687
+ /**
688
+ * Returns true when one normalized token carries useful model identity.
689
+ * @param {string} token
690
+ * @returns {boolean}
691
+ */
692
+ static #isMeaningfulToken(token) {
693
+ return (
694
+ String(token || '').length >= 2 &&
695
+ !new Set(['con', 'step', 'stp', 'model', 'default', 'black']).has(
696
+ String(token || '')
697
+ )
698
+ )
699
+ }
700
+
701
+ /**
702
+ * Returns true when one unresolved body anchor still lies close enough to
703
+ * the board envelope to be renderable without a component match.
704
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody
705
+ * @param {{ minX: number, minY: number, widthMil: number, heightMil: number }} board
706
+ * @returns {boolean}
707
+ */
708
+ static #isBodyPositionNearBoard(componentBody, board) {
709
+ const bodyX = Number(componentBody?.positionMil?.x || 0)
710
+ const bodyY = Number(componentBody?.positionMil?.y || 0)
711
+ const minX = Number(board?.minX || 0) - 600
712
+ const minY = Number(board?.minY || 0) - 600
713
+ const maxX =
714
+ Number(board?.minX || 0) + Number(board?.widthMil || 0) + 600
715
+ const maxY =
716
+ Number(board?.minY || 0) + Number(board?.heightMil || 0) + 600
717
+
718
+ return bodyX >= minX && bodyX <= maxX && bodyY >= minY && bodyY <= maxY
719
+ }
720
+
721
+ /**
722
+ * Normalizes one lookup token for repeated-model grouping.
723
+ * @param {string | undefined} value
724
+ * @returns {string}
725
+ */
726
+ static #normalizeLookupToken(value) {
727
+ return String(value || '')
728
+ .toLowerCase()
729
+ .replace(/[^a-z0-9]+/g, '')
730
+ }
731
+
732
+ /**
733
+ * Normalizes one angle into the range [0, 360).
734
+ * @param {number} angle
735
+ * @returns {number}
736
+ */
737
+ static #normalizeAngle(angle) {
738
+ const normalized = Number(angle || 0) % 360
739
+
740
+ return normalized < 0 ? normalized + 360 : normalized
741
+ }
742
+ }