altium-toolkit 1.0.7 → 1.0.9

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 (93) hide show
  1. package/README.md +18 -6
  2. package/docs/api.md +78 -16
  3. package/docs/model-format.md +229 -8
  4. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +47 -0
  5. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1661 -104
  6. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +59 -0
  7. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +57 -0
  8. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  9. package/docs/testing.md +9 -3
  10. package/package.json +1 -1
  11. package/spec/library-scope.md +7 -1
  12. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  13. package/src/core/altium/AltiumParser.mjs +191 -45
  14. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  15. package/src/core/altium/IntLibModelParser.mjs +240 -0
  16. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  17. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  18. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  19. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  20. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  21. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  22. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  23. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  24. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  25. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  26. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  27. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  28. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  29. package/src/core/altium/PcbModelParser.mjs +466 -28
  30. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  31. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  32. package/src/core/altium/PcbPadStackParser.mjs +58 -0
  33. package/src/core/altium/PcbPickPlacePositionResolver.mjs +217 -0
  34. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  35. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  36. package/src/core/altium/PcbRegionPrimitiveParser.mjs +5 -1
  37. package/src/core/altium/PcbRuleParser.mjs +354 -33
  38. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  39. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  40. package/src/core/altium/PcbStatisticsBuilder.mjs +532 -0
  41. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  42. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  43. package/src/core/altium/PcbUnionParser.mjs +307 -0
  44. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  45. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  46. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  47. package/src/core/altium/PrjPcbModelParser.mjs +257 -5
  48. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  49. package/src/core/altium/ProjectDesignBundleBuilder.mjs +477 -0
  50. package/src/core/altium/ProjectNetlistExporter.mjs +499 -0
  51. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  52. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  53. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  54. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  55. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  56. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  57. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  58. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  59. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  60. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  61. package/src/core/altium/SchematicImageParser.mjs +474 -3
  62. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  63. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  64. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  65. package/src/core/altium/SchematicPinParser.mjs +84 -1
  66. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  67. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  68. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  69. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  70. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  71. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  72. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  73. package/src/core/altium/SchematicTextParser.mjs +123 -0
  74. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  75. package/src/parser.mjs +29 -0
  76. package/src/renderers.mjs +3 -0
  77. package/src/styles/altium-renderers.css +25 -0
  78. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  79. package/src/ui/PcbInteractionGeometry.mjs +350 -0
  80. package/src/ui/PcbInteractionIndex.mjs +593 -0
  81. package/src/ui/PcbInteractionItemRegistry.mjs +66 -0
  82. package/src/ui/PcbInteractionLayerModel.mjs +99 -0
  83. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +74 -9
  84. package/src/ui/PcbScene3dBuilder.mjs +169 -7
  85. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  86. package/src/ui/PcbSvgRenderer.mjs +1187 -34
  87. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  88. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  89. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  90. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  91. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  92. package/src/ui/SchematicTypography.mjs +48 -5
  93. package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
