altium-toolkit 1.4.14 → 1.4.16

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.
package/README.md CHANGED
@@ -57,6 +57,10 @@ extension graphs retain their proven ownership instead of repeating defensive
57
57
  binary classification. See the
58
58
  [1.4.2 release notes](docs/release-notes-v1.4.2.md).
59
59
 
60
+ Version 1.4.16 preserves validated authored STEP anchors during late geometric
61
+ owner recovery and keeps board-space component yaw unchanged. See the
62
+ [1.4.16 release notes](docs/release-notes-v1.4.16.md).
63
+
60
64
  Default `extensions: 'canonical'` keeps compact Altium summary metadata.
61
65
  Request the complete native read model with `extensions: 'full'`,
62
66
  `preserveRaw: true`, or `extensions: ['altium.native-model']`. Project batches
@@ -224,6 +228,7 @@ const legacyCircuitJson = AltiumParser.parseArrayBuffer(file.name, arrayBuffer)
224
228
  - [API](docs/api.md)
225
229
  - [Capabilities](docs/capabilities.md)
226
230
  - [Migration from 1.1.41](docs/migration.md)
231
+ - [1.4.16 release notes](docs/release-notes-v1.4.16.md)
227
232
  - [1.4.2 release notes](docs/release-notes-v1.4.2.md)
228
233
  - [1.4.1 release notes](docs/release-notes-v1.4.1.md)
229
234
  - [1.4.0 release notes](docs/release-notes-v1.4.0.md)
@@ -0,0 +1,27 @@
1
+ # altium-toolkit 1.4.16
2
+
3
+ Version 1.4.16 preserves authored STEP anchors during late geometric owner
4
+ recovery and leaves model-local orientation normalization to the 3D runtime.
5
+
6
+ ## 3D placement fidelity
7
+
8
+ - Near-centroid surface-mount bodies retain their authored positions when the
9
+ recovered owner is geometrically unambiguous.
10
+ - Height-backed owner recovery marks valid source anchors explicitly instead
11
+ of converting them into component-center offsets.
12
+ - Geometric owner recovery preserves the component's authored board-space yaw;
13
+ it no longer infers package-specific half-turns from labels or pad topology.
14
+
15
+ ## Compatibility
16
+
17
+ - Placement recovery remains based on generic body, pad, and footprint
18
+ geometry without project names, filenames, designators, or library strings.
19
+ - Existing schematic colors, canvas borders, and public renderer signatures
20
+ remain unchanged.
21
+
22
+ ## Verification
23
+
24
+ - Repository-owned synthetic tests cover near-centroid connectors,
25
+ height-backed authored anchors, and preserved switch yaw.
26
+ - The complete package suite, formatting check, and npm package dry run are
27
+ required for release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.4.14",
3
+ "version": "1.4.16",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -0,0 +1,40 @@
1
+ # Altium 3D Late Owner Recovery
2
+
3
+ ## Scope
4
+
5
+ Recover explicit STEP placements whose shape-body rows do not carry a direct
6
+ component owner. The recovery must use generic PCB geometry and package
7
+ metadata only. It must not depend on file names, project names, designators,
8
+ vendor names, or library identifiers.
9
+
10
+ ## Required behavior
11
+
12
+ - Resolve a unique owner when an unowned body anchor coincides with the center
13
+ of pads owned by one component.
14
+ - Resolve a unique owner when a body origin is a proven model corner and the
15
+ corresponding model dimensions agree with the owner's footprint geometry.
16
+ - Resolve a unique owner for multi-row through-hole packages when the model
17
+ span agrees with the owned pad span and the body anchor lies within that
18
+ span.
19
+ - Use component height agreement only as supporting evidence for nearby
20
+ package ownership; height alone is insufficient.
21
+ - Preserve pad-centroid and nearby height-backed body positions because they
22
+ are valid authored source anchors, and mark height-backed anchors so the
23
+ runtime does not apply a second source-origin adjustment.
24
+ - Center proven corner-origin and row-origin bodies on the component owner and
25
+ retain the source offset as transform diagnostics.
26
+ - Recompute component side and authored vertical standoff after late owner
27
+ recovery.
28
+ - Preserve authored component yaw; model-local axis normalization belongs to
29
+ the runtime that has access to the imported mesh envelope.
30
+ - Decline ambiguous matches.
31
+
32
+ ## Verification
33
+
34
+ - Focused synthetic tests use obfuscated package identities and cover exact and
35
+ near pad centroids, corner origin, row origin, authored source anchors, late
36
+ standoff, ambiguity rejection, and preserved component yaw.
37
+ - The full toolkit test suite and formatting check pass.
38
+ - A read-only probe of the supplied PCB must show real component designators
39
+ for the affected placements, owner-centered corner/row models, preserved
40
+ authored anchors, recovered top-side standoff, and source-authored yaw.
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: GPL-3.0-or-later
3
3
 
4
4
  import { PcbScene3dBuilder as HistoricalPcbScene3dBuilder } from '../ui/PcbScene3dBuilder.mjs'
