altium-toolkit 1.1.35 → 1.1.36

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 CHANGED
@@ -33,8 +33,8 @@
33
33
  ## Library Scope
34
34
 
35
35
  - Include parser, OLE/binary helpers, schematic SVG rendering, PCB SVG
36
- rendering, BOM HTML rendering, 3D scene-description builders, and static 3D
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.1.35",
3
+ "version": "1.1.36",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -215,7 +215,8 @@ export class PrintableTextDecoder {
215
215
  * @returns {boolean}
216
216
  */
217
217
  static #hasWindows1252PreferredBytes(bytes) {
218
- for (const byte of bytes) {
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
  }