@@ -0,0 +1,99 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbInteractionIndex } from './PcbInteractionIndex.mjs'
6
+
7
+ const VIRTUAL_LAYER_DEFINITIONS = [
8
+ { key: 'tracks', label: 'Tracks' },
9
+ { key: 'vias', label: 'Vias' },
10
+ { key: 'pads', label: 'Pads' },
11
+ { key: 'holes', label: 'Holes' },
12
+ { key: 'zones', label: 'Zones' },
13
+ { key: 'footprint-text', label: 'Footprint text' }
14
+ ]
15
+
16
+ /**
17
+ * Builds a PCB layer summary with physical layers and virtual controls.
18
+ */
19
+ export class PcbInteractionLayerModel {
20
+ /**
21
+ * Resolves physical and virtual interaction layers.
22
+ * @param {object} documentModel Toolkit document model.
23
+ * @returns {{ physicalLayers: object[], virtualLayers: object[] }}
24
+ */
25
+ static resolve(documentModel) {
26
+ const pcb = documentModel?.pcb || {}
27
+ const physicalLayers = PcbInteractionLayerModel.#physicalLayers(pcb)
28
+ const items = PcbInteractionIndex.build(documentModel)
29
+ const layersByObject = PcbInteractionLayerModel.#layersByObject(items)
30
+
31
+ return {
32
+ physicalLayers,
33
+ virtualLayers: VIRTUAL_LAYER_DEFINITIONS.map((definition) => ({
34
+ ...definition,
35
+ physicalLayerKeys: Array.from(
36
+ layersByObject.get(definition.key) || []
37
+ )
38
+ }))
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolves physical layer rows from board and primitive layer metadata.
44
+ * @param {object} pcb PCB model.
45
+ * @returns {object[]}
46
+ */
47
+ static #physicalLayers(pcb) {
48
+ const seen = new Set()
49
+ const layers = []
50
+ const sources = [
51
+ ...(Array.isArray(pcb?.layers) ? pcb.layers : []),
52
+ ...(Array.isArray(pcb?.primitiveLayers) ? pcb.primitiveLayers : [])
53
+ ]
54
+
55
+ for (const layer of sources) {
56
+ const key = String(layer?.name || layer?.layer || '').trim()
57
+ if (!key || seen.has(key)) continue
58
+ seen.add(key)
59
+ layers.push({
60
+ key,
61
+ label: key,
62
+ layerId: Number.isFinite(Number(layer?.layerId))
63
+ ? Number(layer.layerId)
64
+ : null
65
+ })
66
+ }
67
+
68
+ return layers
69
+ }
70
+
71
+ /**
72
+ * Collects referenced physical layer keys by virtual object key.
73
+ * @param {object[]} items Interaction items.
74
+ * @returns {Map<string, Set<string>>}
75
+ */
76
+ static #layersByObject(items) {
77
+ const layersByObject = new Map()
78
+
79
+ for (const item of items) {
80
+ if (!layersByObject.has(item.objectKey)) {
81
+ layersByObject.set(item.objectKey, new Set())
82
+ }
83
+ const layerSet = layersByObject.get(item.objectKey)
84
+ for (const layerKey of item.layerKeys || []) {
85
+ layerSet.add(layerKey)
86
+ }
87
+ if (item.type === 'pad' || item.type === 'via') {
88
+ if (!layersByObject.has('holes')) {
89
+ layersByObject.set('holes', new Set())
90
+ }
91
+ for (const layerKey of item.layerKeys || []) {
92
+ layersByObject.get('holes').add(layerKey)
93
+ }
94
+ }
95
+ }
96
+
97
+ return layersByObject
98
+ }
99
+ }
@@ -46,17 +46,82 @@ export class PcbScene3dBoardOutlineRefiner {
46
46
  return sceneDescription
47
47
  }
48
48
 
49
+ const refinedBoard = {
50
+ ...board,
51
+ minX: candidate.minX,
52
+ minY: candidate.minY,
53
+ widthMil: candidate.widthMil,
54
+ heightMil: candidate.heightMil,
55
+ centerX: candidate.minX + candidate.widthMil / 2,
56
+ centerY: candidate.minY + candidate.heightMil / 2,
57
+ segments: candidate.segments
58
+ }
59
+
49
60
  return {
50
61
  ...sceneDescription,
51
- board: {
52
- ...board,
53
- minX: candidate.minX,
54
- minY: candidate.minY,
55
- widthMil: candidate.widthMil,
56
- heightMil: candidate.heightMil,
57
- centerX: candidate.minX + candidate.widthMil / 2,
58
- centerY: candidate.minY + candidate.heightMil / 2,
59
- segments: candidate.segments
62
+ board: refinedBoard,
63
+ components: PcbScene3dBoardOutlineRefiner.#realignLocalPlacements(
64
+ sceneDescription?.components,
65
+ board,
66
+ refinedBoard
67
+ ),
68
+ externalPlacements:
69
+ PcbScene3dBoardOutlineRefiner.#realignLocalPlacements(
70
+ sceneDescription?.externalPlacements,
71
+ board,
72
+ refinedBoard
73
+ )
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Realigns precomputed local placements to a refined board center.
79
+ * @param {object[] | undefined} placements Scene placements.
80
+ * @param {{ centerX?: number, centerY?: number }} previousBoard Previous board.
81
+ * @param {{ centerX?: number, centerY?: number }} refinedBoard Refined board.
82
+ * @returns {object[] | undefined}
83
+ */
84
+ static #realignLocalPlacements(placements, previousBoard, refinedBoard) {
85
+ if (!Array.isArray(placements)) {
86
+ return placements
87
+ }
88
+
89
+ const deltaX =
90
+ Number(previousBoard?.centerX || 0) -
91
+ Number(refinedBoard?.centerX || 0)
92
+ const deltaY =
93
+ Number(previousBoard?.centerY || 0) -
94
+ Number(refinedBoard?.centerY || 0)
95
+
96
+ if (!deltaX && !deltaY) {
97
+ return placements
98
+ }
99
+
100
+ return placements.map((placement) =>
101
+ PcbScene3dBoardOutlineRefiner.#realignLocalPlacement(
102
+ placement,
103
+ deltaX,
104
+ deltaY
105
+ )
106
+ )
107
+ }
108
+
109
+ /**
110
+ * Applies one local origin delta to a scene placement.
111
+ * @param {object} placement Scene placement.
112
+ * @param {number} deltaX Local X delta.
113
+ * @param {number} deltaY Local Y delta.
114
+ * @returns {object}
115
+ */
116
+ static #realignLocalPlacement(placement, deltaX, deltaY) {
117
+ const positionMil = placement?.positionMil || {}
118
+
119
+ return {
120
+ ...placement,
121
+ positionMil: {
122
+ ...positionMil,
123
+ x: Number(positionMil.x || 0) + deltaX,
124
+ y: Number(positionMil.y || 0) + deltaY
60
125
  }
61
126
  }