5
+ import { AltiumScene3dGeometricOwnerRecovery } from '../ui/AltiumScene3dGeometricOwnerRecovery.mjs'
5
6
 
6
7
  const NEGATIVE_SOURCE_Z_DOMINANCE_RATIO = 0.8
7
8
  const POSITION_EPSILON_MIL = 1e-6
@@ -18,7 +19,10 @@ export class PcbScene3dBuilder {
18
19
  * @returns {object}
19
20
  */
20
21
  static build(documentModel, options = {}) {
21
- const scene = HistoricalPcbScene3dBuilder.build(documentModel, options)
22
+ const scene = AltiumScene3dGeometricOwnerRecovery.apply(
23
+ HistoricalPcbScene3dBuilder.build(documentModel, options),
24
+ documentModel
25
+ )
22
26
  if (!Array.isArray(scene?.externalPlacements)) {
23
27
  return scene
24
28
  }
@@ -0,0 +1,841 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { AltiumScene3dRecoverySpatialIndex } from './AltiumScene3dRecoverySpatialIndex.mjs'
5
+
6
+ /**
7
+ * Recovers component ownership for Altium STEP bodies whose native rows omit
8
+ * a direct owner reference.
9
+ */
10
+ export class AltiumScene3dGeometricOwnerRecovery {
11
+ static #ANCHOR_TOLERANCE_MIL = 8
12
+ static #BODY_INDEX_CELL_MIL = 8
13
+ static #COMPONENT_INDEX_CELL_MIL = 500
14
+ static #MINIMUM_SCORE = 12
15
+ static #MINIMUM_SCORE_MARGIN = 4
16
+ static #PAD_CENTROID_TOLERANCE_MIL = 12
17
+
18
+ /**
19
+ * Applies geometry-backed owner recovery to final external placements.
20
+ * @param {object} sceneDescription Built scene description.
21
+ * @param {object} documentModel Parsed Altium document.
22
+ * @returns {object}
23
+ */
24
+ static apply(sceneDescription, documentModel) {
25
+ if (
26
+ String(sceneDescription?.sourceFormat || '').toLowerCase() !==
27
+ 'altium' ||
28
+ !Array.isArray(sceneDescription?.externalPlacements)
29
+ ) {
30
+ return sceneDescription
31
+ }
32
+
33
+ const components = Array.isArray(documentModel?.pcb?.components)
34
+ ? documentModel.pcb.components
35
+ : []
36
+ const bodies = Array.isArray(documentModel?.pcb?.componentBodies)
37
+ ? documentModel.pcb.componentBodies
38
+ : []
39
+ const pads = Array.isArray(documentModel?.pcb?.pads)
40
+ ? documentModel.pcb.pads
41
+ : []
42
+ if (!components.length || !bodies.length || !pads.length) {
43
+ return sceneDescription
44
+ }
45
+
46
+ const componentsByDesignator = new Map(
47
+ components.map((component) => [
48
+ String(component?.designator || ''),
49
+ component
50
+ ])
51
+ )
52
+ const padsByComponent =
53
+ AltiumScene3dRecoverySpatialIndex.padsByComponent(pads)
54
+ const geometryByComponent = new Map(
55
+ components.map((component) => [
56
+ component,
57
+ AltiumScene3dGeometricOwnerRecovery.#componentPadGeometry(
58
+ component,
59
+ padsByComponent.get(Number(component?.componentIndex)) || []
60
+ )
61
+ ])
62
+ )
63
+ const recoveryContext = {
64
+ componentsByDesignator,
65
+ bodyIndex: AltiumScene3dRecoverySpatialIndex.create(
66
+ bodies,
67
+ AltiumScene3dGeometricOwnerRecovery.#BODY_INDEX_CELL_MIL,
68
+ (body) => body?.positionMil
69
+ ),
70
+ geometryByComponent,
71
+ componentIndex: AltiumScene3dRecoverySpatialIndex.create(
72
+ components
73
+ .map((component) => ({
74
+ component,
75
+ geometry: geometryByComponent.get(component)
76
+ }))
77
+ .filter(({ geometry }) => geometry),
78
+ AltiumScene3dGeometricOwnerRecovery.#COMPONENT_INDEX_CELL_MIL,
79
+ ({ component }) => component
80
+ ),
81
+ board: sceneDescription?.board
82
+ }
83
+
84
+ return {
85
+ ...sceneDescription,
86
+ externalPlacements: sceneDescription.externalPlacements.map(
87
+ (placement) =>
88
+ AltiumScene3dGeometricOwnerRecovery.#recoverPlacement(
89
+ placement,
90
+ recoveryContext
91
+ )
92
+ )
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Recovers one placement or applies exact-owner package corrections.
98
+ * @param {object} placement External placement.
99
+ * @param {object} context Pre-indexed recovery data.
100
+ * @returns {object}
101
+ */
102
+ static #recoverPlacement(placement, context) {
103
+ const body = AltiumScene3dGeometricOwnerRecovery.#resolveComponentBody(
104
+ placement,
105
+ context.bodyIndex
106
+ )
107
+ if (!body) return placement
108
+
109
+ const currentOwner = context.componentsByDesignator.get(
110
+ String(placement?.designator || '')
111
+ )
112
+ if (currentOwner) return placement
113
+
114
+ const match =
115
+ AltiumScene3dGeometricOwnerRecovery.#resolveGeometricOwner(
116
+ placement,
117
+ body,
118
+ context
119
+ )
120
+ if (!match) return placement
121
+
122
+ return AltiumScene3dGeometricOwnerRecovery.#withOwner(
123
+ placement,
124
+ body,
125
+ match,
126
+ context.board
127
+ )
128
+ }
129
+
130
+ /**
131
+ * Resolves the source body row occupying a placement anchor.
132
+ * @param {object} placement External placement.
133
+ * @param {Map<string, object[]>} bodyIndex Spatial body index.
134
+ * @returns {object | null}
135
+ */
136
+ static #resolveComponentBody(placement, bodyIndex) {
137
+ const anchor = placement?.bodyPositionMil
138
+ if (!anchor) return null
139
+
140
+ const matches = AltiumScene3dRecoverySpatialIndex.nearby(
141
+ bodyIndex,
142
+ anchor,
143
+ AltiumScene3dGeometricOwnerRecovery.#BODY_INDEX_CELL_MIL,
144
+ 1
145
+ )
146
+ .map((body) => ({
147
+ body,
148
+ distance: AltiumScene3dGeometricOwnerRecovery.#distance(
149
+ anchor,
150
+ body?.positionMil
151
+ ),
152
+ identity:
153
+ AltiumScene3dGeometricOwnerRecovery.#bodyIdentityScore(
154
+ placement,
155
+ body
156
+ )
157
+ }))
158
+ .filter(
159
+ (candidate) =>
160
+ candidate.distance <=
161
+ AltiumScene3dGeometricOwnerRecovery.#ANCHOR_TOLERANCE_MIL
162
+ )
163
+ .sort(
164
+ (left, right) =>
165
+ right.identity - left.identity ||
166
+ left.distance - right.distance
167
+ )
168
+
169
+ const best = matches[0]
170
+ const second = matches[1]
171
+ if (
172
+ second &&
173
+ best.identity === second.identity &&
174
+ Math.abs(best.distance - second.distance) < 1e-6
175
+ ) {
176
+ return null
177
+ }
178
+ return best?.body || null
179
+ }
180
+
181
+ /**
182
+ * Scores body identity without requiring vendor or library tokens.
183
+ * @param {object} placement External placement.
184
+ * @param {object} body Source body.
185
+ * @returns {number}
186
+ */
187
+ static #bodyIdentityScore(placement, body) {
188
+ const placementText =
189
+ AltiumScene3dGeometricOwnerRecovery.#normalizeIdentity([
190
+ placement?.designator,
191
+ placement?.externalModel?.name
192
+ ])
193
+ const bodyText = AltiumScene3dGeometricOwnerRecovery.#normalizeIdentity(
194
+ [body?.identifier, body?.name]
195
+ )
196
+
197
+ return placementText && bodyText && placementText.includes(bodyText)
198
+ ? bodyText.length
199
+ : 0
200
+ }
201
+
202
+ /**
203
+ * Selects only components within the model-dependent recovery radius.
204
+ * @param {object} placement External placement.
205
+ * @param {Map<string, object[]>} componentIndex Component spatial index.
206
+ * @returns {{ component: object, geometry: object }[]}
207
+ */
208
+ static #nearbyComponentGeometry(placement, componentIndex) {
209
+ const longestModelDimension = Math.max(
210
+ 0,
211
+ ...AltiumScene3dGeometricOwnerRecovery.#modelDimensions(placement)
212
+ )
213
+ const radiusMil = Math.max(300, longestModelDimension)
214
+ const cellRadius = Math.ceil(
215
+ radiusMil /
216
+ AltiumScene3dGeometricOwnerRecovery.#COMPONENT_INDEX_CELL_MIL
217
+ )
218
+ return AltiumScene3dRecoverySpatialIndex.nearby(
219
+ componentIndex,
220
+ placement?.bodyPositionMil,
221
+ AltiumScene3dGeometricOwnerRecovery.#COMPONENT_INDEX_CELL_MIL,
222
+ cellRadius
223
+ ).filter(
224
+ ({ component }) =>
225
+ AltiumScene3dGeometricOwnerRecovery.#distance(
226
+ placement?.bodyPositionMil,
227
+ component
228
+ ) <= radiusMil
229
+ )
230
+ }
231
+
232
+ /**
233
+ * Resolves a unique geometry-backed owner candidate.
234
+ * @param {object} placement External placement.
235
+ * @param {object} body Source component body.
236
+ * @param {object} context Pre-indexed recovery data.
237
+ * @returns {{ component: object, mode: string, score: number } | null}
238
+ */
239
+ static #resolveGeometricOwner(placement, body, context) {
240
+ const candidates =
241
+ AltiumScene3dGeometricOwnerRecovery.#nearbyComponentGeometry(
242
+ placement,
243
+ context.componentIndex
244
+ )
245
+ .map(({ component, geometry }) =>
246
+ AltiumScene3dGeometricOwnerRecovery.#scoreCandidate(
247
+ placement,
248
+ body,
249
+ component,
250
+ geometry
251
+ )
252
+ )
253
+ .filter(Boolean)
254
+ .sort(
255
+ (left, right) =>
256
+ right.score - left.score ||
257
+ left.distance - right.distance
258
+ )
259
+ const best = candidates[0]
260
+ const second = candidates[1]
261
+
262
+ if (
263
+ !best ||
264
+ best.score < AltiumScene3dGeometricOwnerRecovery.#MINIMUM_SCORE ||
265
+ (second &&
266
+ best.score - second.score <
267
+ AltiumScene3dGeometricOwnerRecovery.#MINIMUM_SCORE_MARGIN)
268
+ ) {
269
+ return null
270
+ }
271
+
272
+ return best
273
+ }
274
+
275
+ /**
276
+ * Scores one candidate from owned pad geometry and source dimensions.
277
+ * @param {object} placement External placement.
278
+ * @param {object} body Source component body.
279
+ * @param {object} component Candidate component.
280
+ * @param {object | null} geometry Precomputed pad geometry.
281
+ * @returns {{ component: object, mode: string, score: number, distance: number } | null}
282
+ */
283
+ static #scoreCandidate(placement, body, component, geometry) {
284
+ if (!geometry || geometry.pads.length < 2) return null
285
+
286
+ const anchor = placement?.bodyPositionMil || {}
287
+ const distance = AltiumScene3dGeometricOwnerRecovery.#distance(
288
+ anchor,
289
+ component
290
+ )
291
+ const centroidDistance = AltiumScene3dGeometricOwnerRecovery.#distance(
292
+ anchor,
293
+ geometry.center
294
+ )
295
+ const corner = AltiumScene3dGeometricOwnerRecovery.#isModelCornerOrigin(
296
+ placement,
297
+ component,
298
+ geometry
299
+ )
300
+ const rowOrigin = AltiumScene3dGeometricOwnerRecovery.#isMultiRowOrigin(
301
+ placement,
302
+ geometry
303
+ )
304
+ const heightAgreement =
305
+ AltiumScene3dGeometricOwnerRecovery.#hasHeightAgreement(
306
+ body,
307
+ component
308
+ )
309
+ const nearFootprint =
310
+ AltiumScene3dGeometricOwnerRecovery.#isNearFootprint(
311
+ anchor,
312
+ geometry,
313
+ 50
314
+ )
315
+ let score = 0
316
+ let mode = ''
317
+
318
+ if (
319
+ centroidDistance <=
320
+ AltiumScene3dGeometricOwnerRecovery.#PAD_CENTROID_TOLERANCE_MIL
321
+ ) {
322
+ score = 24
323
+ mode = 'pad-centroid'
324
+ } else if (corner) {
325
+ score = 20
326
+ mode = 'model-corner'
327
+ } else if (rowOrigin) {
328
+ score = 18
329
+ mode = 'multi-row-origin'
330
+ } else if (heightAgreement && nearFootprint && distance <= 250) {
331
+ score = 14
332
+ mode = 'height-backed-origin'
333
+ }
334
+
335
+ if (!score) return null
336
+ if (heightAgreement) score += 2
337
+ score += Math.max(0, 3 - distance / 100)
338
+
339
+ return { component, mode, score, distance }
340
+ }
341
+
342
+ /**
343
+ * Builds bounds and topology for pads owned by one component.
344
+ * @param {object} component PCB component.
345
+ * @param {object[]} ownedPads Pads already indexed to this component.
346
+ * @returns {object | null}
347
+ */
348
+ static #componentPadGeometry(component, ownedPads) {
349
+ const componentIndex = Number(component?.componentIndex)
350
+ if (!Number.isFinite(componentIndex)) return null
351
+ if (!ownedPads.length) return null
352
+
353
+ const centerXs = ownedPads.map((pad) => Number(pad?.x || 0))
354
+ const centerYs = ownedPads.map((pad) => Number(pad?.y || 0))
355
+ const center = {
356
+ x:
357
+ centerXs.reduce((sum, value) => sum + value, 0) /
358
+ centerXs.length,
359
+ y: centerYs.reduce((sum, value) => sum + value, 0) / centerYs.length
360
+ }
361
+ const localPads = ownedPads.map((pad) => ({
362
+ source: pad,
363
+ ...AltiumScene3dGeometricOwnerRecovery.#toLocalPoint(
364
+ pad,
365
+ center,
366
+ component?.rotation
367
+ )
368
+ }))
369
+ const localXs = localPads.map((pad) => pad.x)
370
+ const localYs = localPads.map((pad) => pad.y)
371
+ const minX = Math.min(
372
+ ...ownedPads.map(
373
+ (pad) =>
374
+ Number(pad?.x || 0) -
375
+ AltiumScene3dGeometricOwnerRecovery.#padWidth(pad) / 2
376
+ )
377
+ )
378
+ const maxX = Math.max(
379
+ ...ownedPads.map(
380
+ (pad) =>
381
+ Number(pad?.x || 0) +
382
+ AltiumScene3dGeometricOwnerRecovery.#padWidth(pad) / 2
383
+ )
384
+ )
385
+ const minY = Math.min(
386
+ ...ownedPads.map(
387
+ (pad) =>
388
+ Number(pad?.y || 0) -
389
+ AltiumScene3dGeometricOwnerRecovery.#padDepth(pad) / 2
390
+ )
391
+ )
392
+ const maxY = Math.max(
393
+ ...ownedPads.map(
394
+ (pad) =>
395
+ Number(pad?.y || 0) +
396
+ AltiumScene3dGeometricOwnerRecovery.#padDepth(pad) / 2
397
+ )
398
+ )
399
+
400
+ return {
401
+ pads: ownedPads,
402
+ localPads,
403
+ center,
404
+ bounds: { minX, maxX, minY, maxY },
405
+ width: maxX - minX,
406
+ depth: maxY - minY,
407
+ xCount: AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
408
+ localXs
409
+ ),
410
+ yCount: AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
411
+ localYs
412
+ )
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Rotates one board point into the component-local footprint frame.
418
+ * @param {object} point Board point.
419
+ * @param {object} center Footprint center.
420
+ * @param {unknown} rotationDeg Component rotation.
421
+ * @returns {{ x: number, y: number }}
422
+ */
423
+ static #toLocalPoint(point, center, rotationDeg) {
424
+ const radians =
425
+ (-AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(rotationDeg) *
426
+ Math.PI) /
427
+ 180
428
+ const dx = Number(point?.x || 0) - Number(center?.x || 0)
429
+ const dy = Number(point?.y || 0) - Number(center?.y || 0)
430
+ return {
431
+ x: dx * Math.cos(radians) - dy * Math.sin(radians),
432
+ y: dx * Math.sin(radians) + dy * Math.cos(radians)
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Checks whether a body anchor is the opposite corner of measured model
438
+ * dimensions from the component origin.
439
+ * @param {object} placement External placement.
440
+ * @param {object} component Candidate component.
441
+ * @param {object} geometry Owned pad geometry.
442
+ * @returns {boolean}
443
+ */
444
+ static #isModelCornerOrigin(placement, component, geometry) {
445
+ const offsetDimensions = [
446
+ Math.abs(
447
+ Number(placement?.bodyPositionMil?.x || 0) -
448
+ Number(component?.x || 0)
449
+ ) * 2,
450
+ Math.abs(
451
+ Number(placement?.bodyPositionMil?.y || 0) -
452
+ Number(component?.y || 0)
453
+ ) * 2
454
+ ]
455
+ if (offsetDimensions.some((dimension) => dimension < 20)) return false
456
+
457
+ const modelDimensions =
458
+ AltiumScene3dGeometricOwnerRecovery.#modelDimensions(placement)
459
+ const offsetMatchesModel =
460
+ AltiumScene3dGeometricOwnerRecovery.#matchesDimensionPair(
461
+ offsetDimensions,
462
+ modelDimensions,
463
+ 0.12,
464
+ 25
465
+ )
466
+ const modelMatchesFootprint =
467
+ AltiumScene3dGeometricOwnerRecovery.#matchesDimensionPair(
468
+ [geometry.width, geometry.depth],
469
+ modelDimensions,
470
+ 0.3,
471
+ 45
472
+ )
473
+
474
+ return offsetMatchesModel && modelMatchesFootprint
475
+ }
476
+
477
+ /**
478
+ * Checks whether a multi-row footprint and model share the same long span.
479
+ * @param {object} placement External placement.
480
+ * @param {object} geometry Owned pad geometry.
481
+ * @returns {boolean}
482
+ */
483
+ static #isMultiRowOrigin(placement, geometry) {
484
+ const isGrid =
485
+ geometry.pads.length >= 6 &&
486
+ ((geometry.xCount >= 3 && geometry.yCount === 2) ||
487
+ (geometry.yCount >= 3 && geometry.xCount === 2))
488
+ if (!isGrid) return false
489
+ if (
490
+ !AltiumScene3dGeometricOwnerRecovery.#isNearFootprint(
491
+ placement?.bodyPositionMil,
492
+ geometry,
493
+ 10
494
+ )
495
+ ) {
496
+ return false
497
+ }
498
+
499
+ const modelDimensions =
500
+ AltiumScene3dGeometricOwnerRecovery.#modelDimensions(placement)
501
+ const modelLong = Math.max(...modelDimensions)
502
+ const footprintLong = Math.max(geometry.width, geometry.depth)
503
+
504
+ return AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
505
+ modelLong,
506
+ footprintLong,
507
+ 0.18,
508
+ 50
509
+ )
510
+ }
511
+
512
+ /**
513
+ * Checks whether source and component heights agree.
514
+ * @param {object} body Source body.
515
+ * @param {object} component Candidate component.
516
+ * @returns {boolean}
517
+ */
518
+ static #hasHeightAgreement(body, component) {
519
+ const sourceHeight = body?.overallHeightMil
520
+ const componentHeight = component?.height
521
+ if (
522
+ sourceHeight === null ||
523
+ sourceHeight === undefined ||
524
+ sourceHeight === '' ||
525
+ componentHeight === null ||
526
+ componentHeight === undefined ||
527
+ componentHeight === ''
528
+ ) {
529
+ return false
530
+ }
531
+ const normalizedSourceHeight = Number(sourceHeight)
532
+ const normalizedComponentHeight = Number(componentHeight)
533
+ if (
534
+ !Number.isFinite(normalizedSourceHeight) ||
535
+ normalizedSourceHeight <= 0 ||
536
+ !Number.isFinite(normalizedComponentHeight) ||
537
+ normalizedComponentHeight <= 0
538
+ ) {
539
+ return false
540
+ }
541
+ return AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
542
+ normalizedSourceHeight,
543
+ normalizedComponentHeight,
544
+ 0.08,
545
+ 3
546
+ )
547
+ }
548
+
549
+ /**
550
+ * Applies a recovered owner and preserves only proven authored offsets.
551
+ * @param {object} placement External placement.
552
+ * @param {object} body Source body.
553
+ * @param {{ component: object, mode: string }} match Owner match.
554
+ * @param {object} board Board metadata.
555
+ * @returns {object}
556
+ */
557
+ static #withOwner(placement, body, match, board) {
558
+ const component = match.component
559
+ const mountSide =
560
+ AltiumScene3dGeometricOwnerRecovery.#componentSide(component)
561
+ const preserveAnchor =
562
+ match.mode === 'pad-centroid' ||
563
+ match.mode === 'height-backed-origin'
564
+ const offset = {
565
+ x:
566
+ Number(placement?.bodyPositionMil?.x || 0) -
567
+ Number(component?.x || 0),
568
+ y:
569
+ Number(placement?.bodyPositionMil?.y || 0) -
570
+ Number(component?.y || 0)
571
+ }
572
+ const verticalOffset =
573
+ AltiumScene3dGeometricOwnerRecovery.#verticalOffset(
574
+ body,
575
+ mountSide,
576
+ placement?.modelTransform
577
+ )
578
+ const modelTransform = {
579
+ ...(placement?.modelTransform || {}),
580
+ dzMil: verticalOffset
581
+ }
582
+
583
+ if (match.mode === 'height-backed-origin') {
584
+ modelTransform.preserveSourceAnchor = true
585
+ }
586
+
587
+ if (!preserveAnchor) {
588
+ modelTransform.ownerAnchorOffsetMil = offset
589
+ modelTransform.offsetMil = { x: 0, y: 0, z: verticalOffset }
590
+ } else if (modelTransform.offsetMil) {
591
+ modelTransform.offsetMil = {
592
+ ...modelTransform.offsetMil,
593
+ z: verticalOffset
594
+ }
595
+ }
596
+
597
+ return {
598
+ ...placement,
599
+ designator: String(component?.designator || placement.designator),
600
+ mountSide,
601
+ positionMil: {
602
+ ...placement.positionMil,
603
+ ...(preserveAnchor
604
+ ? {}
605
+ : {
606
+ x:
607
+ Number(component?.x || 0) -
608
+ Number(board?.centerX || 0),
609
+ y:
610
+ Number(component?.y || 0) -
611
+ Number(board?.centerY || 0)
612
+ }),
613
+ z: AltiumScene3dGeometricOwnerRecovery.#faceZ(mountSide, board)
614
+ },
615
+ modelTransform
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Resolves a body vertical offset after a late owner recovery.
621
+ * @param {object} body Source body.
622
+ * @param {'top' | 'bottom'} mountSide Recovered side.
623
+ * @param {object} currentTransform Existing model transform.
624
+ * @returns {number}
625
+ */
626
+ static #verticalOffset(body, mountSide, currentTransform) {
627
+ const standoff = Number(body?.standoffHeightMil)
628
+ const overallHeight = Number(body?.overallHeightMil)
629
+ if (
630
+ mountSide === 'top' &&
631
+ Number.isFinite(standoff) &&
632
+ standoff < 0 &&
633
+ (!Number.isFinite(overallHeight) ||
634
+ overallHeight <= 0 ||
635
+ Math.abs(standoff) < overallHeight)
636
+ ) {
637
+ return standoff
638
+ }
639
+ if (Number.isFinite(standoff) && Math.abs(standoff) < 1e-6) return 0
640
+
641
+ const current = Number(
642
+ currentTransform?.offsetMil?.z ?? currentTransform?.dzMil ?? 0
643
+ )
644
+ return Number.isFinite(current) ? current : 0
645
+ }
646
+
647
+ /**
648
+ * Returns measured model dimensions.
649
+ * @param {object} placement External placement.
650
+ * @returns {number[]}
651
+ */
652
+ static #modelDimensions(placement) {
653
+ return [
654
+ Number(placement?.projection?.boundsMil?.width),
655
+ Number(placement?.projection?.boundsMil?.depth),
656
+ Number(placement?.projection?.boundsMil?.height)
657
+ ].filter((dimension) => Number.isFinite(dimension) && dimension > 0)
658
+ }
659
+
660
+ /**
661
+ * Checks whether two source dimensions match any distinct model axes.
662
+ * @param {number[]} sourceDimensions Two source dimensions.
663
+ * @param {number[]} modelDimensions Candidate model dimensions.
664
+ * @param {number} ratio Relative tolerance.
665
+ * @param {number} minimum Absolute tolerance.
666
+ * @returns {boolean}
667
+ */
668
+ static #matchesDimensionPair(
669
+ sourceDimensions,
670
+ modelDimensions,
671
+ ratio,
672
+ minimum
673
+ ) {
674
+ if (sourceDimensions.length < 2 || modelDimensions.length < 2) {
675
+ return false
676
+ }
677
+
678
+ return modelDimensions.some((first, firstIndex) =>
679
+ modelDimensions.some(
680
+ (second, secondIndex) =>
681
+ firstIndex !== secondIndex &&
682
+ ((AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
683
+ sourceDimensions[0],
684
+ first,
685
+ ratio,
686
+ minimum
687
+ ) &&
688
+ AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
689
+ sourceDimensions[1],
690
+ second,
691
+ ratio,
692
+ minimum
693
+ )) ||
694
+ (AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
695
+ sourceDimensions[0],
696
+ second,
697
+ ratio,
698
+ minimum
699
+ ) &&
700
+ AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
701
+ sourceDimensions[1],
702
+ first,
703
+ ratio,
704
+ minimum
705
+ )))
706
+ )
707
+ )
708
+ }
709
+
710
+ /**
711
+ * Checks one dimension with combined absolute and relative tolerance.
712
+ * @param {number} actual Actual dimension.
713
+ * @param {number} expected Expected dimension.
714
+ * @param {number} ratio Relative tolerance.
715
+ * @param {number} minimum Absolute tolerance.
716
+ * @returns {boolean}
717
+ */
718
+ static #matchesDimension(actual, expected, ratio, minimum) {
719
+ if (!Number.isFinite(actual) || !Number.isFinite(expected)) {
720
+ return false
721
+ }
722
+ const tolerance = Math.max(minimum, Math.abs(expected) * ratio)
723
+ return Math.abs(actual - expected) <= tolerance
724
+ }
725
+
726
+ /**
727
+ * Checks whether a point lies within expanded footprint bounds.
728
+ * @param {object} point Board point.
729
+ * @param {object} geometry Pad geometry.
730
+ * @param {number} expansion Bounds expansion.
731
+ * @returns {boolean}
732
+ */
733
+ static #isNearFootprint(point, geometry, expansion) {
734
+ return (
735
+ Number(point?.x) >= geometry.bounds.minX - expansion &&
736
+ Number(point?.x) <= geometry.bounds.maxX + expansion &&
737
+ Number(point?.y) >= geometry.bounds.minY - expansion &&
738
+ Number(point?.y) <= geometry.bounds.maxY + expansion
739
+ )
740
+ }
741
+
742
+ /**
743
+ * Counts coordinates after grouping native numeric noise.
744
+ * @param {number[]} values Coordinates.
745
+ * @returns {number}
746
+ */
747
+ static #distinctCoordinateCount(values) {
748
+ return new Set(values.map((value) => Math.round(value * 10))).size
749
+ }
750
+
751
+ /**
752
+ * Resolves effective pad width.
753
+ * @param {object} pad PCB pad.
754
+ * @returns {number}
755
+ */
756
+ static #padWidth(pad) {
757
+ return Math.max(
758
+ Number(pad?.sizeTopX || 0),
759
+ Number(pad?.sizeMidX || 0),
760
+ Number(pad?.sizeBottomX || 0)
761
+ )
762
+ }
763
+
764
+ /**
765
+ * Resolves effective pad depth.
766
+ * @param {object} pad PCB pad.
767
+ * @returns {number}
768
+ */
769
+ static #padDepth(pad) {
770
+ return Math.max(
771
+ Number(pad?.sizeTopY || 0),
772
+ Number(pad?.sizeMidY || 0),
773
+ Number(pad?.sizeBottomY || 0)
774
+ )
775
+ }
776
+
777
+ /**
778
+ * Resolves component side.
779
+ * @param {object} component PCB component.
780
+ * @returns {'top' | 'bottom'}
781
+ */
782
+ static #componentSide(component) {
783
+ return /bottom|bot/i.test(String(component?.layer || ''))
784
+ ? 'bottom'
785
+ : 'top'
786
+ }
787
+
788
+ /**
789
+ * Resolves board face Z.
790
+ * @param {'top' | 'bottom'} mountSide Placement side.
791
+ * @param {object} board Board metadata.
792
+ * @returns {number}
793
+ */
794
+ static #faceZ(mountSide, board) {
795
+ const halfThickness = Number(board?.thicknessMil || 63) / 2
796
+ return mountSide === 'bottom' ? -halfThickness : halfThickness
797
+ }
798
+
799
+ /**
800
+ * Measures planar distance.
801
+ * @param {object} first First point.
802
+ * @param {object} second Second point.
803
+ * @returns {number}
804
+ */
805
+ static #distance(first, second) {
806
+ return Math.hypot(
807
+ Number(first?.x || 0) - Number(second?.x || 0),
808
+ Number(first?.y || 0) - Number(second?.y || 0)
809
+ )
810
+ }
811
+
812
+ /**
813
+ * Normalizes identity text.
814
+ * @param {unknown[]} values Identity values.
815
+ * @returns {string}
816
+ */
817
+ static #normalizeIdentity(values) {
818
+ return values
819
+ .map((value) =>
820
+ String(value || '')
821
+ .replace(/\.[^.]+$/, '')
822
+ .toLowerCase()
823
+ .replace(/[^a-z0-9]+/g, '')
824
+ )
825
+ .filter(Boolean)
826
+ .join(' ')
827
+ }
828
+
829
+ /**
830
+ * Normalizes one angle.
831
+ * @param {unknown} angle Angle value.
832
+ * @returns {number}
833
+ */
834
+ static #normalizeAngle(angle) {
835
+ const normalized = Number(angle || 0) % 360
836
+ return normalized < 0 ? normalized + 360 : normalized
837
+ }
838
+ }
839
+
840
+ Object.freeze(AltiumScene3dGeometricOwnerRecovery.prototype)
841
+ Object.freeze(AltiumScene3dGeometricOwnerRecovery)
@@ -0,0 +1,94 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ /**
5
+ * Builds compact reusable indexes for late Altium 3D owner recovery.
6
+ */
7
+ export class AltiumScene3dRecoverySpatialIndex {
8
+ /**
9
+ * Groups source pads by numeric component owner.
10
+ * @param {object[]} pads Source PCB pads.
11
+ * @returns {Map<number, object[]>}
12
+ */
13
+ static padsByComponent(pads) {
14
+ const index = new Map()
15
+ for (const pad of pads) {
16
+ const componentIndex = Number(pad?.componentIndex)
17
+ if (!Number.isFinite(componentIndex)) continue
18
+ index.set(componentIndex, [
19
+ ...(index.get(componentIndex) || []),
20
+ pad
21
+ ])
22
+ }
23
+ return index
24
+ }
25
+
26
+ /**
27
+ * Indexes arbitrary values by a point selected from each value.
28
+ * @param {object[]} values Values to index.
29
+ * @param {number} cellSize Bucket size.
30
+ * @param {(value: object) => object} pointSelector Point selector.
31
+ * @returns {Map<string, object[]>}
32
+ */
33
+ static create(values, cellSize, pointSelector) {
34
+ const index = new Map()
35
+ for (const value of values) {
36
+ const point = pointSelector(value)
37
+ const x = Number(point?.x)
38
+ const y = Number(point?.y)
39
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue
40
+ const key = AltiumScene3dRecoverySpatialIndex.#key(
41
+ Math.floor(x / cellSize),
42
+ Math.floor(y / cellSize)
43
+ )
44
+ index.set(key, [...(index.get(key) || []), value])
45
+ }
46
+ return index
47
+ }
48
+
49
+ /**
50
+ * Reads values from square neighboring spatial buckets.
51
+ * @param {Map<string, object[]>} index Spatial index.
52
+ * @param {object} point Query point.
53
+ * @param {number} cellSize Bucket size.
54
+ * @param {number} radiusCells Query radius in buckets.
55
+ * @returns {object[]}
56
+ */
57
+ static nearby(index, point, cellSize, radiusCells) {
58
+ const centerX = Math.floor(Number(point?.x) / cellSize)
59
+ const centerY = Math.floor(Number(point?.y) / cellSize)
60
+ if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) return []
61
+ const values = []
62
+ for (
63
+ let x = centerX - radiusCells;
64
+ x <= centerX + radiusCells;
65
+ x += 1
66
+ ) {
67
+ for (
68
+ let y = centerY - radiusCells;
69
+ y <= centerY + radiusCells;
70
+ y += 1
71
+ ) {
72
+ values.push(
73
+ ...(index.get(
74
+ AltiumScene3dRecoverySpatialIndex.#key(x, y)
75
+ ) || [])
76
+ )
77
+ }
78
+ }
79
+ return values
80
+ }
81
+
82
+ /**
83
+ * Builds a stable spatial bucket key.
84
+ * @param {number} x X bucket.
85
+ * @param {number} y Y bucket.
86
+ * @returns {string}
87
+ */
88
+ static #key(x, y) {
89
+ return `${x}:${y}`
90
+ }
91
+ }
92
+
93
+ Object.freeze(AltiumScene3dRecoverySpatialIndex.prototype)
94
+ Object.freeze(AltiumScene3dRecoverySpatialIndex)