altium-toolkit 1.1.2 → 1.1.22

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 (57) hide show
  1. package/docs/api.md +37 -0
  2. package/docs/model-format.md +18 -0
  3. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +2 -2
  4. package/docs/testing.md +5 -0
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +5 -0
  7. package/src/core/altium/AltiumLayoutParser.mjs +275 -13
  8. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  9. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  10. package/src/core/altium/AltiumParser.mjs +245 -10
  11. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  12. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  13. package/src/core/altium/AsciiRecordParser.mjs +43 -11
  14. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  15. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  16. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  17. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  18. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  19. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  20. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  21. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  22. package/src/core/altium/SchematicImageParser.mjs +291 -6
  23. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  24. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  25. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  26. package/src/core/altium/SchematicPinParser.mjs +262 -24
  27. package/src/core/altium/SchematicPrimitiveParser.mjs +116 -8
  28. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  29. package/src/core/altium/SchematicStreamExtractor.mjs +62 -15
  30. package/src/core/altium/SchematicTextParser.mjs +125 -11
  31. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  32. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  33. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  34. package/src/core/altium/SourceComponentClient.mjs +239 -0
  35. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  36. package/src/parser.mjs +8 -0
  37. package/src/styles/altium-renderers.css +6 -6
  38. package/src/ui/PcbArcUtils.mjs +19 -2
  39. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  40. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  41. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  42. package/src/ui/SchematicColorResolver.mjs +263 -0
  43. package/src/ui/SchematicContentLayout.mjs +58 -1
  44. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  45. package/src/ui/SchematicImageRenderer.mjs +125 -10
  46. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  47. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  48. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  49. package/src/ui/SchematicNoteRenderer.mjs +87 -7
  50. package/src/ui/SchematicOwnerPinLabelLayout.mjs +560 -10
  51. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  52. package/src/ui/SchematicPinSvgRenderer.mjs +397 -48
  53. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  54. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  55. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  56. package/src/ui/SchematicShapeRenderer.mjs +109 -24
  57. package/src/ui/SchematicSvgRenderer.mjs +1210 -71
@@ -71,9 +71,16 @@ export class PcbScene3dBuilder {
71
71
  ? boardOutline.segments
72
72
  : []
73
73
  }
74
+ const componentBodyModels = componentBodies.map((componentBody) =>
75
+ PcbScene3dBuilder.#resolveComponentBodyModel(
76
+ componentBody,
77
+ modelRegistry
78
+ )
79
+ )
74
80
  const bodyMatches = PcbScene3dBuilder.#resolveComponentBodyMatches(
75
81
  componentBodies,
76
- components
82
+ components,
83
+ componentBodyModels
77
84
  )
78
85
  const topSilkscreen = PcbScene3dBuilder.#buildSilkscreenSide(
79
86
  primitiveLayers,
@@ -127,11 +134,11 @@ export class PcbScene3dBuilder {
127
134
  PcbScene3dBuilder.#buildExternalPlacement(
128
135
  componentBody,
129
136
  bodyMatches[index],
137
+ componentBodyModels[index],
130
138
  components,
131
139
  pads,
132
140
  board,
133
- thicknessMil,
134
- modelRegistry
141
+ thicknessMil
135
142
  )
136
143
  )
137
144
  .filter(Boolean),
@@ -210,29 +217,37 @@ export class PcbScene3dBuilder {
210
217
  }
211
218
  }
212
219
 
220
+ /**
221
+ * Resolves one component-body model through the active registry.
222
+ * @param {{ modelId?: string, checksum?: number | null, name?: string }} componentBody Component body metadata.
223
+ * @param {{ resolveComponentBodyModel?: (componentBody: any) => { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null } | null} modelRegistry Model registry.
224
+ * @returns {{ origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null}
225
+ */
226
+ static #resolveComponentBodyModel(componentBody, modelRegistry) {
227
+ return modelRegistry?.resolveComponentBodyModel?.(componentBody) || null
228
+ }
229
+
213
230
  /**
214
231
  * Builds one explicit external-model placement from normalized component
215
232
  * body metadata.
216
233
  * @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
217
234
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent
235
+ * @param {{ origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null} resolvedModel
218
236
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components
219
237
  * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads
220
238
  * @param {{ centerX: number, centerY: number }} board
221
239
  * @param {number} thicknessMil
222
- * @param {{ resolveComponentBodyModel?: (componentBody: any) => { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null } | null} modelRegistry
223
240
  * @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}
224
241
  */
