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
@@ -11,7 +11,7 @@ export class SchematicOwnerPinLabelLayout {
11
11
  * Resolves one native-facing pin text placement in renderer coordinates
12
12
  * before sheet Y projection. The returned `yOffset` is applied after
13
13
  * projection, matching SVG text baseline behavior.
14
- * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom', symbolOuter?: number }} pin
14
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom', symbolOuter?: number, electrical?: number }} pin
15
15
  * @param {'name' | 'number'} labelKind
16
16
  * @param {{ labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number', rotateTopNumber?: boolean }} [options]
17
17
  * @returns {{ x: number, yOffset: number, anchor: 'start' | 'middle' | 'end', rotation: number } | null}
@@ -19,7 +19,7 @@ export class SchematicOwnerPinLabelLayout {
19
19
  static resolveNativePinTextPlacement(pin, labelKind, options = {}) {
20
20
  const labelMode = options.labelMode || 'name-and-number'
21
21
  const markerStyle =
22
- SchematicOwnerPinLabelLayout.#resolveOuterPinMarkerStyle(pin)
22
+ SchematicOwnerPinLabelLayout.#resolvePinMarkerStyle(pin)
23
23
 
24
24
  if (labelKind === 'number') {
25
25
  return SchematicOwnerPinLabelLayout.#resolveNumberPlacement(
@@ -143,6 +143,124 @@ export class SchematicOwnerPinLabelLayout {
143
143
  return offsets
144
144
  }
145
145
 
146
+ /**
147
+ * Collects compact FET-like owner groups whose numeric contact labels need
148
+ * to stay outside the owner-drawn device body.
149
+ * @param {{ ownerIndex?: string, name?: string, designator?: string, length?: number, orientation: 'left' | 'right' | 'top' | 'bottom', labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[]} pins
150
+ * @returns {Map<string, 'left' | 'right'>}
151
+ */
152
+ static collectCompactExternalNumberLabelSides(pins) {
153
+ const ownerPins = new Map()
154
+
155
+ for (const pin of pins) {
156
+ const ownerIndex = String(pin.ownerIndex || '').trim()
157
+ if (!ownerIndex) continue
158
+ if (!ownerPins.has(ownerIndex)) ownerPins.set(ownerIndex, [])
159
+ ownerPins.get(ownerIndex).push(pin)
160
+ }
161
+
162
+ const sides = new Map()
163
+
164
+ for (const [ownerIndex, groupedPins] of ownerPins.entries()) {
165
+ const side =
166
+ SchematicOwnerPinLabelLayout.#resolveCompactExternalNumberLabelSide(
167
+ groupedPins
168
+ )
169
+ if (side) sides.set(ownerIndex, side)
170
+ }
171
+
172
+ return sides
173
+ }
174
+
175
+ /**
176
+ * Collects rectangular owner bodies whose numeric-only horizontal pin
177
+ * labels are drawn inside the body, close to the body edge.
178
+ * @param {{ ownerIndex?: string, name?: string, designator?: string, length?: number, x: number, y: number, orientation: 'left' | 'right' | 'top' | 'bottom', labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[]} pins
179
+ * @param {{ ownerIndex?: string, x: number, y: number, width: number, height: number }[]} rectangles
180
+ * @returns {Map<string, { left: number, right: number }>}
181
+ */
182
+ static collectInternalNumberLabelBoxes(pins, rectangles) {
183
+ const ownerPins = SchematicOwnerPinLabelLayout.#groupByOwnerIndex(pins)
184
+ const ownerRectangles =
185
+ SchematicOwnerPinLabelLayout.#groupByOwnerIndex(rectangles)
186
+ const boxes = new Map()
187
+
188
+ for (const [ownerIndex, groupedPins] of ownerPins.entries()) {
189
+ const box =
190
+ SchematicOwnerPinLabelLayout.#resolveInternalNumberLabelBox(
191
+ groupedPins,
192
+ ownerRectangles.get(ownerIndex) || []
193
+ )
194
+
195
+ if (box) {
196
+ boxes.set(ownerIndex, box)
197
+ }
198
+ }
199
+
200
+ return boxes
201
+ }
202
+
203
+ /**
204
+ * Collects internally numbered pins whose external number would overlap a
205
+ * visible route text label on the same horizontal lane.
206
+ * @param {{ ownerIndex?: string, name?: string, designator?: string, length?: number, x: number, y: number, orientation: 'left' | 'right' | 'top' | 'bottom', labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number', electrical?: number, symbolOuter?: number }[]} pins
207
+ * @param {{ ownerIndex?: string, text?: string, resolvedText?: string, x?: number, y?: number, anchor?: 'start' | 'middle' | 'end', fontSize?: number, hidden?: boolean, rotation?: number }[]} texts
208
+ * @param {Map<string, { left: number, right: number }>} internalNumberLabelBoxes
209
+ * @returns {Set<string>}
210
+ */
211
+ static collectOverlappingExternalNumberLabelKeys(
212
+ pins,
213
+ texts,
214
+ internalNumberLabelBoxes
215
+ ) {
216
+ const textBounds = (texts || [])
217
+ .map((text) =>
218
+ SchematicOwnerPinLabelLayout.#estimateHorizontalTextBounds(text)
219
+ )
220
+ .filter(Boolean)
221
+ const keys = new Set()
222
+
223
+ if (textBounds.length === 0) {
224
+ return keys
225
+ }
226
+
227
+ for (const pin of pins || []) {
228
+ const ownerIndex = String(pin.ownerIndex || '').trim()
229
+ if (!ownerIndex || !internalNumberLabelBoxes.has(ownerIndex)) {
230
+ continue
231
+ }
232
+
233
+ if (!SchematicOwnerPinLabelLayout.#isInternalNumberLabelPin(pin)) {
234
+ continue
235
+ }
236
+
237
+ const numberBounds =
238
+ SchematicOwnerPinLabelLayout.#estimateExternalNumberBounds(pin)
239
+ if (!numberBounds) {
240
+ continue
241
+ }
242
+
243
+ if (
244
+ textBounds.some((bounds) =>
245
+ SchematicOwnerPinLabelLayout.#boundsOverlap(
246
+ numberBounds,
247
+ bounds,
248
+ 1.5
249
+ )
250
+ )
251
+ ) {
252
+ keys.add(
253
+ SchematicOwnerPinLabelLayout.buildOwnerPinLabelKey(
254
+ ownerIndex,
255
+ pin.designator
256
+ )
257
+ )
258
+ }
259
+ }
260
+
261
+ return keys
262
+ }
263
+
146
264
  /**
147
265
  * Resolves the final SVG text anchor for one schematic free-text label.
148
266
  * Mirrored rotated owner pin-name labels need the opposite text edge so
@@ -170,6 +288,361 @@ export class SchematicOwnerPinLabelLayout {
170
288
  return Number(text.y) >= Number(matchedOwnerPin.y) ? 'end' : 'start'
171
289
  }
172
290
 
291
+ /**
292
+ * Resolves the side that should carry compact owner-drawn pin numbers.
293
+ * @param {{ name?: string, designator?: string, length?: number, orientation: 'left' | 'right' | 'top' | 'bottom', labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[]} pins
294
+ * @returns {'left' | 'right' | null}
295
+ */
296
+ static #resolveCompactExternalNumberLabelSide(pins) {
297
+ if (
298
+ pins.length !== 4 ||
299
+ !pins.every(
300
+ (pin) =>
301
+ (pin.labelMode || 'name-and-number') === 'number-only' &&
302
+ /^\d+$/.test(String(pin.designator || '').trim()) &&
303
+ Math.abs(Number(pin.length || 0)) <= 20 &&
304
+ SchematicOwnerPinLabelLayout.#isFetTerminalName(pin.name)
305
+ )
306
+ ) {
307
+ return null
308
+ }
309
+
310
+ const hasTopPin = pins.some((pin) => pin.orientation === 'top')
311
+ const hasBottomPin = pins.some((pin) => pin.orientation === 'bottom')
312
+ const horizontalPins = pins.filter(
313
+ (pin) => pin.orientation === 'left' || pin.orientation === 'right'
314
+ )
315
+ const horizontalSides = new Set(
316
+ horizontalPins.map((pin) => pin.orientation)
317
+ )
318
+
319
+ if (
320
+ !hasTopPin ||
321
+ !hasBottomPin ||
322
+ horizontalPins.length === 0 ||
323
+ horizontalSides.size !== 1
324
+ ) {
325
+ return null
326
+ }
327
+
328
+ return horizontalPins[0].orientation
329
+ }
330
+
331
+ /**
332
+ * Resolves one rectangular body suitable for internal numeric pin labels.
333
+ * @param {{ name?: string, designator?: string, length?: number, x: number, y: number, orientation: 'left' | 'right' | 'top' | 'bottom', labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[]} pins
334
+ * @param {{ x: number, y: number, width: number, height: number }[]} rectangles
335
+ * @returns {{ left: number, right: number } | null}
336
+ */
337
+ static #resolveInternalNumberLabelBox(pins, rectangles) {
338
+ if (
339
+ pins.length < 4 ||
340
+ !pins.every((pin) =>
341
+ SchematicOwnerPinLabelLayout.#isInternalNumberLabelPin(pin)
342
+ )
343
+ ) {
344
+ return null
345
+ }
346
+
347
+ const sides = new Set(pins.map((pin) => pin.orientation))
348
+
349
+ if (!sides.has('left') || !sides.has('right') || sides.size !== 2) {
350
+ return null
351
+ }
352
+
353
+ const rectangle =
354
+ SchematicOwnerPinLabelLayout.#findInternalNumberLabelRectangle(
355
+ pins,
356
+ rectangles
357
+ )
358
+
359
+ if (!rectangle) {
360
+ return null
361
+ }
362
+
363
+ return {
364
+ left: rectangle.left,
365
+ right: rectangle.right
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Returns the owner rectangle whose vertical edges carry every pin.
371
+ * @param {{ x: number, y: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
372
+ * @param {{ x: number, y: number, width: number, height: number }[]} rectangles
373
+ * @returns {{ left: number, right: number, top: number, bottom: number } | null}
374
+ */
375
+ static #findInternalNumberLabelRectangle(pins, rectangles) {
376
+ for (const rectangle of rectangles) {
377
+ const normalized =
378
+ SchematicOwnerPinLabelLayout.#normalizeRectangle(rectangle)
379
+
380
+ if (
381
+ normalized &&
382
+ SchematicOwnerPinLabelLayout.#pinsAlignWithRectangleEdges(
383
+ pins,
384
+ normalized
385
+ )
386
+ ) {
387
+ return normalized
388
+ }
389
+ }
390
+
391
+ return null
392
+ }
393
+
394
+ /**
395
+ * Returns true when every pin body endpoint lies on a vertical body edge.
396
+ * @param {{ x: number, y: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
397
+ * @param {{ left: number, right: number, top: number, bottom: number }} rectangle
398
+ * @returns {boolean}
399
+ */
400
+ static #pinsAlignWithRectangleEdges(pins, rectangle) {
401
+ const tolerance = 1.5
402
+
403
+ for (const pin of pins) {
404
+ const x = Number(pin.x)
405
+ const y = Number(pin.y)
406
+
407
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
408
+ return false
409
+ }
410
+
411
+ if (
412
+ y < rectangle.top - tolerance ||
413
+ y > rectangle.bottom + tolerance
414
+ ) {
415
+ return false
416
+ }
417
+
418
+ if (
419
+ pin.orientation === 'left' &&
420
+ Math.abs(x - rectangle.left) > tolerance
421
+ ) {
422
+ return false
423
+ }
424
+
425
+ if (
426
+ pin.orientation === 'right' &&
427
+ Math.abs(x - rectangle.right) > tolerance
428
+ ) {
429
+ return false
430
+ }
431
+ }
432
+
433
+ return true
434
+ }
435
+
436
+ /**
437
+ * Normalizes a rectangle to absolute edge coordinates.
438
+ * @param {{ x: number, y: number, width: number, height: number }} rectangle
439
+ * @returns {{ left: number, right: number, top: number, bottom: number } | null}
440
+ */
441
+ static #normalizeRectangle(rectangle) {
442
+ const x1 = Number(rectangle?.x)
443
+ const y1 = Number(rectangle?.y)
444
+ const x2 = x1 + Number(rectangle?.width)
445
+ const y2 = y1 + Number(rectangle?.height)
446
+
447
+ if (
448
+ !Number.isFinite(x1) ||
449
+ !Number.isFinite(y1) ||
450
+ !Number.isFinite(x2) ||
451
+ !Number.isFinite(y2) ||
452
+ x1 === x2 ||
453
+ y1 === y2
454
+ ) {
455
+ return null
456
+ }
457
+
458
+ return {
459
+ left: Math.min(x1, x2),
460
+ right: Math.max(x1, x2),
461
+ top: Math.min(y1, y2),
462
+ bottom: Math.max(y1, y2)
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Returns true for numeric-only horizontal pins on short owner stubs.
468
+ * @param {{ name?: string, designator?: string, length?: number, orientation: 'left' | 'right' | 'top' | 'bottom', labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }} pin
469
+ * @returns {boolean}
470
+ */
471
+ static #isInternalNumberLabelPin(pin) {
472
+ const name = String(pin.name || '').trim()
473
+ const designator = String(pin.designator || '').trim()
474
+
475
+ return (
476
+ (pin.orientation === 'left' || pin.orientation === 'right') &&
477
+ (pin.labelMode || 'name-and-number') === 'number-only' &&
478
+ /^\d+$/.test(designator) &&
479
+ (!name || /^\d+$/.test(name)) &&
480
+ Math.abs(Number(pin.length || 0)) <= 30
481
+ )
482
+ }
483
+
484
+ /**
485
+ * Groups schematic owner-local primitives by owner index.
486
+ * @template T
487
+ * @param {(T & { ownerIndex?: string })[]} items
488
+ * @returns {Map<string, T[]>}
489
+ */
490
+ static #groupByOwnerIndex(items) {
491
+ const groups = new Map()
492
+
493
+ for (const item of items) {
494
+ const ownerIndex = String(item.ownerIndex || '').trim()
495
+
496
+ if (!ownerIndex) {
497
+ continue
498
+ }
499
+
500
+ if (!groups.has(ownerIndex)) {
501
+ groups.set(ownerIndex, [])
502
+ }
503
+
504
+ groups.get(ownerIndex).push(item)
505
+ }
506
+
507
+ return groups
508
+ }
509
+
510
+ /**
511
+ * Returns true for FET terminal names, including numbered gate/source pins.
512
+ * @param {string | undefined} name
513
+ * @returns {boolean}
514
+ */
515
+ static #isFetTerminalName(name) {
516
+ return /^(?:[DS]|[GS]\d*)$/i.test(String(name || '').trim())
517
+ }
518
+
519
+ /**
520
+ * Estimates a route text label's source-coordinate visual bounds.
521
+ * @param {{ ownerIndex?: string, text?: string, resolvedText?: string, x?: number, y?: number, anchor?: 'start' | 'middle' | 'end', fontSize?: number, hidden?: boolean, rotation?: number } | null} text
522
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number } | null}
523
+ */
524
+ static #estimateHorizontalTextBounds(text) {
525
+ if (!text || text.hidden || text.ownerIndex) return null
526
+ if (Number(text.rotation || 0) !== 0) return null
527
+
528
+ const label = String(text.resolvedText ?? text.text ?? '').trim()
529
+ const x = Number(text.x)
530
+ const y = Number(text.y)
531
+ if (!label || !Number.isFinite(x) || !Number.isFinite(y)) return null
532
+
533
+ const fontSize = SchematicOwnerPinLabelLayout.#resolveViewerFontSize(
534
+ text.fontSize
535
+ )
536
+ const width = SchematicOwnerPinLabelLayout.#estimateTextWidth(
537
+ label,
538
+ fontSize
539
+ )
540
+ const anchor = text.anchor || 'start'
541
+ const minX =
542
+ anchor === 'end'
543
+ ? x - width
544
+ : anchor === 'middle'
545
+ ? x - width / 2
546
+ : x
547
+ const maxX =
548
+ anchor === 'end'
549
+ ? x
550
+ : anchor === 'middle'
551
+ ? x + width / 2
552
+ : x + width
553
+
554
+ return {
555
+ minX,
556
+ maxX,
557
+ minY: y - fontSize * 0.7,
558
+ maxY: y + fontSize * 0.35
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Estimates one external pin number's source-coordinate bounds.
564
+ * @param {{ designator?: string, length?: number, x: number, y: number, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number }} pin
565
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number } | null}
566
+ */
567
+ static #estimateExternalNumberBounds(pin) {
568
+ if (pin.orientation !== 'left' && pin.orientation !== 'right') {
569
+ return null
570
+ }
571
+
572
+ const label = String(pin.designator || '').trim()
573
+ const x = Number(pin.x)
574
+ const y = Number(pin.y)
575
+ if (!label || !Number.isFinite(x) || !Number.isFinite(y)) return null
576
+
577
+ const markerStyle =
578
+ SchematicOwnerPinLabelLayout.#resolvePinMarkerStyle(pin)
579
+ const clearance =
580
+ SchematicOwnerPinLabelLayout.#resolveHorizontalPinNumberClearance(
581
+ markerStyle,
582
+ pin
583
+ )
584
+ const offset = Math.max(
585
+ clearance,
586
+ SchematicOwnerPinLabelLayout.#resolveCompactExternalHorizontalNumberOffset(
587
+ pin
588
+ )
589
+ )
590
+ const numberX = pin.orientation === 'left' ? x - offset : x + offset
591
+ const anchor =
592
+ SchematicOwnerPinLabelLayout.#resolveHorizontalPinNumberAnchor(
593
+ pin,
594
+ markerStyle
595
+ )
596
+ const fontSize = SchematicOwnerPinLabelLayout.#resolveViewerFontSize()
597
+ const width = SchematicOwnerPinLabelLayout.#estimateTextWidth(
598
+ label,
599
+ fontSize
600
+ )
601
+
602
+ return {
603
+ minX: anchor === 'end' ? numberX - width : numberX,
604
+ maxX: anchor === 'end' ? numberX : numberX + width,
605
+ minY: y - fontSize * 0.7,
606
+ maxY: y + fontSize * 0.35
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Returns the viewer-adjusted schematic font size used for collision
612
+ * estimates.
613
+ * @param {number | undefined} fontSize Source font size.
614
+ * @returns {number}
615
+ */
616
+ static #resolveViewerFontSize(fontSize = 10) {
617
+ return Math.max(Number(fontSize || 10) - 1, 6)
618
+ }
619
+
620
+ /**
621
+ * Estimates one rendered text run width.
622
+ * @param {string} text Text content.
623
+ * @param {number} fontSize Viewer font size.
624
+ * @returns {number}
625
+ */
626
+ static #estimateTextWidth(text, fontSize) {
627
+ return Math.max(String(text || '').length * fontSize * 0.62, fontSize)
628
+ }
629
+
630
+ /**
631
+ * Returns true when two source-coordinate boxes overlap.
632
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} first First bounds.
633
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} second Second bounds.
634
+ * @param {number} tolerance Coordinate tolerance.
635
+ * @returns {boolean}
636
+ */
637
+ static #boundsOverlap(first, second, tolerance) {
638
+ return (
639
+ first.minX <= second.maxX + tolerance &&
640
+ first.maxX >= second.minX - tolerance &&
641
+ first.minY <= second.maxY + tolerance &&
642
+ first.maxY >= second.minY - tolerance
643
+ )
644
+ }
645
+
173
646
  /**
174
647
  * Moves left/right pin numbers outward by the same horizontal correction
175
648
  * already applied to their explicit owner pin-name labels.
@@ -206,7 +679,7 @@ export class SchematicOwnerPinLabelLayout {
206
679
  /**
207
680
  * Resolves schematic pin-number placement.
208
681
  * @param {{ x: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
209
- * @param {'single-in' | 'single-out' | 'double' | null} markerStyle
682
+ * @param {'single-in' | 'single-out' | 'double' | 'cross' | null} markerStyle
210
683
  * @param {{ rotateTopNumber?: boolean }} options
211
684
  * @returns {{ x: number, yOffset: number, anchor: 'start' | 'middle' | 'end', rotation: number } | null}
212
685
  */
