altium-toolkit 1.0.8 → 1.0.10

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 (102) hide show
  1. package/README.md +18 -6
  2. package/docs/api.md +78 -16
  3. package/docs/model-format.md +229 -8
  4. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +76 -0
  5. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +35 -0
  6. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +53 -0
  7. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1826 -110
  8. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  9. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +86 -0
  10. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +63 -0
  11. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  12. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  13. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  14. package/docs/testing.md +9 -3
  15. package/package.json +1 -1
  16. package/spec/library-scope.md +7 -1
  17. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  18. package/src/core/altium/AltiumParser.mjs +196 -45
  19. package/src/core/altium/CiArtifactBundleBuilder.mjs +202 -0
  20. package/src/core/altium/DraftsmanDigestParser.mjs +689 -0
  21. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  22. package/src/core/altium/IntLibModelParser.mjs +240 -0
  23. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  24. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  25. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  26. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  27. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  28. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  29. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  30. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  31. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  32. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  33. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  34. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  35. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  36. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  37. package/src/core/altium/PcbModelParser.mjs +495 -32
  38. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  39. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  40. package/src/core/altium/PcbPadStackParser.mjs +229 -2
  41. package/src/core/altium/PcbPickPlacePositionResolver.mjs +224 -0
  42. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  43. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  44. package/src/core/altium/PcbRegionPrimitiveParser.mjs +76 -3
  45. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  46. package/src/core/altium/PcbRuleParser.mjs +354 -33
  47. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  48. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  49. package/src/core/altium/PcbStatisticsBuilder.mjs +541 -0
  50. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  51. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  52. package/src/core/altium/PcbUnionParser.mjs +307 -0
  53. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  54. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  55. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  56. package/src/core/altium/PrjPcbModelParser.mjs +281 -7
  57. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  58. package/src/core/altium/ProjectDesignBundleBuilder.mjs +492 -0
  59. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  60. package/src/core/altium/ProjectNetlistExporter.mjs +503 -0
  61. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  62. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  63. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  64. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  65. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  66. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  67. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  68. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  69. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  70. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  71. package/src/core/altium/SchematicImageParser.mjs +474 -3
  72. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  73. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  74. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  75. package/src/core/altium/SchematicPinParser.mjs +84 -1
  76. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  77. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  78. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  79. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  80. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  81. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  82. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  83. package/src/core/altium/SchematicTextParser.mjs +123 -0
  84. package/src/core/altium/SvgModelCrossLinkValidator.mjs +402 -0
  85. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +136 -96
  86. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  87. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  88. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  89. package/src/parser.mjs +35 -0
  90. package/src/styles/altium-renderers.css +19 -0
  91. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  92. package/src/ui/PcbInteractionIndex.mjs +9 -4
  93. package/src/ui/PcbScene3dBuilder.mjs +137 -3
  94. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  95. package/src/ui/PcbSvgRenderer.mjs +1252 -34
  96. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  97. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  98. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  99. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  100. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  101. package/src/ui/SchematicTypography.mjs +48 -5
  102. package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
