altium-toolkit 1.1.35 → 1.1.37

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 (35) hide show
  1. package/AGENTS.md +17 -2
  2. package/package.json +1 -1
  3. package/src/core/altium/PrintableTextDecoder.mjs +20 -1
  4. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +288 -7
  5. package/src/core/altium/SchematicTextParser.mjs +18 -253
  6. package/src/core/altium/SchematicTitleBlockParser.mjs +410 -0
  7. package/src/core/altium/SelectedPartAltiumExportAdapter.mjs +215 -0
  8. package/src/parser.mjs +1 -0
  9. package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +245 -12
  10. package/src/ui/AltiumScene3dAuthoredConnectorYawPolicy.mjs +176 -0
  11. package/src/ui/AltiumScene3dBottomSourceHalfTurnPolicy.mjs +93 -0
  12. package/src/ui/AltiumScene3dDisplayModuleYawPolicy.mjs +284 -0
  13. package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +521 -23
  14. package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +766 -19
  15. package/src/ui/AltiumScene3dQfnFootprintDetector.mjs +307 -0
  16. package/src/ui/AltiumScene3dRepeatedFullFootprintBodyCollapse.mjs +518 -0
  17. package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +121 -17
  18. package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +286 -135
  19. package/src/ui/AltiumScene3dShapeStackOwnerConflictPolicy.mjs +299 -0
  20. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +22 -0
  21. package/src/ui/PcbScene3dBuilder.mjs +613 -117
  22. package/src/ui/PcbScene3dModelRegistry.mjs +222 -10
  23. package/src/ui/PcbScene3dPackages.mjs +16 -1
  24. package/src/ui/PcbScene3dPlacementSideResolver.mjs +262 -22
  25. package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +984 -0
  26. package/src/ui/PcbScene3dStaticBodyPadOwnerPromotion.mjs +378 -0
  27. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +169 -23
  28. package/src/ui/PcbScene3dStaticBodyPrototypeRecovery.mjs +445 -0
  29. package/src/ui/PcbScene3dStaticBodyRecovery.mjs +7 -1
  30. package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +199 -2
  31. package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +439 -0
  32. package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +23 -2
  33. package/src/ui/SchematicColorResolver.mjs +44 -0
  34. package/src/ui/SchematicLineColorResolver.mjs +1 -1
  35. package/src/ui/SchematicShapeRenderer.mjs +13 -13