@@ -221,7 +694,10 @@ export class SchematicOwnerPinLabelLayout {
221
694
  pin
222
695
  ),
223
696
  yOffset: -1,
224
- anchor: 'end',
697
+ anchor: SchematicOwnerPinLabelLayout.#resolveHorizontalPinNumberAnchor(
698
+ pin,
699
+ markerStyle
700
+ ),
225
701
  rotation: 0
226
702
  }
227
703
  case 'right':
@@ -233,7 +709,10 @@ export class SchematicOwnerPinLabelLayout {
233
709
  pin
234
710
  ),
235
711
  yOffset: -1,
236
- anchor: 'start',
712
+ anchor: SchematicOwnerPinLabelLayout.#resolveHorizontalPinNumberAnchor(
713
+ pin,
714
+ markerStyle
715
+ ),
237
716
  rotation: 0
238
717
  }
239
718
  case 'top':
@@ -308,22 +787,46 @@ export class SchematicOwnerPinLabelLayout {
308
787
 
309
788
  /**
310
789
  * Returns the horizontal pin-number clearance needed by the pin geometry.
311
- * @param {'single-in' | 'single-out' | 'double' | null} markerStyle
312
- * @param {{ length?: number }} pin
790
+ * @param {'single-in' | 'single-out' | 'double' | 'cross' | null} markerStyle
791
+ * @param {{ length?: number, electrical?: number }} pin
313
792
  * @returns {number}
314
793
  */
315
794
  static #resolveHorizontalPinNumberClearance(markerStyle, pin) {
316
795
  switch (markerStyle) {
317
796
  case 'double':
318
- return 17
797
+ return 21
798
+ case 'cross':
799
+ return 9
319
800
  case 'single-in':
320
801
  case 'single-out':
321
802
  return 8
322
803
  default:
804
+ if (Number(pin?.electrical || 0) === 1) {
805
+ return 16
806
+ }
807
+
323
808
  return SchematicOwnerPinLabelLayout.#resolveLongPinInset(pin, 2)
324
809
  }
325
810
  }