@@ -0,0 +1,195 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+
7
+ const { getField, parseNumericField } = ParserUtils
8
+
9
+ /**
10
+ * Builds a read-only ownership graph from schematic record owner indexes.
11
+ */
12
+ export class SchematicOwnershipGraphParser {
13
+ static SCHEMA_ID = 'altium-toolkit.schematic.ownership.a1'
14
+
15
+ /**
16
+ * Parses record parent/child links from raw schematic records.
17
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }[]} records Schematic records.
18
+ * @returns {{ schema: string, records: object[], recordsByIndexInSheet: Record<string, object>, childrenByParentKey: Record<string, object[]>, parentsByChildKey: Record<string, object> }}
19
+ */
20
+ static parse(records) {
21
+ const entries = (records || []).map((record, fallbackIndex) =>
22
+ SchematicOwnershipGraphParser.#recordEntry(record, fallbackIndex)
23
+ )
24
+ const ownerLookup =
25
+ SchematicOwnershipGraphParser.#buildOwnerLookup(entries)
26
+ const childrenByParentKey = {}
27
+ const parentsByChildKey = {}
28
+
29
+ for (const entry of entries) {
30
+ if (!entry.ownerIndex) {
31
+ continue
32
+ }
33
+
34
+ const parent = ownerLookup.get(entry.ownerIndex)
35
+ if (!parent || parent.key === entry.key) {
36
+ continue
37
+ }
38
+
39
+ if (!childrenByParentKey[parent.key]) {
40
+ childrenByParentKey[parent.key] = []
41
+ }
42
+
43
+ childrenByParentKey[parent.key].push(
44
+ SchematicOwnershipGraphParser.#childDescriptor(entry)
45
+ )
46
+ parentsByChildKey[entry.key] = {
47
+ parentKey: parent.key,
48
+ ownerIndex: entry.ownerIndex
49
+ }
50
+ }
51
+
52
+ return {
53
+ schema: SchematicOwnershipGraphParser.SCHEMA_ID,
54
+ records: entries.map((entry) =>
55
+ SchematicOwnershipGraphParser.#publicRecord(entry)
56
+ ),
57
+ recordsByIndexInSheet:
58
+ SchematicOwnershipGraphParser.#recordsByIndexInSheet(entries),
59
+ childrenByParentKey,
60
+ parentsByChildKey
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Normalizes one raw schematic record into a sidecar row.
66
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }} record Source record.
67
+ * @param {number} fallbackIndex Fallback record index.
68
+ * @returns {object}
69
+ */
70
+ static #recordEntry(record, fallbackIndex) {
71
+ const recordIndex = Number.isInteger(record?.recordIndex)
72
+ ? record.recordIndex
73
+ : fallbackIndex
74
+ const indexInSheet = parseNumericField(record?.fields, 'IndexInSheet')
75
+
76
+ return {
77
+ key: 'schematic-record-' + recordIndex,
78
+ recordIndex,
79
+ recordType: getField(record?.fields, 'RECORD'),
80
+ indexInSheet,
81
+ ownerIndex: getField(record?.fields, 'OwnerIndex'),
82
+ uniqueId:
83
+ getField(record?.fields, 'UniqueID') ||
84
+ getField(record?.fields, 'UniqueId'),
85
+ name: getField(record?.fields, 'Name'),
86
+ text: getField(record?.fields, 'Text')
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Builds tolerant owner-index lookup keys for record parents.
92
+ * @param {object[]} entries Record sidecar rows.
93
+ * @returns {Map<string, object>}
94
+ */
95
+ static #buildOwnerLookup(entries) {
96
+ const lookup = new Map()
97
+
98
+ for (const entry of entries) {
99
+ for (const key of SchematicOwnershipGraphParser.#ownerKeys(entry)) {
100
+ if (!lookup.has(key)) {
101
+ lookup.set(key, entry)
102
+ }
103
+ }
104
+ }
105
+
106
+ return lookup
107
+ }
108
+
109
+ /**
110
+ * Returns candidate owner-index keys for one record.
111
+ * @param {object} entry Record sidecar row.
112
+ * @returns {string[]}
113
+ */
114
+ static #ownerKeys(entry) {
115
+ const keys = new Set([
116
+ String(entry.recordIndex),
117
+ String(entry.recordIndex + 1)
118
+ ])
119
+
120
+ if (Number.isInteger(entry.indexInSheet)) {
121
+ keys.add(String(entry.indexInSheet))
122
+ keys.add(String(entry.indexInSheet + 1))
123
+ }
124
+
125
+ return [...keys]
126
+ }
127
+
128
+ /**
129
+ * Builds a compact public record row.
130
+ * @param {object} entry Internal record row.
131
+ * @returns {object}
132
+ */
133
+ static #publicRecord(entry) {
134
+ return SchematicOwnershipGraphParser.#stripEmpty({
135
+ key: entry.key,
136
+ recordIndex: entry.recordIndex,
137
+ recordType: entry.recordType,
138
+ indexInSheet: entry.indexInSheet,
139
+ ownerIndex: entry.ownerIndex,
140
+ uniqueId: entry.uniqueId,
141
+ name: entry.name,
142
+ text: entry.text
143
+ })
144
+ }
145
+
146
+ /**
147
+ * Builds a compact child descriptor for grouped owner lists.
148
+ * @param {object} entry Internal record row.
149
+ * @returns {object}
150
+ */
151
+ static #childDescriptor(entry) {
152
+ return SchematicOwnershipGraphParser.#stripEmpty({
153
+ key: entry.key,
154
+ recordIndex: entry.recordIndex,
155
+ recordType: entry.recordType,
156
+ ownerIndex: entry.ownerIndex,
157
+ indexInSheet: entry.indexInSheet,
158
+ name: entry.name
159
+ })
160
+ }
161
+
162
+ /**
163
+ * Indexes records by native IndexInSheet values.
164
+ * @param {object[]} entries Record sidecar rows.
165
+ * @returns {Record<string, object>}
166
+ */
167
+ static #recordsByIndexInSheet(entries) {
168
+ const index = {}
169
+
170
+ for (const entry of entries) {
171
+ if (Number.isInteger(entry.indexInSheet)) {
172
+ index[String(entry.indexInSheet)] =
173
+ SchematicOwnershipGraphParser.#publicRecord(entry)
174
+ }
175
+ }
176
+
177
+ return index
178
+ }
179
+
180
+ /**
181
+ * Drops empty object fields while preserving numeric zero.
182
+ * @param {Record<string, unknown>} value Candidate object.
183
+ * @returns {Record<string, unknown>}
184
+ */
185
+ static #stripEmpty(value) {
186
+ return Object.fromEntries(
187
+ Object.entries(value || {}).filter(
188
+ ([, entryValue]) =>
189
+ entryValue !== null &&
190
+ entryValue !== undefined &&
191
+ entryValue !== ''
192
+ )
193
+ )
194
+ }
195
+ }
@@ -409,6 +409,14 @@ export class SchematicPinParser {
409
409
 
410
410
  const segments = []
411
411
  const lineStyle = SchematicPinParser.#resolveSchematicLineStyle(fields)
412
+ const startMarker = SchematicPinParser.#resolvePolylineMarker(
413
+ fields,
414
+ 'Start'
415
+ )
416
+ const endMarker = SchematicPinParser.#resolvePolylineMarker(
417
+ fields,
418
+ 'End'
419
+ )
412
420
 