225
242
  static #buildExternalPlacement(
226
243
  componentBody,
227
244
  matchedComponent,
245
+ resolvedModel,
228
246
  components,
229
247
  pads,
230
248
  board,
231
- thicknessMil,
232
- modelRegistry
249
+ thicknessMil
233
250
  ) {
234
- const resolvedModel =
235
- modelRegistry?.resolveComponentBodyModel?.(componentBody) || null
236
251
  if (!resolvedModel) {
237
252
  return null
238
253
  }
@@ -247,7 +262,8 @@ export class PcbScene3dBuilder {
247
262
  const mountSide = PcbScene3dPlacementSideResolver.resolvePlacementSide(
248
263
  componentBody,
249
264
  matchedComponent,
250
- components
265
+ components,
266
+ board
251
267
  )
252
268
  const halfBoardThickness = thicknessMil / 2
253
269
  const sourcePosition =
@@ -283,7 +299,9 @@ export class PcbScene3dBuilder {
283
299
  bodyRotationDeg: Number(componentBody.rotationDeg || 0),
284
300
  modelTransform: {
285
301
  rotationDeg: modelRotation,
286
- dzMil: Number(componentBody.dzMil || 0)
302
+ dzMil: PcbScene3dBuilder.#resolveComponentBodyVerticalOffset(
303
+ componentBody
304
+ )
287
305
  },
288
306
  projection: PcbScene3dBuilder.#resolveProjectionDiagnostics(
289
307
  componentBody,
@@ -295,6 +313,22 @@ export class PcbScene3dBuilder {
295
313
  }
296
314
  }
297
315
 
316
+ /**
317
+ * Resolves the vertical offset that should remain after the viewer seats
318
+ * raw model bounds on the board face.
319
+ * @param {{ dzMil?: number, standoffHeightMil?: number | null }} componentBody Component-body placement metadata.
320
+ * @returns {number}
321
+ */
322
+ static #resolveComponentBodyVerticalOffset(componentBody) {
323
+ const standoffHeightMil = Number(componentBody?.standoffHeightMil)
324
+ if (Number.isFinite(standoffHeightMil)) {
325
+ return standoffHeightMil < 0 ? standoffHeightMil : 0
326
+ }
327
+
328
+ const dzMil = Number(componentBody?.dzMil)
329
+ return Number.isFinite(dzMil) && dzMil < 0 ? dzMil : 0
330
+ }
331
+
298
332
  /**
299
333
  * Explains which footprint projection source informed one external model.
300
334
  * @param {object} componentBody Normalized component body row.
@@ -420,19 +454,34 @@ export class PcbScene3dBuilder {
420
454
  * components.
421
455
  * @param {{ modelId?: string, name?: string, identifier?: string, positionMil?: { x?: number, y?: number } }[]} componentBodies
422
456
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components
457
+ * @param {({ origin: string, name: string, format: string } | null)[]} resolvedBodyModels
423
458
  * @returns {({ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string } | null)[]}
424
459
  */
425
- static #resolveComponentBodyMatches(componentBodies, components) {
460
+ static #resolveComponentBodyMatches(
461
+ componentBodies,
462
+ components,
463
+ resolvedBodyModels
464
+ ) {
426
465
  const matches = new Array(componentBodies.length).fill(null)
427
466
  const assignedBodyIndexes = new Set()
428
467
  const assignedComponentIndexes = new Set()
429
468
  const closeCandidates = []
430
469
  const matchContext = PcbScene3dBuilder.#buildBodyMatchContext(
431
470
  componentBodies,
432
- components
471
+ components,
472
+ resolvedBodyModels
433
473
  )
434
474
 