326
811
 
812
+ /**
813
+ * Resolves the text edge used for horizontal pin numbers.
814
+ * @param {{ orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number }} pin Pin primitive.
815
+ * @param {'single-in' | 'single-out' | 'double' | 'cross' | null} markerStyle Marker style.
816
+ * @returns {'start' | 'end'}
817
+ */
818
+ static #resolveHorizontalPinNumberAnchor(pin, markerStyle) {
819
+ const routeFacing =
820
+ markerStyle === 'double' ||
821
+ (!markerStyle && Number(pin?.electrical || 0) === 1)
822
+
823
+ if (routeFacing) {
824
+ return pin.orientation === 'left' ? 'start' : 'end'
825
+ }
826
+
827
+ return pin.orientation === 'left' ? 'end' : 'start'
828
+ }
829
+
327
830
  /**
328
831
  * Returns the horizontal pin-name inset used inside the symbol body.
329
832
  * @param {{ length?: number }} pin
@@ -335,7 +838,7 @@ export class SchematicOwnerPinLabelLayout {
335
838
  return 10
336
839
  }
337
840
 
338
- return SchematicOwnerPinLabelLayout.#resolveLongPinInset(pin, 4)
841
+ return SchematicOwnerPinLabelLayout.#resolveLongPinInset(pin, 7)
339
842
  }
340
843
 
341
844
  /**
@@ -354,10 +857,22 @@ export class SchematicOwnerPinLabelLayout {
354
857
  return fallback === 2 ? 10 : 8
355
858
  }
356
859
 
860
+ /**
861
+ * Resolves the stub-side offset used when a pin already has an internal
862
+ * numeric label in its owner body.
863
+ * @param {{ length?: number }} pin Pin primitive.
864
+ * @returns {number}
865
+ */
866
+ static #resolveCompactExternalHorizontalNumberOffset(pin) {
867
+ const length = Math.abs(Number(pin.length || 0))
868
+
869
+ return Math.max(8, Math.min(length - 6, 12))
870
+ }
871
+
357
872
  /**
358
873
  * Resolves one authored outer pin marker style from the stored symbol flag.
359
874
  * @param {{ symbolOuter?: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
360
- * @returns {'single-in' | 'single-out' | 'double' | null}
875
+ * @returns {'single-in' | 'single-out' | 'double' | 'cross' | null}
361
876
  */