@@ -0,0 +1,445 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Recovers missing anonymous static-body polygons from complete same-model rows.
7
+ */
8
+ export class PcbScene3dStaticBodyPrototypeRecovery {
9
+ /**
10
+ * Recovers repeated anonymous body rows that omit their vertex payload.
11
+ * @param {object[] | undefined} componentBodies Component bodies.
12
+ * @returns {object[]}
13
+ */
14
+ static recover(componentBodies) {
15
+ const bodies = Array.isArray(componentBodies) ? componentBodies : []
16
+ const prototypes =
17
+ PcbScene3dStaticBodyPrototypeRecovery.#prototypeMap(bodies)
18
+
19
+ if (!prototypes.size) {
20
+ return bodies
21
+ }
22
+
23
+ return bodies.map((componentBody) =>
24
+ PcbScene3dStaticBodyPrototypeRecovery.#recoverBody(
25
+ componentBody,
26
+ prototypes
27
+ )
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Builds reusable complete-geometry prototypes by exact model identity.
33
+ * @param {object[]} componentBodies Component bodies.
34
+ * @returns {Map<string, object[]>}
35
+ */
36
+ static #prototypeMap(componentBodies) {
37
+ const prototypes = new Map()
38
+
39
+ componentBodies
40
+ .filter((componentBody) =>
41
+ PcbScene3dStaticBodyPrototypeRecovery.#isPrototype(
42
+ componentBody
43
+ )
44
+ )
45
+ .forEach((componentBody) => {
46
+ const key =
47
+ PcbScene3dStaticBodyPrototypeRecovery.#bodyFamilyKey(
48
+ componentBody
49
+ )
50
+ if (!key) {
51
+ return
52
+ }
53
+
54
+ if (!prototypes.has(key)) {
55
+ prototypes.set(key, [])
56
+ }
57
+ prototypes.get(key)?.push(componentBody)
58
+ })
59
+
60
+ return prototypes
61
+ }
62
+
63
+ /**
64
+ * Recovers one missing body from the closest compatible prototype.
65
+ * @param {object} componentBody Target body.
66
+ * @param {Map<string, object[]>} prototypes Complete prototype rows.
67
+ * @returns {object}
68
+ */
69
+ static #recoverBody(componentBody, prototypes) {
70
+ if (
71
+ !PcbScene3dStaticBodyPrototypeRecovery.#isRecoverableTarget(
72
+ componentBody
73
+ )
74
+ ) {
75
+ return componentBody
76
+ }
77
+
78
+ const key =
79
+ PcbScene3dStaticBodyPrototypeRecovery.#bodyFamilyKey(componentBody)
80
+ const prototype =
81
+ PcbScene3dStaticBodyPrototypeRecovery.#nearestPrototype(
82
+ componentBody,
83
+ prototypes.get(key)
84
+ )
85
+
86
+ return prototype
87
+ ? PcbScene3dStaticBodyPrototypeRecovery.#withPrototypeGeometry(
88
+ componentBody,
89
+ prototype
90
+ )
91
+ : componentBody
92
+ }
93
+
94
+ /**
95
+ * Checks whether a row can provide reusable static geometry.
96
+ * @param {object | undefined} componentBody Candidate body.
97
+ * @returns {boolean}
98
+ */
99
+ static #isPrototype(componentBody) {
100
+ return (
101
+ PcbScene3dStaticBodyPrototypeRecovery.#isAnonymous(componentBody) &&
102
+ PcbScene3dStaticBodyPrototypeRecovery.#isCompleteExtrudedPolygon(
103
+ componentBody
104
+ ) &&
105
+ Boolean(
106
+ PcbScene3dStaticBodyPrototypeRecovery.#bodyFamilyKey(
107
+ componentBody
108
+ )
109
+ )
110
+ )
111
+ }
112
+
113
+ /**
114
+ * Checks whether a row is missing renderable static polygon vertices.
115
+ * @param {object | undefined} componentBody Candidate body.
116
+ * @returns {boolean}
117
+ */
118
+ static #isRecoverableTarget(componentBody) {
119
+ return (
120
+ PcbScene3dStaticBodyPrototypeRecovery.#isAnonymous(componentBody) &&
121
+ PcbScene3dStaticBodyPrototypeRecovery.#isExtrudedPolygonBody(
122
+ componentBody
123
+ ) &&
124
+ !PcbScene3dStaticBodyPrototypeRecovery.#isCompleteExtrudedPolygon(
125
+ componentBody
126
+ ) &&
127
+ PcbScene3dStaticBodyPrototypeRecovery.#hasRecoverablePosition(
128
+ componentBody
129
+ ) &&
130
+ PcbScene3dStaticBodyPrototypeRecovery.#heightMil(componentBody) > 0
131
+ )
132
+ }
133
+
134
+ /**
135
+ * Checks whether the body row has no authored identity text.
136
+ * @param {object | undefined} componentBody Candidate body.
137
+ * @returns {boolean}
138
+ */
139
+ static #isAnonymous(componentBody) {
140
+ return ![componentBody?.identifier, componentBody?.name]
141
+ .map((value) => String(value || '').trim())
142
+ .some(Boolean)
143
+ }
144
+
145
+ /**
146
+ * Checks whether a body row describes an extruded polygon model.
147
+ * @param {object | undefined} componentBody Candidate body.
148
+ * @returns {boolean}
149
+ */
150
+ static #isExtrudedPolygonBody(componentBody) {
151
+ return (
152
+ PcbScene3dStaticBodyPrototypeRecovery.#geometryKind(
153
+ componentBody
154
+ ) === 'extruded-polygon'
155
+ )
156
+ }
157
+
158
+ /**
159
+ * Checks whether a row already has complete extruded polygon vertices.
160
+ * @param {object | undefined} componentBody Candidate body.
161
+ * @returns {boolean}
162
+ */
163
+ static #isCompleteExtrudedPolygon(componentBody) {
164
+ const geometry = componentBody?.staticGeometry
165
+
166
+ return (
167
+ PcbScene3dStaticBodyPrototypeRecovery.#isExtrudedPolygonBody(
168
+ componentBody
169
+ ) &&
170
+ String(geometry?.status || '').toLowerCase() === 'complete' &&
171
+ Array.isArray(geometry?.verticesMil) &&
172
+ geometry.verticesMil.length >= 3
173
+ )
174
+ }
175
+
176
+ /**
177
+ * Builds the exact model-family key used for prototype reuse.
178
+ * @param {object | undefined} componentBody Candidate body.
179
+ * @returns {string}
180
+ */
181
+ static #bodyFamilyKey(componentBody) {
182
+ const identity = [
183
+ componentBody?.modelId,
184
+ componentBody?.checksum,
185
+ PcbScene3dStaticBodyPrototypeRecovery.#geometryKind(componentBody),
186
+ PcbScene3dStaticBodyPrototypeRecovery.#numberKey(
187
+ PcbScene3dStaticBodyPrototypeRecovery.#heightMil(componentBody)
188
+ ),
189
+ PcbScene3dStaticBodyPrototypeRecovery.#numberKey(
190
+ PcbScene3dStaticBodyPrototypeRecovery.#standoffMil(
191
+ componentBody
192
+ )
193
+ )
194
+ ]
195
+ .map((value) =>
196
+ String(value ?? '')
197
+ .trim()
198
+ .toLowerCase()
199
+ )
200
+ .filter(Boolean)
201
+
202
+ return identity.length >= 4 ? identity.join('|') : ''
203
+ }
204
+
205
+ /**
206
+ * Chooses the closest layer-compatible prototype for one target row.
207
+ * @param {object} componentBody Target body.
208
+ * @param {object[] | undefined} candidates Candidate prototypes.
209
+ * @returns {object | null}
210
+ */
211
+ static #nearestPrototype(componentBody, candidates) {
212
+ const normalizedCandidates = Array.isArray(candidates) ? candidates : []
213
+
214
+ return (
215
+ normalizedCandidates
216
+ .filter((candidate) =>
217
+ PcbScene3dStaticBodyPrototypeRecovery.#compatibleLayers(
218
+ componentBody,
219
+ candidate
220
+ )
221
+ )
222
+ .map((candidate) => ({
223
+ candidate,
224
+ distance:
225
+ PcbScene3dStaticBodyPrototypeRecovery.#distanceBetweenBodies(
226
+ componentBody,
227
+ candidate
228
+ )
229
+ }))
230
+ .sort((left, right) => left.distance - right.distance)[0]
231
+ ?.candidate || null
232
+ )
233
+ }
234
+
235
+ /**
236
+ * Returns a cloned target row with translated prototype vertices.
237
+ * @param {object} componentBody Target body.
238
+ * @param {object} prototype Complete prototype body.
239
+ * @returns {object}
240
+ */
241
+ static #withPrototypeGeometry(componentBody, prototype) {
242
+ const geometry = componentBody?.staticGeometry || {}
243
+ const prototypeGeometry = prototype?.staticGeometry || {}
244
+ const heightMil =
245
+ PcbScene3dStaticBodyPrototypeRecovery.#heightMil(componentBody) ||
246
+ PcbScene3dStaticBodyPrototypeRecovery.#heightMil(prototype)
247
+ const standoffHeightMil =
248
+ PcbScene3dStaticBodyPrototypeRecovery.#standoffMil(componentBody) ||
249
+ PcbScene3dStaticBodyPrototypeRecovery.#standoffMil(prototype)
250
+
251
+ return {
252
+ ...componentBody,
253
+ staticGeometry: {
254
+ ...prototypeGeometry,
255
+ ...geometry,
256
+ kind: 'extruded-polygon',
257
+ status: 'complete',
258
+ units: geometry.units || prototypeGeometry.units || 'mil',
259
+ minZMil: Number(
260
+ geometry.minZMil ?? prototypeGeometry.minZMil ?? 0
261
+ ),
262
+ maxZMil: Number(
263
+ geometry.maxZMil ??
264
+ prototypeGeometry.maxZMil ??
265
+ heightMil + standoffHeightMil
266
+ ),
267
+ heightMil,
268
+ standoffHeightMil,
269
+ verticesMil:
270
+ PcbScene3dStaticBodyPrototypeRecovery.#prototypeVerticesForTarget(
271
+ componentBody,
272
+ prototype
273
+ )
274
+ }
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Translates prototype vertices into the target body's coordinate frame.
280
+ * @param {object} componentBody Target body.
281
+ * @param {object} prototype Complete prototype body.
282
+ * @returns {{ x: number, y: number }[]}
283
+ */
284
+ static #prototypeVerticesForTarget(componentBody, prototype) {
285
+ const vertices = Array.isArray(prototype?.staticGeometry?.verticesMil)
286
+ ? prototype.staticGeometry.verticesMil
287
+ : []
288
+
289
+ if (
290
+ !PcbScene3dStaticBodyPrototypeRecovery.#usesSourceCoordinateFrame(
291
+ prototype,
292
+ vertices
293
+ )
294
+ ) {
295
+ return vertices.map((vertex) => ({
296
+ x: Number(vertex?.x || 0),
297
+ y: Number(vertex?.y || 0)
298
+ }))
299
+ }
300
+
301
+ const prototypeSource =
302
+ PcbScene3dStaticBodyPrototypeRecovery.#sourcePosition(prototype)
303
+ const targetSource =
304
+ PcbScene3dStaticBodyPrototypeRecovery.#sourcePosition(componentBody)
305
+
306
+ return vertices.map((vertex) => ({
307
+ x: targetSource.x + Number(vertex?.x || 0) - prototypeSource.x,
308
+ y: targetSource.y + Number(vertex?.y || 0) - prototypeSource.y
309
+ }))
310
+ }
311
+
312
+ /**
313
+ * Checks whether explicit mechanical layer values are compatible.
314
+ * @param {object | undefined} left Left body.
315
+ * @param {object | undefined} right Right body.
316
+ * @returns {boolean}
317
+ */
318
+ static #compatibleLayers(left, right) {
319
+ const leftLayer = String(left?.layer || '')
320
+ .trim()
321
+ .toLowerCase()
322
+ const rightLayer = String(right?.layer || '')
323
+ .trim()
324
+ .toLowerCase()
325
+
326
+ return !leftLayer || !rightLayer || leftLayer === rightLayer
327
+ }
328
+
329
+ /**
330
+ * Resolves the lower-case geometry kind for a body row.
331
+ * @param {object | undefined} componentBody Candidate body.
332
+ * @returns {string}
333
+ */
334
+ static #geometryKind(componentBody) {
335
+ return String(
336
+ componentBody?.staticGeometry?.kind ||
337
+ componentBody?.modelTypeName ||
338
+ ''
339
+ )
340
+ .trim()
341
+ .toLowerCase()
342
+ }
343
+
344
+ /**
345
+ * Resolves the body extrusion height.
346
+ * @param {object | undefined} componentBody Candidate body.
347
+ * @returns {number}
348
+ */
349
+ static #heightMil(componentBody) {
350
+ const height = Number(
351
+ componentBody?.staticGeometry?.heightMil ??
352
+ componentBody?.overallHeightMil
353
+ )
354
+
355
+ return Number.isFinite(height) ? height : 0
356
+ }
357
+
358
+ /**
359
+ * Resolves the body standoff height.
360
+ * @param {object | undefined} componentBody Candidate body.
361
+ * @returns {number}
362
+ */
363
+ static #standoffMil(componentBody) {
364
+ const standoff = Number(
365
+ componentBody?.staticGeometry?.standoffHeightMil ??
366
+ componentBody?.standoffHeightMil
367
+ )
368
+
369
+ return Number.isFinite(standoff) ? standoff : 0
370
+ }
371
+
372
+ /**
373
+ * Checks whether the body row has a finite source anchor.
374
+ * @param {object | undefined} componentBody Candidate body.
375
+ * @returns {boolean}
376
+ */
377
+ static #hasRecoverablePosition(componentBody) {
378
+ const source =
379
+ PcbScene3dStaticBodyPrototypeRecovery.#sourcePosition(componentBody)
380
+
381
+ return Number.isFinite(source.x) && Number.isFinite(source.y)
382
+ }
383
+
384
+ /**
385
+ * Resolves a body source position.
386
+ * @param {object | undefined} componentBody Candidate body.
387
+ * @returns {{ x: number, y: number }}
388
+ */
389
+ static #sourcePosition(componentBody) {
390
+ return {
391
+ x: Number(componentBody?.positionMil?.x),
392
+ y: Number(componentBody?.positionMil?.y)
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Measures the source-anchor distance between two body rows.
398
+ * @param {object} left Left body.
399
+ * @param {object} right Right body.
400
+ * @returns {number}
401
+ */
402
+ static #distanceBetweenBodies(left, right) {
403
+ const leftSource =
404
+ PcbScene3dStaticBodyPrototypeRecovery.#sourcePosition(left)
405
+ const rightSource =
406
+ PcbScene3dStaticBodyPrototypeRecovery.#sourcePosition(right)
407
+
408
+ return Math.hypot(
409
+ leftSource.x - rightSource.x,
410
+ leftSource.y - rightSource.y
411
+ )
412
+ }
413
+
414
+ /**
415
+ * Checks whether polygon vertices use board/source coordinates.
416
+ * @param {object} componentBody Component body.
417
+ * @param {{ x?: number, y?: number }[]} vertices Vertices.
418
+ * @returns {boolean}
419
+ */
420
+ static #usesSourceCoordinateFrame(componentBody, vertices) {
421
+ const source =
422
+ PcbScene3dStaticBodyPrototypeRecovery.#sourcePosition(componentBody)
423
+ const coordinates = (Array.isArray(vertices) ? vertices : []).flatMap(
424
+ (vertex) => [Number(vertex?.x || 0), Number(vertex?.y || 0)]
425
+ )
426
+
427
+ return (
428
+ Math.max(Math.abs(source.x), Math.abs(source.y), ...coordinates) >
429
+ 1000
430
+ )
431
+ }
432
+
433
+ /**
434
+ * Converts a number into a stable key fragment.
435
+ * @param {number | string | undefined | null} value Candidate value.
436
+ * @returns {string}
437
+ */
438
+ static #numberKey(value) {
439
+ const number = Number(value)
440
+
441
+ return Number.isFinite(number)
442
+ ? String(Math.round(number * 10000) / 10000)
443
+ : ''
444
+ }
445
+ }
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { PcbScene3dStaticBodySymmetryRecovery } from './PcbScene3dStaticBodySymmetryRecovery.mjs'
6
+ import { PcbScene3dStaticBodyPrototypeRecovery } from './PcbScene3dStaticBodyPrototypeRecovery.mjs'
6
7
 
7
8
  /**
8
9
  * Recovers renderable static-body geometry and display metadata.
@@ -38,8 +39,13 @@ export class PcbScene3dStaticBodyRecovery {
38
39
  */
39
40
  static recover(componentBodies, bodyMatches = []) {
40
41
  const bodies = Array.isArray(componentBodies) ? componentBodies : []
42
+ const prototypeRecoveredBodies =
43
+ PcbScene3dStaticBodyPrototypeRecovery.recover(bodies)
41
44
  const symmetricRecoveredBodies =
42
- PcbScene3dStaticBodySymmetryRecovery.recover(bodies, bodyMatches)
45
+ PcbScene3dStaticBodySymmetryRecovery.recover(
46
+ prototypeRecoveredBodies,
47
+ bodyMatches
48
+ )
43
49
  const topRecoveredBodies = symmetricRecoveredBodies.map(
44
50
  (componentBody) =>
45
51
  PcbScene3dStaticBodyRecovery.#recoverCoverTopGeometry(