435
475
  componentBodies.forEach((componentBody, bodyIndex) => {
476
+ if (
477
+ !PcbScene3dBuilder.#isResolvableComponentBody(
478
+ resolvedBodyModels,
479
+ bodyIndex
480
+ )
481
+ ) {
482
+ return
483
+ }
484
+
436
485
  components.forEach((component, componentIndex) => {
437
486
  const distance =
438
487
  PcbScene3dBuilder.#distanceBetweenBodyAndComponent(
@@ -449,9 +498,31 @@ export class PcbScene3dBuilder {
449
498
  distance
450
499
  )
451
500
  ) {
501
+ const affinityScore =
502
+ PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
503
+ componentBody,
504
+ component
505
+ )
506
+ const sideCompatible =
507
+ PcbScene3dBuilder.#isBodyComponentSideCompatible(
508
+ componentBody,
509
+ component
510
+ )
511
+ const precise =
512
+ PcbScene3dBuilder.#isPreciseBodyComponentDistance(
513
+ distance
514
+ )
515
+
452
516
  closeCandidates.push({
453
517
  bodyIndex,
454
518
  componentIndex,
519
+ affinityScore,
520
+ preciseOwnerScore:
521
+ precise && (sideCompatible || affinityScore > 0)
522
+ ? 1
523
+ : 0,
524
+ sideAffinityScore:
525
+ sideCompatible && affinityScore > 0 ? 1 : 0,
455
526
  distance
456
527
  })
457
528
  }
@@ -459,7 +530,13 @@ export class PcbScene3dBuilder {
459
530
  })
460
531
 
461
532
  closeCandidates
