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.
- package/AGENTS.md +17 -2
- package/package.json +1 -1
- package/src/core/altium/PrintableTextDecoder.mjs +20 -1
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +288 -7
- package/src/core/altium/SchematicTextParser.mjs +18 -253
- package/src/core/altium/SchematicTitleBlockParser.mjs +410 -0
- package/src/core/altium/SelectedPartAltiumExportAdapter.mjs +215 -0
- package/src/parser.mjs +1 -0
- package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +245 -12
- package/src/ui/AltiumScene3dAuthoredConnectorYawPolicy.mjs +176 -0
- package/src/ui/AltiumScene3dBottomSourceHalfTurnPolicy.mjs +93 -0
- package/src/ui/AltiumScene3dDisplayModuleYawPolicy.mjs +284 -0
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +521 -23
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +766 -19
- package/src/ui/AltiumScene3dQfnFootprintDetector.mjs +307 -0
- package/src/ui/AltiumScene3dRepeatedFullFootprintBodyCollapse.mjs +518 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +121 -17
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +286 -135
- package/src/ui/AltiumScene3dShapeStackOwnerConflictPolicy.mjs +299 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +22 -0
- package/src/ui/PcbScene3dBuilder.mjs +613 -117
- package/src/ui/PcbScene3dModelRegistry.mjs +222 -10
- package/src/ui/PcbScene3dPackages.mjs +16 -1
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +262 -22
- package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +984 -0
- package/src/ui/PcbScene3dStaticBodyPadOwnerPromotion.mjs +378 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +169 -23
- package/src/ui/PcbScene3dStaticBodyPrototypeRecovery.mjs +445 -0
- package/src/ui/PcbScene3dStaticBodyRecovery.mjs +7 -1
- package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +199 -2
- package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +439 -0
- package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +23 -2
- package/src/ui/SchematicColorResolver.mjs +44 -0
- package/src/ui/SchematicLineColorResolver.mjs +1 -1
- package/src/ui/SchematicShapeRenderer.mjs +13 -13
package/AGENTS.md
CHANGED
|
@@ -33,13 +33,28 @@
|
|
|
33
33
|
## Library Scope
|
|
34
34
|
|
|
35
35
|
- Include parser, OLE/binary helpers, schematic SVG rendering, PCB SVG
|
|
36
|
-
rendering, BOM HTML rendering,
|
|
37
|
-
summary rendering.
|
|
36
|
+
rendering, BOM HTML rendering, selected-part library export adapters, 3D
|
|
37
|
+
scene-description builders, and static 3D summary rendering.
|
|
38
38
|
- Do not add host app interaction to this library: no pan/zoom controllers, DOM
|
|
39
39
|
event orchestration, OrbitControls runtime, picking, download UI, or app state.
|
|
40
40
|
- Keep parser and renderer fixes universal. Never special-case a specific file
|
|
41
41
|
name, project identifier, fixture helper, or source-derived phrase.
|
|
42
42
|
|
|
43
|
+
## Fix Quality Rules
|
|
44
|
+
|
|
45
|
+
- Fixes must always address the general behavior, not a specific example,
|
|
46
|
+
fixture, file, project, or test case.
|
|
47
|
+
- Do not implement workarounds, cheats, allowlists, hard-coded example handling,
|
|
48
|
+
or special-case logic to make one sample pass.
|
|
49
|
+
- When you encounter existing workaround code, cheating behavior, or
|
|
50
|
+
example-specific handling, rewrite it into general-purpose behavior
|
|
51
|
+
immediately when it is in scope for the change.
|
|
52
|
+
- Keep fixes universal and structural: derive behavior from the underlying data
|
|
53
|
+
model, format, protocol, or UI contract instead of matching known sample text,
|
|
54
|
+
filenames, labels, or project identifiers.
|
|
55
|
+
- After fixing code, run the appropriate repo-owned tests and do not modify tests
|
|
56
|
+
just to make a workaround pass.
|
|
57
|
+
|
|
43
58
|
## Testing Guidelines
|
|
44
59
|
|
|
45
60
|
- Use repo scripts only: `npm test`.
|
package/package.json
CHANGED
|
@@ -215,7 +215,8 @@ export class PrintableTextDecoder {
|
|
|
215
215
|
* @returns {boolean}
|
|
216
216
|
*/
|
|
217
217
|
static #hasWindows1252PreferredBytes(bytes) {
|
|
218
|
-
for (
|
|
218
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
219
|
+
const byte = bytes[index]
|
|
219
220
|
if (
|
|
220
221
|
PrintableTextDecoder.#WINDOWS_1252_PRINTABLE_CONTROL_BYTES.has(
|
|
221
222
|
byte
|
|
@@ -223,11 +224,29 @@ export class PrintableTextDecoder {
|
|
|
223
224
|
) {
|
|
224
225
|
return true
|
|
225
226
|
}
|
|
227
|
+
|
|
228
|
+
if (
|
|
229
|
+
byte >= 0xc0 &&
|
|
230
|
+
byte <= 0xff &&
|
|
231
|
+
(PrintableTextDecoder.#isAsciiLetter(bytes[index - 1]) ||
|
|
232
|
+
PrintableTextDecoder.#isAsciiLetter(bytes[index + 1]))
|
|
233
|
+
) {
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
226
236
|
}
|
|
227
237
|
|
|
228
238
|
return false
|
|
229
239
|
}
|
|
230
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Returns true when one byte is an ASCII letter.
|
|
243
|
+
* @param {number | undefined} byte Byte value.
|
|
244
|
+
* @returns {boolean}
|
|
245
|
+
*/
|
|
246
|
+
static #isAsciiLetter(byte) {
|
|
247
|
+
return (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a)
|
|
248
|
+
}
|
|
249
|
+
|
|
231
250
|
/**
|
|
232
251
|
* Trims ASCII whitespace from one byte range.
|
|
233
252
|
* @param {Uint8Array} bytes
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { ParserUtils } from './ParserUtils.mjs'
|
|
6
6
|
|
|
7
7
|
const { getField, parseBoolean, parseNumericField } = ParserUtils
|
|
8
|
+
const WIRED_PART_MIN_SCORE = 2
|
|
9
|
+
const WIRED_PART_DOMINANCE_RATIO = 2
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Resolves which multipart symbol section is visible for one schematic owner.
|
|
@@ -21,6 +23,10 @@ export class SchematicMultipartOwnerMatcher {
|
|
|
21
23
|
const partBounds = new Map()
|
|
22
24
|
const ownerBounds = new Map()
|
|
23
25
|
const directOwnerIndexesByRecord = new WeakMap()
|
|
26
|
+
const ownerlessConnectionPoints =
|
|
27
|
+
SchematicMultipartOwnerMatcher.#collectOwnerlessConnectionPoints(
|
|
28
|
+
records
|
|
29
|
+
)
|
|
24
30
|
|
|
25
31
|
for (const record of records) {
|
|
26
32
|
const ownerIndex = getField(record.fields, 'OwnerIndex')
|
|
@@ -92,11 +98,6 @@ export class SchematicMultipartOwnerMatcher {
|
|
|
92
98
|
parseNumericField(record.fields, 'CurrentPartId') || ''
|
|
93
99
|
)
|
|
94
100
|
const partCount = parseNumericField(record.fields, 'PartCount') || 0
|
|
95
|
-
|
|
96
|
-
if (!currentPartId || partCount <= 1) {
|
|
97
|
-
continue
|
|
98
|
-
}
|
|
99
|
-
|
|
100
101
|
const directOwnerIndex =
|
|
101
102
|
SchematicMultipartOwnerMatcher.#findSerializedOwnerIndex(
|
|
102
103
|
records,
|
|
@@ -107,6 +108,11 @@ export class SchematicMultipartOwnerMatcher {
|
|
|
107
108
|
continue
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
if (!currentPartId || partCount <= 1) {
|
|
112
|
+
directOwnerIndexesByRecord.set(record, directOwnerIndex)
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
|
|
110
116
|
directOwnerIndexesByRecord.set(record, directOwnerIndex)
|
|
111
117
|
}
|
|
112
118
|
|
|
@@ -120,13 +126,21 @@ export class SchematicMultipartOwnerMatcher {
|
|
|
120
126
|
const x = parseNumericField(record.fields, 'Location.X')
|
|
121
127
|
const y = parseNumericField(record.fields, 'Location.Y')
|
|
122
128
|
const isMirrored = parseBoolean(record.fields.IsMirrored)
|
|
129
|
+
const directOwnerIndex = directOwnerIndexesByRecord.get(record)
|
|
123
130
|
|
|
124
131
|
if (!currentPartId || partCount <= 1 || x === null || y === null) {
|
|
132
|
+
const inferredPartId =
|
|
133
|
+
SchematicMultipartOwnerMatcher.#inferWiredMultipartOwnerPart(
|
|
134
|
+
records,
|
|
135
|
+
directOwnerIndex,
|
|
136
|
+
ownerlessConnectionPoints
|
|
137
|
+
)
|
|
138
|
+
if (directOwnerIndex && inferredPartId) {
|
|
139
|
+
activeOwnerParts.set(directOwnerIndex, inferredPartId)
|
|
140
|
+
}
|
|
125
141
|
continue
|
|
126
142
|
}
|
|
127
143
|
|
|
128
|
-
const directOwnerIndex = directOwnerIndexesByRecord.get(record)
|
|
129
|
-
|
|
130
144
|
if (directOwnerIndex) {
|
|
131
145
|
activeOwnerParts.set(directOwnerIndex, currentPartId)
|
|
132
146
|
continue
|
|
@@ -253,6 +267,243 @@ export class SchematicMultipartOwnerMatcher {
|
|
|
253
267
|
)
|
|
254
268
|
}
|
|
255
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Infers the visible part for malformed multipart owners from routed pin
|
|
272
|
+
* endpoint evidence. This only applies when one part clearly owns the
|
|
273
|
+
* external wire connections, avoiding guesses for ambiguous overlaps.
|
|
274
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
275
|
+
* @param {string | undefined} ownerIndex
|
|
276
|
+
* @param {{ x: number, y: number }[]} ownerlessConnectionPoints
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
static #inferWiredMultipartOwnerPart(
|
|
280
|
+
records,
|
|
281
|
+
ownerIndex,
|
|
282
|
+
ownerlessConnectionPoints
|
|
283
|
+
) {
|
|
284
|
+
const normalizedOwnerIndex = String(ownerIndex || '').trim()
|
|
285
|
+
if (!normalizedOwnerIndex || !ownerlessConnectionPoints.length) {
|
|
286
|
+
return ''
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const scores =
|
|
290
|
+
SchematicMultipartOwnerMatcher.#scoreWiredMultipartOwnerParts(
|
|
291
|
+
records,
|
|
292
|
+
normalizedOwnerIndex,
|
|
293
|
+
ownerlessConnectionPoints
|
|
294
|
+
)
|
|
295
|
+
if (scores.size < 2) {
|
|
296
|
+
return ''
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const rankedScores = [...scores.entries()].sort((left, right) => {
|
|
300
|
+
if (left[1] !== right[1]) {
|
|
301
|
+
return right[1] - left[1]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return Number(left[0]) - Number(right[0])
|
|
305
|
+
})
|
|
306
|
+
const [bestPartId, bestScore] = rankedScores[0] || ['', 0]
|
|
307
|
+
const secondScore = rankedScores[1]?.[1] || 0
|
|
308
|
+
|
|
309
|
+
if (bestScore < WIRED_PART_MIN_SCORE) {
|
|
310
|
+
return ''
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (
|
|
314
|
+
secondScore > 0 &&
|
|
315
|
+
bestScore < secondScore * WIRED_PART_DOMINANCE_RATIO
|
|
316
|
+
) {
|
|
317
|
+
return ''
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return bestPartId
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Scores owner parts by the number of pin endpoints touching ownerless
|
|
325
|
+
* wire endpoints or vertices.
|
|
326
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
327
|
+
* @param {string} ownerIndex
|
|
328
|
+
* @param {{ x: number, y: number }[]} ownerlessConnectionPoints
|
|
329
|
+
* @returns {Map<string, number>}
|
|
330
|
+
*/
|
|
331
|
+
static #scoreWiredMultipartOwnerParts(
|
|
332
|
+
records,
|
|
333
|
+
ownerIndex,
|
|
334
|
+
ownerlessConnectionPoints
|
|
335
|
+
) {
|
|
336
|
+
const scores = new Map()
|
|
337
|
+
|
|
338
|
+
for (const record of records || []) {
|
|
339
|
+
if (
|
|
340
|
+
getField(record.fields, 'RECORD') !== '2' ||
|
|
341
|
+
getField(record.fields, 'OwnerIndex') !== ownerIndex
|
|
342
|
+
) {
|
|
343
|
+
continue
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const ownerPartId = getField(record.fields, 'OwnerPartId')
|
|
347
|
+
if (!ownerPartId || ownerPartId === '-1') {
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!scores.has(ownerPartId)) {
|
|
352
|
+
scores.set(ownerPartId, 0)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const endpoint =
|
|
356
|
+
SchematicMultipartOwnerMatcher.#resolvePinConnectionPoint(
|
|
357
|
+
record.fields
|
|
358
|
+
)
|
|
359
|
+
if (
|
|
360
|
+
endpoint &&
|
|
361
|
+
SchematicMultipartOwnerMatcher.#pointTouchesAnyConnectionPoint(
|
|
362
|
+
endpoint,
|
|
363
|
+
ownerlessConnectionPoints
|
|
364
|
+
)
|
|
365
|
+
) {
|
|
366
|
+
scores.set(ownerPartId, scores.get(ownerPartId) + 1)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return scores
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Collects ownerless schematic wire endpoints and vertices.
|
|
375
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
376
|
+
* @returns {{ x: number, y: number }[]}
|
|
377
|
+
*/
|
|
378
|
+
static #collectOwnerlessConnectionPoints(records) {
|
|
379
|
+
const points = []
|
|
380
|
+
|
|
381
|
+
for (const record of records || []) {
|
|
382
|
+
if (getField(record.fields, 'OwnerIndex')) {
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const recordType = getField(record.fields, 'RECORD')
|
|
387
|
+
if (
|
|
388
|
+
recordType === '6' ||
|
|
389
|
+
recordType === '26' ||
|
|
390
|
+
recordType === '27'
|
|
391
|
+
) {
|
|
392
|
+
points.push(
|
|
393
|
+
...SchematicMultipartOwnerMatcher.#collectSchematicWirePoints(
|
|
394
|
+
record.fields
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (recordType !== '13') {
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const locationX = parseNumericField(record.fields, 'Location.X')
|
|
405
|
+
const locationY = parseNumericField(record.fields, 'Location.Y')
|
|
406
|
+
const cornerX = parseNumericField(record.fields, 'Corner.X')
|
|
407
|
+
const cornerY = parseNumericField(record.fields, 'Corner.Y')
|
|
408
|
+
|
|
409
|
+
if (locationX !== null && locationY !== null) {
|
|
410
|
+
points.push({ x: locationX, y: locationY })
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (cornerX !== null && cornerY !== null) {
|
|
414
|
+
points.push({ x: cornerX, y: cornerY })
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return points
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Collects polyline vertices while preserving omitted unchanged axes.
|
|
423
|
+
* @param {Record<string, string | string[]>} fields
|
|
424
|
+
* @returns {{ x: number, y: number }[]}
|
|
425
|
+
*/
|
|
426
|
+
static #collectSchematicWirePoints(fields) {
|
|
427
|
+
const locationCount = parseNumericField(fields, 'LocationCount')
|
|
428
|
+
const points = []
|
|
429
|
+
let previousX = null
|
|
430
|
+
let previousY = null
|
|
431
|
+
|
|
432
|
+
if (locationCount === null || locationCount < 2) {
|
|
433
|
+
return points
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for (let index = 1; index <= locationCount; index += 1) {
|
|
437
|
+
const x = parseNumericField(fields, 'X' + index)
|
|
438
|
+
const y = parseNumericField(fields, 'Y' + index)
|
|
439
|
+
|
|
440
|
+
if (x === null && y === null) {
|
|
441
|
+
break
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const pointX = x === null ? previousX : x
|
|
445
|
+
const pointY = y === null ? previousY : y
|
|
446
|
+
|
|
447
|
+
if (pointX === null || pointY === null) {
|
|
448
|
+
break
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
points.push({ x: pointX, y: pointY })
|
|
452
|
+
previousX = pointX
|
|
453
|
+
previousY = pointY
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return points
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Resolves the external route endpoint for one raw pin record.
|
|
461
|
+
* @param {Record<string, string | string[]>} fields
|
|
462
|
+
* @returns {{ x: number, y: number } | null}
|
|
463
|
+
*/
|
|
464
|
+
static #resolvePinConnectionPoint(fields) {
|
|
465
|
+
const x = parseNumericField(fields, 'Location.X')
|
|
466
|
+
const y = parseNumericField(fields, 'Location.Y')
|
|
467
|
+
const length = parseNumericField(fields, 'PinLength')
|
|
468
|
+
const orientation =
|
|
469
|
+
SchematicMultipartOwnerMatcher.#inferPinConnectionOrientation(
|
|
470
|
+
parseNumericField(fields, 'PinConglomerate')
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
if (x === null || y === null || length === null || !orientation) {
|
|
474
|
+
return null
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
switch (orientation) {
|
|
478
|
+
case 'right':
|
|
479
|
+
return { x: x + length, y }
|
|
480
|
+
case 'left':
|
|
481
|
+
return { x: x - length, y }
|
|
482
|
+
case 'top':
|
|
483
|
+
return { x, y: y + length }
|
|
484
|
+
case 'bottom':
|
|
485
|
+
return { x, y: y - length }
|
|
486
|
+
default:
|
|
487
|
+
return null
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Returns true when one point matches any known connection point.
|
|
493
|
+
* @param {{ x: number, y: number }} point
|
|
494
|
+
* @param {{ x: number, y: number }[]} connectionPoints
|
|
495
|
+
* @returns {boolean}
|
|
496
|
+
*/
|
|
497
|
+
static #pointTouchesAnyConnectionPoint(point, connectionPoints) {
|
|
498
|
+
const tolerance = 0.01
|
|
499
|
+
|
|
500
|
+
return connectionPoints.some(
|
|
501
|
+
(connectionPoint) =>
|
|
502
|
+
Math.abs(connectionPoint.x - point.x) <= tolerance &&
|
|
503
|
+
Math.abs(connectionPoint.y - point.y) <= tolerance
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
|
|
256
507
|
/**
|
|
257
508
|
* Returns true when one schematic record belongs to the selected visible
|
|
258
509
|
* part for a multipart owner.
|
|
@@ -563,4 +814,34 @@ export class SchematicMultipartOwnerMatcher {
|
|
|
563
814
|
return null
|
|
564
815
|
}
|
|
565
816
|
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Maps raw pin conglomerates into external endpoint orientations.
|
|
820
|
+
* @param {number | null} conglomerate
|
|
821
|
+
* @returns {'left' | 'right' | 'top' | 'bottom' | null}
|
|
822
|
+
*/
|
|
823
|
+
static #inferPinConnectionOrientation(conglomerate) {
|
|
824
|
+
switch (conglomerate) {
|
|
825
|
+
case 34:
|
|
826
|
+
case 42:
|
|
827
|
+
case 50:
|
|
828
|
+
case 58:
|
|
829
|
+
return 'left'
|
|
830
|
+
case 32:
|
|
831
|
+
case 40:
|
|
832
|
+
case 48:
|
|
833
|
+
case 56:
|
|
834
|
+
return 'right'
|
|
835
|
+
case 33:
|
|
836
|
+
case 49:
|
|
837
|
+
case 57:
|
|
838
|
+
return 'top'
|
|
839
|
+
case 35:
|
|
840
|
+
case 51:
|
|
841
|
+
case 59:
|
|
842
|
+
return 'bottom'
|
|
843
|
+
default:
|
|
844
|
+
return null
|
|
845
|
+
}
|
|
846
|
+
}
|
|
566
847
|
}
|