362
877
  static #resolveOuterPinMarkerStyle(pin) {
363
878
  if (pin.orientation !== 'left' && pin.orientation !== 'right') {
@@ -370,10 +885,45 @@ export class SchematicOwnerPinLabelLayout {
370
885
  return 'single-out'
371
886
  case 2:
372
887
  return 'single-in'
888
+ case 6:
889
+ return 'cross'
373
890
  case 34:
374
891
  return 'double'
375
892
  default:
376
893
  return null
377
894
  }
378
895
  }
896
+
897
+ /**
898
+ * Resolves the marker style that contributes to native text clearance.
899
+ * @param {{ symbolOuter?: number, electrical?: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
900
+ * @returns {'single-in' | 'single-out' | 'double' | 'cross' | null}
901
+ */
902
+ static #resolvePinMarkerStyle(pin) {
903
+ return (
904
+ SchematicOwnerPinLabelLayout.#resolveOuterPinMarkerStyle(pin) ||
905
+ SchematicOwnerPinLabelLayout.#resolveElectricalPinMarkerStyle(pin)
906
+ )
907
+ }
908
+
909
+ /**
910
+ * Resolves electrical pin marker styles. Bidirectional pins keep the
911
+ * existing route-facing number placement through the explicit check.
912
+ * @param {{ electrical?: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
913
+ * @returns {'single-in' | 'single-out' | null}
914
+ */
915
+ static #resolveElectricalPinMarkerStyle(pin) {
916
+ if (pin.orientation !== 'left' && pin.orientation !== 'right') {
917
+ return null
918
+ }
919
+
920
+ switch (Number(pin.electrical)) {
921
+ case 0:
922
+ return 'single-in'
923
+ case 2:
924
+ return 'single-out'
925
+ default:
926
+ return null
927
+ }
928
+ }
379
929
  }