462
- .sort((left, right) => left.distance - right.distance)
533
+ .sort(
534
+ (left, right) =>
535
+ right.preciseOwnerScore - left.preciseOwnerScore ||
536
+ right.sideAffinityScore - left.sideAffinityScore ||
537
+ right.affinityScore - left.affinityScore ||
538
+ left.distance - right.distance
539
+ )
463
540
  .forEach(({ bodyIndex, componentIndex }) => {
464
541
  if (
465
542
  assignedBodyIndexes.has(bodyIndex) ||
@@ -475,6 +552,15 @@ export class PcbScene3dBuilder {
475
552
 
476
553
  const groupedBodyIndexes = new Map()
477
554
  componentBodies.forEach((componentBody, bodyIndex) => {
555
+ if (
556
+ !PcbScene3dBuilder.#isResolvableComponentBody(
557
+ resolvedBodyModels,
558
+ bodyIndex
559
+ )
560
+ ) {
561
+ return
562
+ }
563
+
478
564
  const groupKey =
479
565
  PcbScene3dPlacementSideResolver.resolveBodyGroupKey(
480
566
  componentBody
@@ -543,14 +629,28 @@ export class PcbScene3dBuilder {
543
629
  * Builds reusable identity statistics for body/component matching.
544
630
  * @param {{ modelId?: string, name?: string, identifier?: string }[]} componentBodies
545
631
  * @param {{ pattern?: string, source?: string, modelPath?: string }[]} components
632
+ * @param {({ origin: string, name: string, format: string } | null)[]} resolvedBodyModels
546
633
  * @returns {{ bodyGroupCounts: Map<string, number>, candidateComponentCounts: Map<string, number> }}
547
634
  */
548
- static #buildBodyMatchContext(componentBodies, components) {
635
+ static #buildBodyMatchContext(
636
+ componentBodies,
637
+ components,
638
+ resolvedBodyModels
639
+ ) {
549
640
  const bodyGroupCounts = new Map()
550
641
  const bodyByGroup = new Map()
551
642
  const candidateComponentCounts = new Map()
552
643
 
553
- for (const componentBody of componentBodies) {
644
+ componentBodies.forEach((componentBody, bodyIndex) => {
645
+ if (
646
+ !PcbScene3dBuilder.#isResolvableComponentBody(
647
+ resolvedBodyModels,
648
+ bodyIndex
649
+ )
650
+ ) {
651
+ return
652
+ }
653
+
554
654
  const groupKey =
555
655
  PcbScene3dPlacementSideResolver.resolveBodyGroupKey(
556
656
  componentBody
@@ -562,7 +662,7 @@ export class PcbScene3dBuilder {
562
662
  if (!bodyByGroup.has(groupKey)) {
563
663
  bodyByGroup.set(groupKey, componentBody)
564
664
  }
565
- }
665
+ })
566
666
 
567
667
  bodyByGroup.forEach((componentBody, groupKey) => {
568
668
  candidateComponentCounts.set(
@@ -580,6 +680,91 @@ export class PcbScene3dBuilder {
580
680
  return { bodyGroupCounts, candidateComponentCounts }
581
681
  }
582
682
 
683
+ /**
684
+ * Returns true when one body row can produce a renderable external model.
685
+ * @param {unknown[]} resolvedBodyModels Resolved body-model entries.
686
+ * @param {number} bodyIndex Body index.
687
+ * @returns {boolean}
688
+ */
689
+ static #isResolvableComponentBody(resolvedBodyModels, bodyIndex) {
690
+ return Boolean(
691
+ Array.isArray(resolvedBodyModels)
692
+ ? resolvedBodyModels[bodyIndex]
693
+ : true
694
+ )
695
+ }
696
+
697
+ /**
698
+ * Returns true when the body/component anchors are close enough to be
699
+ * considered an explicit placement match.
700
+ * @param {number} distanceMil Body/component anchor distance in mil.
701
+ * @returns {boolean}
702
+ */
703
+ static #isPreciseBodyComponentDistance(distanceMil) {
704
+ return (
705
+ Number(distanceMil) <=
706
+ PcbScene3dBuilder.#PRECISE_BODY_MATCH_TOLERANCE_MIL
707
+ )
708
+ }
709
+
710
+ /**
711
+ * Checks whether the authored mechanical layer agrees with the component
712
+ * layer. Unknown sides remain neutral so generic mechanical bodies can
713
+ * still match from distance and identity.
714
+ * @param {{ layer?: string }} componentBody Component-body record.
715
+ * @param {{ layer?: string }} component Component record.
716
+ * @returns {boolean}
717
+ */
718
+ static #isBodyComponentSideCompatible(componentBody, component) {
719
+ const bodySide = PcbScene3dBuilder.#resolveMechanicalLayerSide(
720
+ componentBody?.layer
721
+ )
722
+ const componentSide = PcbScene3dBuilder.#resolveComponentLayerSide(
723
+ component?.layer
724
+ )
725
+
726
+ return !bodySide || !componentSide || bodySide === componentSide
727
+ }
728
+
729
+ /**
730
+ * Resolves a component layer to a board side.
731
+ * @param {string | undefined} layer Component layer.
732
+ * @returns {'top' | 'bottom' | null}
733
+ */
734
+ static #resolveComponentLayerSide(layer) {
735
+ const normalized = String(layer || '')
736
+ .trim()
737
+ .toUpperCase()
738
+
739
+ if (!normalized) {
740
+ return null
741
+ }
742
+
743
+ if (normalized.includes('BOTTOM') || normalized === 'BOT') {
744
+ return 'bottom'
745
+ }
746
+
747
+ if (normalized.includes('TOP')) {
748
+ return 'top'
749
+ }
750
+
751
+ return null
752
+ }
753
+
754
+ /**
755
+ * Resolves common paired Altium mechanical layer numbers to a board side.
756
+ * @param {string | undefined} layer Mechanical layer.
757
+ * @returns {'top' | 'bottom' | null}
758
+ */
759
+ static #resolveMechanicalLayerSide(layer) {
760
+ const match = String(layer || '').match(/^MECHANICAL\s*(\d+)$/i)
761
+ if (!match) {
762
+ return null
763
+ }
764
+
765
+ return Number(match[1]) % 2 === 0 ? 'bottom' : 'top'
766
+ }
767
+
583
768
  /**
584
769
  * Returns true when a close body/component pair is identity-compatible and
585
770
  * the body group can be matched one-to-one to component anchors.
@@ -595,10 +780,7 @@ export class PcbScene3dBuilder {
595
780
  matchContext,
596
781
  distanceMil
597
782
  ) {
598
- if (
599
- Number(distanceMil) <=
600
- PcbScene3dBuilder.#PRECISE_BODY_MATCH_TOLERANCE_MIL
601
- ) {
783
+ if (PcbScene3dBuilder.#isPreciseBodyComponentDistance(distanceMil)) {
602
784
  return true
603
785
  }
604
786
 
@@ -6,14 +6,14 @@
6
6
  * Indexes session companion assets for 3D model lookup.
7
7
  */
8
8
  export class PcbScene3dModelRegistry {
9
- /** @type {{ file?: File | Blob | null, name: string, relativePath: string, format: string, normalizedPath: string, normalizedBaseName: string }[]} */
9
+ /** @type {{ file?: File | Blob | null, name: string, relativePath: string, format: string, source: string, normalizedPath: string, normalizedBaseName: string }[]} */
10
10
  #modelFiles
11
11
 
12
12
  /** @type {{ id: string, checksum: number | null, name: string, format: string, payloadText: string, sourceStream: string, normalizedId: string, normalizedBaseName: string }[]} */
13
13
  #embeddedModels
14
14
 
15
15
  /**
16
- * @param {{ file?: File | Blob | null, name: string, relativePath: string, format: string, normalizedPath: string, normalizedBaseName: string }[]} modelFiles
16
+ * @param {{ file?: File | Blob | null, name: string, relativePath: string, format: string, source: string, normalizedPath: string, normalizedBaseName: string }[]} modelFiles
17
17
  * @param {{ id: string, checksum: number | null, name: string, format: string, payloadText: string, sourceStream: string, normalizedId: string, normalizedBaseName: string }[]} embeddedModels
18
18
  */
19
19
  constructor(modelFiles, embeddedModels) {
@@ -23,7 +23,7 @@ export class PcbScene3dModelRegistry {
23
23
 
24
24
  /**
25
25
  * Creates one model registry from session files.
26
- * @param {{ name?: string, relativePath?: string }[]} sessionFiles
26
+ * @param {{ name?: string, relativePath?: string, source?: string }[]} sessionFiles
27
27
  * @param {{ id?: string, checksum?: number | null, name?: string, format?: string, payloadText?: string, sourceStream?: string }[]} [embeddedModels]
28
28
  * @returns {PcbScene3dModelRegistry}
29
29
  */
@@ -63,8 +63,8 @@ export class PcbScene3dModelRegistry {
63
63
 
64
64
  /**
65
65
  * Normalizes one session file into registry metadata.
66
- * @param {{ name?: string, relativePath?: string }} file
67
- * @returns {{ file?: File | Blob | null, name: string, relativePath: string, format: string, normalizedPath: string, normalizedBaseName: string } | null}
66
+ * @param {{ name?: string, relativePath?: string, source?: string }} file
67
+ * @returns {{ file?: File | Blob | null, name: string, relativePath: string, format: string, source: string, normalizedPath: string, normalizedBaseName: string } | null}
68
68
  */
69
69
  static #normalizeFile(file) {
70
70
  const relativePath = String(file?.relativePath || file?.name || '')
@@ -80,6 +80,7 @@ export class PcbScene3dModelRegistry {
80
80
  name,
81
81
  relativePath,
82
82
  format,
83
+ source: String(file?.source || ''),
83
84
  normalizedPath:
84
85
  PcbScene3dModelRegistry.#normalizeToken(relativePath),
85
86
  normalizedBaseName: PcbScene3dModelRegistry.#normalizeToken(
@@ -122,13 +123,7 @@ export class PcbScene3dModelRegistry {
122
123
  (file) => file.normalizedPath === normalizedPath
123
124
  )
124
125
  if (byPath) {
125
- return {
126
- origin: 'session',
127
- file: byPath.file,
128
- name: byPath.name,
129
- relativePath: byPath.relativePath,
130
- format: byPath.format
131
- }
126
+ return PcbScene3dModelRegistry.#sessionModelFromFile(byPath)
132
127
  }
133
128
 
134
129
  const fileName =
@@ -165,7 +160,7 @@ export class PcbScene3dModelRegistry {
165
160
  /**
166
161
  * Resolves one indexed file by normalized basename and format priority.
167
162
  * @param {string} normalizedBaseName
168
- * @returns {{ file?: File | Blob | null, name: string, relativePath: string, format: string } | null}
163
+ * @returns {{ file?: File | Blob | null, name: string, relativePath: string, format: string, source?: string } | null}
169
164
  */
170
165
  #resolveByBaseName(normalizedBaseName) {
171
166
  if (!normalizedBaseName) {
@@ -184,13 +179,28 @@ export class PcbScene3dModelRegistry {
184
179
  return null
185
180
  }
186
181
 
187
- return {
182
+ return PcbScene3dModelRegistry.#sessionModelFromFile(rankedMatches[0])
183
+ }
184
+
185
+ /**
186
+ * Builds public session model metadata from one indexed file row.
187
+ * @param {{ file?: File | Blob | null, name: string, relativePath: string, format: string, source?: string }} file Indexed model file.
188
+ * @returns {{ origin: 'session', file?: File | Blob | null, name: string, relativePath: string, format: string, source?: string }}
189
+ */
190
+ static #sessionModelFromFile(file) {
191
+ const model = {
188
192
  origin: 'session',
189
- file: rankedMatches[0].file,
190
- name: rankedMatches[0].name,
191
- relativePath: rankedMatches[0].relativePath,
192
- format: rankedMatches[0].format
193
+ file: file.file,
194
+ name: file.name,
195
+ relativePath: file.relativePath,
196
+ format: file.format
193
197
  }
198
+ const source = String(file.source || '').trim()
199
+ if (source) {
200
+ model.source = source
201
+ }
202
+
203
+ return model
194
204
  }
195
205
 
196
206
  /**
@@ -12,15 +12,17 @@ export class PcbScene3dPlacementSideResolver {
12
12
 
13
13
  /**
14
14
  * Resolves which board side one explicit model should mount on.
15
- * @param {{ layer?: string, standoffHeightMil?: number | null, overallHeightMil?: number | null }} componentBody
15
+ * @param {{ layer?: string, positionMil?: { x?: number, y?: number }, standoffHeightMil?: number | null, overallHeightMil?: number | null }} componentBody
16
16
  * @param {{ layer?: string } | null} matchedComponent
17
17
  * @param {{ layer?: string, pattern?: string, source?: string, modelPath?: string, x?: number, y?: number }[]} components
18
+ * @param {{ minX?: number, minY?: number, widthMil?: number, heightMil?: number } | null} board
18
19
  * @returns {'top' | 'bottom'}
19
20
  */
20
21
  static resolvePlacementSide(
21
22
  componentBody,
22
23
  matchedComponent,
23
- components = []
24
+ components = [],
25
+ board = null
24
26
  ) {
25
27
  const matchedSide =
26
28
  PcbScene3dPlacementSideResolver.#resolveComponentLayerSide(
@@ -32,7 +34,13 @@ export class PcbScene3dPlacementSideResolver {
32
34
 
33
35
  const standoffSide =
34
36
  PcbScene3dPlacementSideResolver.#resolveStandoffSide(componentBody)
35
- if (standoffSide) {
37
+ if (
38
+ standoffSide &&
39
+ PcbScene3dPlacementSideResolver.#isBodyAnchorInsideBoard(
40
+ componentBody,
41
+ board
42
+ )
43
+ ) {
36
44
  return standoffSide
37
45
  }
38
46
 
@@ -45,11 +53,15 @@ export class PcbScene3dPlacementSideResolver {
45
53
  return nearbySide
46
54
  }
47
55
 
48
- return (
56
+ const mechanicalSide =
49
57
  PcbScene3dPlacementSideResolver.#resolveMechanicalLayerSide(
50
58
  componentBody?.layer
51
- ) || 'top'
52
- )
59
+ )
60
+ if (mechanicalSide) {
61
+ return mechanicalSide
62
+ }
63
+
64
+ return standoffSide || 'top'
53
65
  }
54
66
 
55
67
  /**
@@ -148,6 +160,36 @@ export class PcbScene3dPlacementSideResolver {
148
160
  return Math.abs(standoff) >= threshold ? 'bottom' : null
149
161
  }
150
162
 
163
+ /**
164
+ * Returns true when one body anchor sits inside the normalized board bounds.
165
+ * @param {{ positionMil?: { x?: number, y?: number } } | null} componentBody
166
+ * @param {{ minX?: number, minY?: number, widthMil?: number, heightMil?: number } | null} board
167
+ * @returns {boolean}
168
+ */
169
+ static #isBodyAnchorInsideBoard(componentBody, board) {
170
+ const x = Number(componentBody?.positionMil?.x)
171
+ const y = Number(componentBody?.positionMil?.y)
172
+ const minX = Number(board?.minX)
173
+ const minY = Number(board?.minY)
174
+ const width = Number(board?.widthMil)
175
+ const height = Number(board?.heightMil)
176
+
177
+ if (
178
+ !Number.isFinite(x) ||
179
+ !Number.isFinite(y) ||
180
+ !Number.isFinite(minX) ||
181
+ !Number.isFinite(minY) ||
182
+ !Number.isFinite(width) ||
183
+ !Number.isFinite(height) ||
184
+ width <= 0 ||
185
+ height <= 0
186
+ ) {
187
+ return false
188
+ }
189
+
190
+ return x >= minX && x <= minX + width && y >= minY && y <= minY + height
191
+ }
192
+
151
193
  /**
152
194
  * Resolves side from the nearest footprint-compatible component.
153
195
  * @param {{ positionMil?: { x?: number, y?: number } } & { name?: string, identifier?: string }} componentBody