413
421
  for (let index = 1; index < points.length; index += 1) {
414
422
  const previous = points[index - 1]
@@ -433,7 +441,11 @@ export class SchematicPinParser {
433
441
  isBus: options.isBus === true ? true : undefined,
434
442
  recordType: options.recordType || undefined,
435
443
  omittedEndpointAxis: omittedEndpointAxis || undefined,
436
- sourceLocationCount: points.length
444
+ sourceLocationCount: points.length,
445
+ ...(index === 1 && startMarker ? { startMarker } : {}),
446
+ ...(index === points.length - 1 && endMarker
447
+ ? { endMarker }
448
+ : {})
437
449
  })
438
450
  }
439
451
 
@@ -525,6 +537,77 @@ export class SchematicPinParser {
525
537
  : '#a44a1b'
526
538
  }
527
539
 
540
+ /**
541
+ * Resolves one authored polyline endpoint marker.
542
+ * @param {Record<string, string | string[]>} fields Source record fields.
543
+ * @param {'Start' | 'End'} edge Endpoint prefix.
544
+ * @returns {{ shape: number, shapeName: string, size: number } | null}
545
+ */
546
+ static #resolvePolylineMarker(fields, edge) {
547
+ const shape = SchematicPinParser.#parseFirstNumericField(fields, [
548
+ edge + 'LineShape',
549
+ edge + 'LineMarker',
550
+ edge + 'MarkerShape',
551
+ edge + 'ArrowKind'
552
+ ])
553
+
554
+ if (shape === null || shape <= 0) {
555
+ return null
556
+ }
557
+
558
+ const size =
559
+ SchematicPinParser.#parseFirstNumericField(fields, [
560
+ edge + 'LineShapeSize',
561
+ edge + 'LineMarkerSize',
562
+ edge + 'MarkerSize',
563
+ edge + 'ArrowSize'
564
+ ]) || 6
565
+
566
+ return {
567
+ shape,
568
+ shapeName:
569
+ SchematicPinParser.#resolvePolylineMarkerShapeName(shape),
570
+ size
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Parses the first present numeric field from a candidate list.
576
+ * @param {Record<string, string | string[]>} fields Source record fields.
577
+ * @param {string[]} names Candidate field names.
578
+ * @returns {number | null}
579
+ */
580
+ static #parseFirstNumericField(fields, names) {
581
+ for (const name of names) {
582
+ const value = ParserUtils.parseNumericField(fields, name)
583
+ if (value !== null) {
584
+ return value
585
+ }
586
+ }
587
+
588
+ return null
589
+ }
590
+
591
+ /**
592
+ * Maps native polyline marker codes onto stable renderer labels.
593
+ * @param {number} shape Numeric marker code.
594
+ * @returns {string}
595
+ */
596
+ static #resolvePolylineMarkerShapeName(shape) {
597
+ switch (shape) {
598
+ case 1:
599
+ return 'arrow'
600
+ case 2:
601
+ return 'filled-arrow'
602
+ case 3:
603
+ return 'circle'
604
+ case 4:
605
+ return 'square'
606
+ default:
607
+ return 'marker-' + shape
608
+ }
609
+ }
610
+
528
611
  /**
529
612
  * Collects a schematic point list, carrying forward a missing coordinate
530
613
  * axis from the preceding point when Altium omitted an unchanged value.
@@ -32,6 +32,24 @@ export class SchematicPrimitiveParser {
32
32
  )
33
33
  }
34
34
 
35
+ /**
36
+ * Returns true when one record belongs to the rounded-rectangle primitive.
37
+ * @param {Record<string, string | string[]>} fields
38
+ * @returns {boolean}
39
+ */
40
+ static isRoundedRectangleRecord(fields) {
41
+ return getField(fields, 'RECORD') === '10'
42
+ }
43
+
44
+ /**
45
+ * Returns true when one record belongs to the schematic IEEE-symbol family.
46
+ * @param {Record<string, string | string[]>} fields
47
+ * @returns {boolean}
48
+ */
49
+ static isIeeeSymbolRecord(fields) {
50
+ return getField(fields, 'RECORD') === '3'
51
+ }
52
+
35
53
  /**
36
54
  * Returns true when one point-listed primitive describes an axis-aligned
37
55
  * rectangle instead of an arbitrary polyline.
@@ -117,6 +135,215 @@ export class SchematicPrimitiveParser {
117
135
  .filter(Boolean)
118
136
  }
119
137
 
138
+ /**
139
+ * Normalizes record-5 cubic Bezier primitives.
140
+ * @param {{ fields: Record<string, string | string[]> }[]} records
141
+ * @returns {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, control1: { x: number, y: number }, control2: { x: number, y: number }, end: { x: number, y: number } }[], color: string, width: number, lineStyle: number, renderOrder: number, ownerIndex?: string }[]}
142
+ */
143
+ static parseSchematicBeziers(records) {
144
+ return records
145
+ .map((record, index) => {
146
+ const points = SchematicPrimitiveParser.#collectPolygonPoints(
147
+ record.fields
148
+ )
149
+ const segments =
150
+ SchematicPrimitiveParser.#buildBezierSegments(points)
151
+
152
+ if (!segments.length) {
153
+ return null
154
+ }
155
+
156
+ return SchematicPrimitiveParser.#stripUndefined({
157
+ points,
158
+ segments,
159
+ color: toColor(record.fields.Color, '#000000'),
160
+ width: parseNumericField(record.fields, 'LineWidth') || 1,
161
+ lineStyle:
162
+ parseNumericField(record.fields, 'LineStyle') || 0,
163
+ renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
164
+ record.fields,
165
+ index
166
+ ),
167
+ ownerIndex:
168
+ getField(record.fields, 'OwnerIndex') || undefined
169
+ })
170
+ })
171
+ .filter(Boolean)
172
+ }
173
+
174
+ /**
175
+ * Normalizes record-9 pie/wedge primitives.
176
+ * @param {{ fields: Record<string, string | string[]> }[]} records
177
+ * @returns {{ x: number, y: number, radius: number, radiusY: number, startAngle: number, endAngle: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, renderOrder: number, ownerIndex?: string }[]}
178
+ */
179
+ static parseSchematicPies(records) {
180
+ return records
181
+ .map((record, index) => {
182
+ const x = parseNumericFieldWithFraction(
183
+ record.fields,
184
+ 'Location.X'
185
+ )
186
+ const y = parseNumericFieldWithFraction(
187
+ record.fields,
188
+ 'Location.Y'
189
+ )
190
+ const radius = parseNumericFieldWithFraction(
191
+ record.fields,
192
+ 'Radius'
193
+ )
194
+ const radiusY =
195
+ parseNumericFieldWithFraction(
196
+ record.fields,
197
+ 'SecondaryRadius'
198
+ ) ?? radius
199
+
200
+ if (
201
+ x === null ||
202
+ y === null ||
203
+ radius === null ||
204
+ radius <= 0 ||
205
+ radiusY === null ||
206
+ radiusY <= 0
207
+ ) {
208
+ return null
209
+ }
210
+
211
+ return SchematicPrimitiveParser.#stripUndefined({
212
+ x,
213
+ y,
214
+ radius,
215
+ radiusY,
216
+ startAngle:
217
+ parseNumericField(record.fields, 'StartAngle') ?? 0,
218
+ endAngle:
219
+ parseNumericField(record.fields, 'EndAngle') ?? 360,
220
+ color: toColor(record.fields.Color, '#000000'),
221
+ fill: toColor(record.fields.AreaColor, '#ffffff'),
222
+ isSolid: parseBoolean(record.fields.IsSolid),
223
+ transparent: parseBoolean(record.fields.Transparent),
224
+ lineWidth:
225
+ parseNumericField(record.fields, 'LineWidth') || 1,
226
+ renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
227
+ record.fields,
228
+ index
229
+ ),
230
+ ownerIndex:
231
+ getField(record.fields, 'OwnerIndex') || undefined
232
+ })
233
+ })
234
+ .filter(Boolean)
235
+ }
236
+
237
+ /**
238
+ * Normalizes record-10 rounded rectangle primitives.
239
+ * @param {{ fields: Record<string, string | string[]> }[]} records
240
+ * @returns {{ x: number, y: number, width: number, height: number, radius: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, lineStyle: number, renderOrder: number, ownerIndex?: string, recordId?: string }[]}
241
+ */
242
+ static parseSchematicRoundedRectangles(records) {
243
+ return records
244
+ .map((record, index) => {
245
+ const x1 = parseNumericField(record.fields, 'Location.X')
246
+ const y1 = parseNumericField(record.fields, 'Location.Y')
247
+ const x2 = parseNumericField(record.fields, 'Corner.X')
248
+ const y2 = parseNumericField(record.fields, 'Corner.Y')
249
+
250
+ if (x1 === null || y1 === null || x2 === null || y2 === null) {
251
+ return null
252
+ }
253
+
254
+ const width = Math.abs(x2 - x1)
255
+ const height = Math.abs(y2 - y1)
256
+
257
+ return SchematicPrimitiveParser.#stripUndefined({
258
+ x: Math.min(x1, x2),
259
+ y: Math.min(y1, y2),
260
+ width,
261
+ height,
262
+ radius: SchematicPrimitiveParser.#resolveCornerRadius(
263
+ record.fields,
264
+ width,
265
+ height
266
+ ),
267
+ color: toColor(record.fields.Color, '#a44a1b'),
268
+ fill: toColor(record.fields.AreaColor, '#ffffff'),
269
+ isSolid: parseBoolean(record.fields.IsSolid),
270
+ transparent: parseBoolean(record.fields.Transparent),
271
+ lineWidth:
272
+ parseNumericField(record.fields, 'LineWidth') || 1,
273
+ lineStyle:
274
+ parseNumericField(record.fields, 'LineStyle') || 0,
275
+ renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
276
+ record.fields,
277
+ index
278
+ ),
279
+ ownerIndex:
280
+ getField(record.fields, 'OwnerIndex') || undefined,
281
+ recordId:
282
+ getField(record.fields, 'UniqueID') ||
283
+ getField(record.fields, 'UniqueId') ||
284
+ undefined
285
+ })
286
+ })
287
+ .filter(Boolean)
288
+ }
289
+
290
+ /**
291
+ * Normalizes record-3 IEEE-style schematic symbol primitives.
292
+ * @param {{ fields: Record<string, string | string[]> }[]} records
293
+ * @returns {{ x: number, y: number, symbol: number, symbolName: string, size: number, color: string, lineWidth: number, renderOrder: number, ownerIndex?: string, recordId?: string }[]}
294
+ */
295
+ static parseSchematicIeeeSymbols(records) {
296
+ return records
297
+ .map((record, index) => {
298
+ const x = parseNumericFieldWithFraction(
299
+ record.fields,
300
+ 'Location.X'
301
+ )
302
+ const y = parseNumericFieldWithFraction(
303
+ record.fields,
304
+ 'Location.Y'
305
+ )
306
+
307
+ if (x === null || y === null) {
308
+ return null
309
+ }
310
+
311
+ const symbol =
312
+ parseNumericField(record.fields, 'Symbol') ??
313
+ parseNumericField(record.fields, 'SymbolKind') ??
314
+ parseNumericField(record.fields, 'IeeeSymbol') ??
315
+ 0
316
+
317
+ return SchematicPrimitiveParser.#stripUndefined({
318
+ x,
319
+ y,
320
+ symbol,
321
+ symbolName:
322
+ SchematicPrimitiveParser.#ieeeSymbolName(symbol),
323
+ size: Math.max(
324
+ parseNumericField(record.fields, 'Size') ||
325
+ parseNumericField(record.fields, 'Radius') ||
326
+ 12,
327
+ 1
328
+ ),
329
+ color: toColor(record.fields.Color, '#000000'),
330
+ lineWidth:
331
+ parseNumericField(record.fields, 'LineWidth') || 1,
332
+ renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
333
+ record.fields,
334
+ index
335
+ ),
336
+ ownerIndex:
337
+ getField(record.fields, 'OwnerIndex') || undefined,
338
+ recordId:
339
+ getField(record.fields, 'UniqueID') ||
340
+ getField(record.fields, 'UniqueId') ||
341
+ undefined
342
+ })
343
+ })
344
+ .filter(Boolean)
345
+ }
346
+
120
347
  /**
121
348
  * Normalizes record-14 body primitives into drawable rectangles.
122
349
  * @param {{ fields: Record<string, string | string[]> }[]} records
@@ -429,6 +656,80 @@ export class SchematicPrimitiveParser {
429
656
  return fallbackOrder
430
657
  }
431
658
 
659
+ /**
660
+ * Builds cubic Bezier spans from Altium's `4 + 3n` point sequence.
661
+ * @param {{ x: number, y: number }[]} points Source control points.
662
+ * @returns {{ start: { x: number, y: number }, control1: { x: number, y: number }, control2: { x: number, y: number }, end: { x: number, y: number } }[]}
663
+ */
664
+ static #buildBezierSegments(points) {
665
+ if (!Array.isArray(points) || points.length < 4) {
666
+ return []
667
+ }
668
+
669
+ const segments = []
670
+ for (let index = 0; index + 3 < points.length; index += 3) {
671
+ segments.push({
672
+ start: points[index],
673
+ control1: points[index + 1],
674
+ control2: points[index + 2],
675
+ end: points[index + 3]
676
+ })
677
+ }
678
+
679
+ return segments
680
+ }
681
+
682
+ /**
683
+ * Removes undefined fields from a primitive object.
684
+ * @param {object} value Source object.
685
+ * @returns {object}
686
+ */
687
+ static #stripUndefined(value) {
688
+ return Object.fromEntries(
689
+ Object.entries(value || {}).filter(
690
+ ([, entryValue]) => entryValue !== undefined
691
+ )
692
+ )
693
+ }
694
+
695
+ /**
696
+ * Resolves a stable rounded-rectangle corner radius.
697
+ * @param {Record<string, string | string[]>} fields Source fields.
698
+ * @param {number} width Width in schematic units.
699
+ * @param {number} height Height in schematic units.
700
+ * @returns {number}
701
+ */
702
+ static #resolveCornerRadius(fields, width, height) {
703
+ const radius =
704
+ parseNumericField(fields, 'CornerRadius') ??
705
+ parseNumericField(fields, 'Radius') ??
706
+ parseNumericField(fields, 'CornerRadius.X') ??
707
+ Math.min(width, height) / 5
708
+
709
+ return Math.max(Math.min(radius, width / 2, height / 2), 0)
710
+ }
711
+
712
+ /**
713
+ * Maps native IEEE symbol codes to stable display names.
714
+ * @param {number} symbol Native symbol code.
715
+ * @returns {string}
716
+ */
717
+ static #ieeeSymbolName(symbol) {
718
+ return (
719
+ {
720
+ 1: 'and',
721
+ 2: 'or',
722
+ 3: 'xor',
723
+ 4: 'inverter',
724
+ 5: 'buffer',
725
+ 6: 'clock',
726
+ 7: 'schmitt',
727
+ 8: 'open-collector',
728
+ 9: 'open-emitter'
729
+ }[Number(symbol)] || 'unknown'
730
+ )
731
+ }
732
+
432
733
  /**
433
734
  * Builds one stable geometry-key queue for rectangle source metadata.
434
735
  * @param {{ fields: Record<string, string | string[]> }[]} records