62
127
  }
@@ -18,14 +18,17 @@ export class PcbScene3dBuilder {
18
18
  static #DENSE_OVERLAY_MIN_REGION_AREA_RATIO = 0.2
19
19
  static #DENSE_OVERLAY_MIN_TRACK_COUNT = 250
20
20
  static #DENSE_OVERLAY_KNOCKOUT_COLOR = 0x2f6a2c
21
- static #PRECISE_BODY_MATCH_TOLERANCE_MIL = 5
21
+ static #PRECISE_BODY_MATCH_TOLERANCE_MIL = 20
22
+ static #UNMATCHED_BODY_OVERHANG_RATIO = 0.25
23
+ static #UNMATCHED_BODY_MIN_OVERHANG_MIL = 150
24
+ static #UNMATCHED_BODY_MAX_OVERHANG_MIL = 600
22
25
  static #TRUETYPE_TEXT_WIDTH_RATIO = 0.55
23
26
 
24
27
  /**
25
28
  * Builds a scene description for host 3D renderers.
26
29
  * @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
27
- * @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]
28
- * @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[], texts: any[], fillColor?: number, strokeColor?: number }, bottom: { fills: any[], tracks: any[], arcs: any[], texts: any[], fillColor?: number, strokeColor?: number } } } }}
30
+ * @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, resolveBoardAssemblyModel?: (documentModel: any) => { origin: string, name: string, format: string, file?: File | Blob | null, relativePath?: string } | null } | null, boardThicknessMil?: number }} [options]
31
+ * @returns {{ board: { widthMil: number, heightMil: number, thicknessMil: number, minX: number, minY: number, centerX: number, centerY: number, segments: Array<Record<string, number | string>> }, boardAssemblyModel: { origin: string, name: string, format: string, file?: File | Blob | null, relativePath?: string } | null, 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[], texts: any[], fillColor?: number, strokeColor?: number }, bottom: { fills: any[], tracks: any[], arcs: any[], texts: any[], fillColor?: number, strokeColor?: number } } } }}
29
32
  */
30
33
  static build(documentModel, options = {}) {
31
34
  const pcb = documentModel?.pcb || {}
@@ -60,6 +63,10 @@ export class PcbScene3dBuilder {
60
63
  centerY:
61
64
  Number(boardOutline.minY || 0) +
62
65
  Number(boardOutline.heightMil || 0) / 2,
66
+ surfaceColor: Number.isInteger(appearance3d.solderMaskTopColor)
67
+ ? appearance3d.solderMaskTopColor
68
+ : appearance3d.solderMaskBottomColor,
69
+ edgeColor: appearance3d.boardCoreColor,
63
70
  segments: Array.isArray(boardOutline.segments)
64
71
  ? boardOutline.segments
65
72
  : []
@@ -103,6 +110,9 @@ export class PcbScene3dBuilder {
103
110
  const sceneDescription = {
104
111
  sourceFormat: 'altium',
105
112
  board,
113
+ boardAssemblyModel:
114
+ modelRegistry?.resolveBoardAssemblyModel?.(documentModel) ||
115
+ null,
106
116
  components: components.map((component) =>
107
117
  PcbScene3dBuilder.#buildComponent(
108
118
  component,
@@ -118,6 +128,7 @@ export class PcbScene3dBuilder {
118
128
  componentBody,
119
129
  bodyMatches[index],
120
130
  components,
131
+ pads,
121
132
  board,
122
133
  thicknessMil,
123
134
  modelRegistry
@@ -205,6 +216,7 @@ export class PcbScene3dBuilder {
205
216
  * @param {{ modelId?: string, checksum?: number | null, embedded?: boolean, name?: string, identifier?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, modelRotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number }} componentBody
206
217
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent
207
218
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components
219
+ * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads
208
220
  * @param {{ centerX: number, centerY: number }} board
209
221
  * @param {number} thicknessMil
210
222
  * @param {{ resolveComponentBodyModel?: (componentBody: any) => { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null } | null} modelRegistry
@@ -214,6 +226,7 @@ export class PcbScene3dBuilder {
214
226
  componentBody,
215
227
  matchedComponent,
216
228
  components,
229
+ pads,
217
230
  board,
218
231
  thicknessMil,
219
232
  modelRegistry
@@ -272,10 +285,134 @@ export class PcbScene3dBuilder {
272
285
  rotationDeg: modelRotation,
273
286
  dzMil: Number(componentBody.dzMil || 0)
274
287
  },
288
+ projection: PcbScene3dBuilder.#resolveProjectionDiagnostics(
289
+ componentBody,
290
+ matchedComponent,
291
+ pads,
292
+ resolvedModel
293
+ ),
275
294
  externalModel: resolvedModel
276
295
  }
277
296
  }
278
297
 
298
+ /**
299
+ * Explains which footprint projection source informed one external model.
300
+ * @param {object} componentBody Normalized component body row.
301
+ * @param {{ x: number, y: number, height?: number | null } | null} matchedComponent Matched component.
302
+ * @param {object[]} pads Normalized pad rows.
303
+ * @param {object | null} resolvedModel Resolved model metadata.
304
+ * @returns {{ source: string, reason: string, boundsMil: { width: number, depth: number, height: number } }}
305
+ */
306
+ static #resolveProjectionDiagnostics(
307
+ componentBody,
308
+ matchedComponent,
309
+ pads,
310
+ resolvedModel
311
+ ) {
312
+ const authoredBounds = PcbScene3dBuilder.#firstBounds([
313
+ componentBody?.projectionOverrideMil,
314
+ componentBody?.projectionOverride?.boundsMil,
315
+ componentBody?.projectionBoundsMil
316
+ ])
317
+ if (authoredBounds) {
318
+ return {
319
+ source: 'authored-override',
320
+ reason: 'Component body carried an explicit projection override.',
321
+ boundsMil: authoredBounds
322
+ }
323
+ }
324
+
325
+ const modelBounds = PcbScene3dBuilder.#firstBounds([
326
+ componentBody?.modelBoundsMil,
327
+ resolvedModel?.boundsMil
328
+ ])
329
+ if (modelBounds) {
330
+ return {
331
+ source: 'model-bounds',
332
+ reason: 'Resolved 3D model bounds were available.',
333
+ boundsMil: modelBounds
334
+ }
335
+ }
336
+
337
+ if (matchedComponent) {
338
+ const padSpan = PcbScene3dBuilder.#resolvePadSpan(
339
+ matchedComponent,
340
+ pads
341
+ )
342
+ if (padSpan.width > 0 || padSpan.depth > 0) {
343
+ return {
344
+ source: 'pad-fallback',
345
+ reason: 'Projection fell back to nearby component pad span.',
346
+ boundsMil: {
347
+ width: padSpan.width,
348
+ depth: padSpan.depth,
349
+ height: Number(matchedComponent.height || 0)
350
+ }
351
+ }
352
+ }
353
+
354
+ const body = PcbScene3dPackages.resolve(matchedComponent, padSpan)
355
+ return {
356
+ source: 'component-fallback',
357
+ reason: 'Projection fell back to the procedural component body.',
358
+ boundsMil: {
359
+ width: body.sizeMil.width,
360
+ depth: body.sizeMil.depth,
361
+ height: body.sizeMil.height
362
+ }
363
+ }
364
+ }
365
+
366
+ return {
367
+ source: 'model-anchor-fallback',
368
+ reason: 'Projection used the model anchor because no owner geometry was available.',
369
+ boundsMil: { width: 0, depth: 0, height: 0 }
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Returns the first complete bounds object from candidate metadata.
375
+ * @param {unknown[]} candidates Candidate bounds records.
376
+ * @returns {{ width: number, depth: number, height: number } | null}
377
+ */
378
+ static #firstBounds(candidates) {
379
+ for (const candidate of candidates || []) {
380
+ const bounds = PcbScene3dBuilder.#normalizeBounds(candidate)
381
+ if (bounds) {
382
+ return bounds
383
+ }
384
+ }
385
+
386
+ return null
387
+ }
388
+
389
+ /**
390
+ * Normalizes width/depth/height bounds metadata.
391
+ * @param {unknown} candidate Candidate bounds record.
392
+ * @returns {{ width: number, depth: number, height: number } | null}
393
+ */
394
+ static #normalizeBounds(candidate) {
395
+ if (!candidate || typeof candidate !== 'object') {
396
+ return null
397
+ }
398
+
399
+ const width = Number(candidate.width ?? candidate.x ?? candidate.sizeX)
400
+ const depth = Number(candidate.depth ?? candidate.y ?? candidate.sizeY)
401
+ const height = Number(
402
+ candidate.height ?? candidate.z ?? candidate.sizeZ
403
+ )
404
+
405
+ if (
406
+ !Number.isFinite(width) ||
407
+ !Number.isFinite(depth) ||
408
+ !Number.isFinite(height)
409
+ ) {
410
+ return null
411
+ }
412
+
413
+ return { width, depth, height }
414
+ }
415
+
279
416
  /**
280
417
  * Resolves explicit body placements to component anchors using a unique
281
418
  * nearest-neighbor pass plus an ordered-affinity fallback for repeated
@@ -1159,16 +1296,41 @@ export class PcbScene3dBuilder {
1159
1296
  static #isBodyPositionNearBoard(componentBody, board) {
1160
1297
  const bodyX = Number(componentBody?.positionMil?.x || 0)
1161
1298
  const bodyY = Number(componentBody?.positionMil?.y || 0)
1162
- const minX = Number(board?.minX || 0) - 600
1163
- const minY = Number(board?.minY || 0) - 600
1299
+ const xOverhang = PcbScene3dBuilder.#resolveUnmatchedBodyOverhang(
1300
+ board?.widthMil
1301
+ )
1302
+ const yOverhang = PcbScene3dBuilder.#resolveUnmatchedBodyOverhang(
1303
+ board?.heightMil
1304
+ )
1305
+ const minX = Number(board?.minX || 0) - xOverhang
1306
+ const minY = Number(board?.minY || 0) - yOverhang
1164
1307
  const maxX =
1165
- Number(board?.minX || 0) + Number(board?.widthMil || 0) + 600
1308
+ Number(board?.minX || 0) + Number(board?.widthMil || 0) + xOverhang
1166
1309
  const maxY =
1167
- Number(board?.minY || 0) + Number(board?.heightMil || 0) + 600
1310
+ Number(board?.minY || 0) + Number(board?.heightMil || 0) + yOverhang
1168
1311
 
1169
1312
  return bodyX >= minX && bodyX <= maxX && bodyY >= minY && bodyY <= maxY
1170
1313
  }
1171
1314
 
1315
+ /**
1316
+ * Resolves a proportional unresolved-body margin for one board axis.
1317
+ * @param {number | string | undefined} spanMil Board axis span.
1318
+ * @returns {number}
1319
+ */
1320
+ static #resolveUnmatchedBodyOverhang(spanMil) {
1321
+ const proportional =
1322
+ Math.max(Number(spanMil || 0), 0) *
1323
+ PcbScene3dBuilder.#UNMATCHED_BODY_OVERHANG_RATIO
1324
+
1325
+ return Math.min(
1326
+ PcbScene3dBuilder.#UNMATCHED_BODY_MAX_OVERHANG_MIL,
1327
+ Math.max(
1328
+ proportional,
1329
+ PcbScene3dBuilder.#UNMATCHED_BODY_MIN_OVERHANG_MIL
1330
+ )
1331
+ )
1332
+ }
1333
+
1172
1334
  /**
1173
1335
  * Normalizes one angle into the range [0, 360).
1174
1336
  * @param {number} angle
@@ -208,6 +208,49 @@ export class PcbScene3dModelRegistry {
208
208
  return this.#resolveExplicitMatch(componentBody?.name)
209
209
  }
210
210
 
211
+ /**
212
+ * Resolves a project-level full board assembly model for one PCB document.
213
+ * @param {{ fileName?: string }} documentModel
214
+ * @returns {{ origin: 'board-assembly', file?: File | Blob | null, name: string, relativePath: string, format: string } | null}
215
+ */
216
+ resolveBoardAssemblyModel(documentModel) {
217
+ const normalizedBoardBaseName = PcbScene3dModelRegistry.#normalizeToken(
218
+ PcbScene3dModelRegistry.#basenameWithoutExtension(
219
+ documentModel?.fileName
220
+ )
221
+ )
222
+ if (!normalizedBoardBaseName) {
223
+ return null
224
+ }
225
+
226
+ const rankedMatches = this.#modelFiles
227
+ .filter(
228
+ (file) =>
229
+ file.normalizedBaseName === normalizedBoardBaseName &&
230
+ PcbScene3dModelRegistry.#isBoardAssemblyPath(
231
+ file.relativePath
232
+ )
233
+ )
234
+ .sort(
235
+ (left, right) =>
236
+ PcbScene3dModelRegistry.#formatRank(left.format) -
237
+ PcbScene3dModelRegistry.#formatRank(right.format)
238
+ )
239
+
240
+ if (!rankedMatches.length) {
241
+ return null
242
+ }
243
+
244
+ const matchedFile = rankedMatches[0]
245
+ return {
246
+ origin: 'board-assembly',
247
+ file: matchedFile.file,
248
+ name: matchedFile.name,
249
+ relativePath: matchedFile.relativePath,
250
+ format: matchedFile.format
251
+ }
252
+ }
253
+
211
254
  /**
212
255
  * Normalizes one embedded payload for registry lookup.
213
256
  * @param {{ id?: string, checksum?: number | null, name?: string, format?: string, payloadText?: string, sourceStream?: string }} model
@@ -295,6 +338,37 @@ export class PcbScene3dModelRegistry {
295
338
  return format === 'wrl' ? 0 : 1
296
339
  }
297
340
 
341
+ /**
342
+ * Checks whether one model path is in a conventional board model folder.
343
+ * @param {string | undefined} relativePath
344
+ * @returns {boolean}
345
+ */
346
+ static #isBoardAssemblyPath(relativePath) {
347
+ return String(relativePath || '')
348
+ .replaceAll('\\', '/')
349
+ .split('/')
350
+ .some(
351
+ (part) =>
352
+ PcbScene3dModelRegistry.#normalizeToken(part) === '3dbodies'
353
+ )
354
+ }
355
+
356
+ /**
357
+ * Returns one path basename without its extension.
358
+ * @param {string | undefined} filePath
359
+ * @returns {string}
360
+ */
361
+ static #basenameWithoutExtension(filePath) {
362
+ const baseName =
363
+ String(filePath || '')
364
+ .replaceAll('\\', '/')
365
+ .split('/')
366
+ .filter(Boolean)
367
+ .at(-1) || ''
368
+
369
+ return baseName.replace(/\.[^.]+$/u, '')
370
+ }
371
+
298
372
  /**
299
373
  * Normalizes one lookup token.
300
374
  * @param {string | undefined} value