altium-toolkit 0.1.0

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 (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,716 @@
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 {
8
+ getField,
9
+ parseBoolean,
10
+ parseNumericField,
11
+ parseNumericFieldWithFraction,
12
+ toColor
13
+ } = ParserUtils
14
+
15
+ /**
16
+ * Normalizes schematic drawing primitives that are not plain line segments.
17
+ */
18
+ export class SchematicPrimitiveParser {
19
+ /**
20
+ * Returns true when one record belongs to the rectangle primitive family.
21
+ * Some record-225 frames store only `Location`/`Corner` in printable runs.
22
+ * @param {Record<string, string | string[]>} fields
23
+ * @returns {boolean}
24
+ */
25
+ static isRectangleRecord(fields) {
26
+ const recordType = getField(fields, 'RECORD')
27
+
28
+ return (
29
+ recordType === '14' ||
30
+ recordType === '225' ||
31
+ SchematicPrimitiveParser.isListedRectangleRecord(fields)
32
+ )
33
+ }
34
+
35
+ /**
36
+ * Returns true when one point-listed primitive describes an axis-aligned
37
+ * rectangle instead of an arbitrary polyline.
38
+ * @param {Record<string, string | string[]>} fields
39
+ * @returns {boolean}
40
+ */
41
+ static isListedRectangleRecord(fields) {
42
+ const locationX = parseNumericField(fields, 'Location.X')
43
+ const locationY = parseNumericField(fields, 'Location.Y')
44
+ const cornerX = parseNumericField(fields, 'Corner.X')
45
+ const cornerY = parseNumericField(fields, 'Corner.Y')
46
+ const points = SchematicPrimitiveParser.#collectPolygonPoints(fields)
47
+
48
+ if (
49
+ locationX === null ||
50
+ locationY === null ||
51
+ cornerX === null ||
52
+ cornerY === null ||
53
+ points.length !== 4
54
+ ) {
55
+ return false
56
+ }
57
+
58
+ const xs = [...new Set(points.map((point) => point.x))]
59
+ const ys = [...new Set(points.map((point) => point.y))]
60
+
61
+ if (xs.length !== 2 || ys.length !== 2) {
62
+ return false
63
+ }
64
+
65
+ const minX = Math.min(...xs)
66
+ const maxX = Math.max(...xs)
67
+ const minY = Math.min(...ys)
68
+ const maxY = Math.max(...ys)
69
+ const corners = new Set([
70
+ minX + ':' + minY,
71
+ minX + ':' + maxY,
72
+ maxX + ':' + minY,
73
+ maxX + ':' + maxY
74
+ ])
75
+
76
+ return (
77
+ corners.has(locationX + ':' + locationY) &&
78
+ corners.has(cornerX + ':' + cornerY) &&
79
+ points.every((point) => corners.has(point.x + ':' + point.y))
80
+ )
81
+ }
82
+
83
+ /**
84
+ * Normalizes record-7 polygon primitives into fill-capable polygons.
85
+ * @param {{ fields: Record<string, string | string[]> }[]} records
86
+ * @returns {{ points: { x: number, y: number }[], color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string }[]}
87
+ */
88
+ static parseSchematicPolygons(records) {
89
+ return records
90
+ .map((record, index) => {
91
+ const points = SchematicPrimitiveParser.#collectPolygonPoints(
92
+ record.fields
93
+ )
94
+
95
+ if (points.length < 2) {
96
+ return null
97
+ }
98
+
99
+ return {
100
+ points,
101
+ color: toColor(record.fields.Color, '#a44a1b'),
102
+ fill: toColor(record.fields.AreaColor, '#ffe16f'),
103
+ isSolid: parseBoolean(record.fields.IsSolid),
104
+ transparent: parseBoolean(record.fields.Transparent),
105
+ lineWidth:
106
+ parseNumericField(record.fields, 'LineWidth') || 1,
107
+ renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
108
+ record.fields,
109
+ index
110
+ ),
111
+ ownerIndex:
112
+ getField(record.fields, 'OwnerIndex') || undefined
113
+ }
114
+ })
115
+ .filter(Boolean)
116
+ }
117
+
118
+ /**
119
+ * Normalizes record-14 body primitives into drawable rectangles.
120
+ * @param {{ fields: Record<string, string | string[]> }[]} records
121
+ * @returns {{ x: number, y: number, width: number, height: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, lineStyle: number, ownerIndex?: string }[]}
122
+ */
123
+ static parseSchematicRectangles(records) {
124
+ return records
125
+ .map((record, index) => {
126
+ const x1 = parseNumericField(record.fields, 'Location.X')
127
+ const y1 = parseNumericField(record.fields, 'Location.Y')
128
+ const x2 = parseNumericField(record.fields, 'Corner.X')
129
+ const y2 = parseNumericField(record.fields, 'Corner.Y')
130
+ const isRectangleRecord =
131
+ SchematicPrimitiveParser.isRectangleRecord(record.fields)
132
+ const isListedRectangle =
133
+ SchematicPrimitiveParser.isListedRectangleRecord(
134
+ record.fields
135
+ )
136
+ const usesFrameFallback =
137
+ SchematicPrimitiveParser.#shouldUseFrameFallback(
138
+ record.fields,
139
+ isListedRectangle
140
+ )
141
+ const recordType = getField(record.fields, 'RECORD')
142
+
143
+ if (x1 === null || y1 === null || x2 === null || y2 === null) {
144
+ return null
145
+ }
146
+
147
+ return {
148
+ x: Math.min(x1, x2),
149
+ y: Math.min(y1, y2),
150
+ width: Math.abs(x2 - x1),
151
+ height: Math.abs(y2 - y1),
152
+ color: usesFrameFallback
153
+ ? '#ff0000'
154
+ : toColor(
155
+ record.fields.Color,
156
+ recordType === '225' ? '#ff0000' : '#a44a1b'
157
+ ),
158
+ fill: usesFrameFallback
159
+ ? '#ffffff'
160
+ : toColor(
161
+ record.fields.AreaColor,
162
+ recordType === '225' ? '#ffffff' : '#ffe16f'
163
+ ),
164
+ isSolid:
165
+ parseBoolean(record.fields.IsSolid) ||
166
+ usesFrameFallback ||
167
+ SchematicPrimitiveParser.#hasImplicitAreaFill(
168
+ record.fields,
169
+ isRectangleRecord
170
+ ),
171
+ transparent: usesFrameFallback
172
+ ? false
173
+ : parseBoolean(record.fields.Transparent),
174
+ lineWidth:
175
+ parseNumericField(record.fields, 'LineWidth') || 1,
176
+ lineStyle: usesFrameFallback
177
+ ? 1
178
+ : parseNumericField(record.fields, 'LineStyle') || 0,
179
+ renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
180
+ record.fields,
181
+ index
182
+ ),
183
+ ownerIndex:
184
+ getField(record.fields, 'OwnerIndex') || undefined
185
+ }
186
+ })
187
+ .filter(Boolean)
188
+ }
189
+
190
+ /**
191
+ * Infers paint order for solid owner body rectangles whose printable
192
+ * record lost IndexInSheet. Those bodies should stay behind their owner's
193
+ * indexed contact/detail primitives rather than inheriting unrelated
194
+ * global rectangle-list offsets from elsewhere on the sheet.
195
+ * @param {{ fields: Record<string, string | string[]> }[]} rectangleRecords
196
+ * @param {{ x: number, y: number, width: number, height: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, lineStyle: number, renderOrder: number, ownerIndex?: string }[]} rectangles
197
+ * @param {{ x1: number, y1: number, x2: number, y2: number, renderOrder?: number, ownerIndex?: string }[]} lines
198
+ * @param {{ points: { x: number, y: number }[], renderOrder?: number, ownerIndex?: string }[]} polygons
199
+ * @param {{ x: number, y: number, radiusX: number, radiusY: number, renderOrder?: number, ownerIndex?: string }[]} ellipses
200
+ * @param {{ x: number, y: number, radius: number, radiusY?: number, renderOrder?: number, ownerIndex?: string }[]} arcs
201
+ * @returns {{ x: number, y: number, width: number, height: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, lineStyle: number, renderOrder: number, ownerIndex?: string }[]}
202
+ */
203
+ static inferMissingOwnerRectangleRenderOrders(
204
+ rectangleRecords,
205
+ rectangles,
206
+ lines,
207
+ polygons,
208
+ ellipses,
209
+ arcs
210
+ ) {
211
+ const rectangleMetaQueues =
212
+ SchematicPrimitiveParser.#buildRectangleRecordMetaQueues(
213
+ rectangleRecords
214
+ )
215
+ const normalizedRectangles = rectangles.map((rectangle) => ({
216
+ rectangle,
217
+ hasExplicitOrder:
218
+ SchematicPrimitiveParser.#shiftRectangleMeta(
219
+ rectangleMetaQueues,
220
+ rectangle
221
+ )?.hasExplicitOrder || false
222
+ }))
223
+ const ownerGeometryItems =
224
+ SchematicPrimitiveParser.#buildOwnerGeometryItems(
225
+ normalizedRectangles,
226
+ lines,
227
+ polygons,
228
+ ellipses,
229
+ arcs
230
+ )
231
+
232
+ return normalizedRectangles.map(({ rectangle, hasExplicitOrder }) => {
233
+ if (
234
+ hasExplicitOrder ||
235
+ !rectangle.ownerIndex ||
236
+ rectangle.isSolid !== true
237
+ ) {
238
+ return rectangle
239
+ }
240
+
241
+ const inferredRenderOrder =
242
+ SchematicPrimitiveParser.#inferMissingOwnerRectangleRenderOrder(
243
+ rectangle,
244
+ ownerGeometryItems.get(String(rectangle.ownerIndex)) || []
245
+ )
246
+
247
+ if (inferredRenderOrder === null) {
248
+ return rectangle
249
+ }
250
+
251
+ return {
252
+ ...rectangle,
253
+ renderOrder: inferredRenderOrder
254
+ }
255
+ })
256
+ }
257
+
258
+ /**
259
+ * Normalizes authored sheet overlay regions into rectangular overlays.
260
+ * @param {{ fields: Record<string, string | string[]> }[]} records
261
+ * @returns {{ x: number, y: number, width: number, height: number, color: string, fill: string, renderOrder: number }[]}
262
+ */
263
+ static parseSchematicRegions(records) {
264
+ return records
265
+ .map((record, index) => {
266
+ const x1 = parseNumericField(record.fields, 'Location.X')
267
+ const y1 = parseNumericField(record.fields, 'Location.Y')
268
+ const x2 = parseNumericField(record.fields, 'Corner.X')
269
+ const y2 = parseNumericField(record.fields, 'Corner.Y')
270
+
271
+ if (x1 === null || y1 === null || x2 === null || y2 === null) {
272
+ return null
273
+ }
274
+
275
+ return {
276
+ x: Math.min(x1, x2),
277
+ y: Math.min(y1, y2),
278
+ width: Math.abs(x2 - x1),
279
+ height: Math.abs(y2 - y1),
280
+ color: toColor(record.fields.Color, '#ff0000'),
281
+ fill: toColor(record.fields.AreaColor, '#ffffcf'),
282
+ renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
283
+ record.fields,
284
+ index
285
+ )
286
+ }
287
+ })
288
+ .filter(Boolean)
289
+ }
290
+
291
+ /**
292
+ * Normalizes record-11/12 curve primitives into drawable arcs.
293
+ * Record 11 carries an optional secondary radius for ellipse segments.
294
+ * @param {{ fields: Record<string, string | string[]> }[]} records
295
+ * @returns {{ x: number, y: number, radius: number, radiusY?: number, startAngle: number, endAngle: number, color: string, width: number, ownerIndex?: string }[]}
296
+ */
297
+ static parseSchematicArcs(records) {
298
+ return records
299
+ .map((record, index) => {
300
+ const x = parseNumericFieldWithFraction(
301
+ record.fields,
302
+ 'Location.X'
303
+ )
304
+ const y = parseNumericFieldWithFraction(
305
+ record.fields,
306
+ 'Location.Y'
307
+ )
308
+ const radius = parseNumericFieldWithFraction(
309
+ record.fields,
310
+ 'Radius'
311
+ )
312
+ const radiusY = parseNumericFieldWithFraction(
313
+ record.fields,
314
+ 'SecondaryRadius'
315
+ )
316
+ const startAngle = parseNumericField(
317
+ record.fields,
318
+ 'StartAngle'
319
+ )
320
+ const endAngle = parseNumericField(record.fields, 'EndAngle')
321
+ const normalizedRadiusY = radiusY === null ? radius : radiusY
322
+
323
+ if (
324
+ x === null ||
325
+ y === null ||
326
+ radius === null ||
327
+ radius <= 0 ||
328
+ normalizedRadiusY === null ||
329
+ normalizedRadiusY <= 0
330
+ ) {
331
+ return null
332
+ }
333
+
334
+ return {
335
+ x,
336
+ y,
337
+ radius,
338
+ ...(getField(record.fields, 'RECORD') === '11'
339
+ ? { radiusY: normalizedRadiusY }
340
+ : {}),
341
+ startAngle: startAngle === null ? 0 : startAngle,
342
+ endAngle: endAngle === null ? 360 : endAngle,
343
+ color: toColor(record.fields.Color, '#a44a1b'),
344
+ width: parseNumericField(record.fields, 'LineWidth') || 1,
345
+ renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
346
+ record.fields,
347
+ index
348
+ ),
349
+ ownerIndex:
350
+ getField(record.fields, 'OwnerIndex') || undefined
351
+ }
352
+ })
353
+ .filter(Boolean)
354
+ }
355
+
356
+ /**
357
+ * Normalizes record-8 ellipse primitives into drawable outlines.
358
+ * @param {{ fields: Record<string, string | string[]> }[]} records
359
+ * @returns {{ x: number, y: number, radiusX: number, radiusY: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string }[]}
360
+ */
361
+ static parseSchematicEllipses(records) {
362
+ return records
363
+ .map((record, index) => {
364
+ const x = parseNumericFieldWithFraction(
365
+ record.fields,
366
+ 'Location.X'
367
+ )
368
+ const y = parseNumericFieldWithFraction(
369
+ record.fields,
370
+ 'Location.Y'
371
+ )
372
+ const radiusX = parseNumericFieldWithFraction(
373
+ record.fields,
374
+ 'Radius'
375
+ )
376
+ const radiusY = parseNumericFieldWithFraction(
377
+ record.fields,
378
+ 'SecondaryRadius'
379
+ )
380
+
381
+ if (
382
+ x === null ||
383
+ y === null ||
384
+ radiusX === null ||
385
+ radiusX <= 0 ||
386
+ radiusY === null ||
387
+ radiusY <= 0
388
+ ) {
389
+ return null
390
+ }
391
+
392
+ return {
393
+ x,
394
+ y,
395
+ radiusX,
396
+ radiusY,
397
+ color: toColor(record.fields.Color, '#a44a1b'),
398
+ fill: toColor(record.fields.AreaColor, '#ffffff'),
399
+ isSolid: parseBoolean(record.fields.IsSolid),
400
+ transparent: parseBoolean(record.fields.Transparent),
401
+ lineWidth:
402
+ parseNumericField(record.fields, 'LineWidth') || 1,
403
+ renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
404
+ record.fields,
405
+ index
406
+ ),
407
+ ownerIndex:
408
+ getField(record.fields, 'OwnerIndex') || undefined
409
+ }
410
+ })
411
+ .filter(Boolean)
412
+ }
413
+
414
+ /**
415
+ * Resolves one stable render-order key from Altium sheet order metadata.
416
+ * @param {Record<string, string | string[]>} fields
417
+ * @param {number} fallbackOrder
418
+ * @returns {number}
419
+ */
420
+ static #resolveRenderOrder(fields, fallbackOrder) {
421
+ const indexInSheet = parseNumericField(fields, 'IndexInSheet')
422
+
423
+ if (indexInSheet !== null) {
424
+ return indexInSheet
425
+ }
426
+
427
+ return fallbackOrder
428
+ }
429
+
430
+ /**
431
+ * Builds one stable geometry-key queue for rectangle source metadata.
432
+ * @param {{ fields: Record<string, string | string[]> }[]} records
433
+ * @returns {Map<string, { hasExplicitOrder: boolean }[]>}
434
+ */
435
+ static #buildRectangleRecordMetaQueues(records) {
436
+ const queues = new Map()
437
+
438
+ for (const record of records) {
439
+ const x1 = parseNumericField(record.fields, 'Location.X')
440
+ const y1 = parseNumericField(record.fields, 'Location.Y')
441
+ const x2 = parseNumericField(record.fields, 'Corner.X')
442
+ const y2 = parseNumericField(record.fields, 'Corner.Y')
443
+
444
+ if (x1 === null || y1 === null || x2 === null || y2 === null) {
445
+ continue
446
+ }
447
+
448
+ const key = SchematicPrimitiveParser.#buildRectangleGeometryKey({
449
+ ownerIndex: getField(record.fields, 'OwnerIndex') || undefined,
450
+ x: Math.min(x1, x2),
451
+ y: Math.min(y1, y2),
452
+ width: Math.abs(x2 - x1),
453
+ height: Math.abs(y2 - y1)
454
+ })
455
+
456
+ if (!queues.has(key)) {
457
+ queues.set(key, [])
458
+ }
459
+
460
+ queues.get(key).push({
461
+ hasExplicitOrder:
462
+ parseNumericField(record.fields, 'IndexInSheet') !== null
463
+ })
464
+ }
465
+
466
+ return queues
467
+ }
468
+
469
+ /**
470
+ * Consumes one rectangle source-metadata queue entry for a normalized body.
471
+ * @param {Map<string, { hasExplicitOrder: boolean }[]>} queues
472
+ * @param {{ ownerIndex?: string, x: number, y: number, width: number, height: number }} rectangle
473
+ * @returns {{ hasExplicitOrder: boolean } | null}
474
+ */
475
+ static #shiftRectangleMeta(queues, rectangle) {
476
+ const key =
477
+ SchematicPrimitiveParser.#buildRectangleGeometryKey(rectangle)
478
+ const queue = queues.get(key)
479
+
480
+ if (!queue?.length) {
481
+ return null
482
+ }
483
+
484
+ return queue.shift() || null
485
+ }
486
+
487
+ /**
488
+ * Builds one geometry key that stays stable across raw and normalized
489
+ * rectangle representations.
490
+ * @param {{ ownerIndex?: string, x: number, y: number, width: number, height: number }} rectangle
491
+ * @returns {string}
492
+ */
493
+ static #buildRectangleGeometryKey(rectangle) {
494
+ return [
495
+ String(rectangle.ownerIndex || ''),
496
+ Number(rectangle.x),
497
+ Number(rectangle.y),
498
+ Number(rectangle.width),
499
+ Number(rectangle.height)
500
+ ].join(':')
501
+ }
502
+
503
+ /**
504
+ * Collects owner-geometry bounds that can help infer one missing owner
505
+ * body render order.
506
+ * @param {{ rectangle: { ownerIndex?: string, x: number, y: number, width: number, height: number, renderOrder: number }, hasExplicitOrder: boolean }[]} rectangles
507
+ * @param {{ x1: number, y1: number, x2: number, y2: number, renderOrder?: number, ownerIndex?: string }[]} lines
508
+ * @param {{ points: { x: number, y: number }[], renderOrder?: number, ownerIndex?: string }[]} polygons
509
+ * @param {{ x: number, y: number, radiusX: number, radiusY: number, renderOrder?: number, ownerIndex?: string }[]} ellipses
510
+ * @param {{ x: number, y: number, radius: number, radiusY?: number, renderOrder?: number, ownerIndex?: string }[]} arcs
511
+ * @returns {Map<string, { renderOrder: number, minX: number, maxX: number, minY: number, maxY: number }[]>}
512
+ */
513
+ static #buildOwnerGeometryItems(
514
+ rectangles,
515
+ lines,
516
+ polygons,
517
+ ellipses,
518
+ arcs
519
+ ) {
520
+ const ownerItems = new Map()
521
+
522
+ for (const { rectangle, hasExplicitOrder } of rectangles) {
523
+ if (!rectangle.ownerIndex || !hasExplicitOrder) {
524
+ continue
525
+ }
526
+
527
+ SchematicPrimitiveParser.#pushOwnerGeometryItem(ownerItems, {
528
+ ownerIndex: String(rectangle.ownerIndex),
529
+ renderOrder: Number(rectangle.renderOrder),
530
+ minX: rectangle.x,
531
+ maxX: rectangle.x + rectangle.width,
532
+ minY: rectangle.y,
533
+ maxY: rectangle.y + rectangle.height
534
+ })
535
+ }
536
+
537
+ for (const line of lines) {
538
+ if (!line.ownerIndex) {
539
+ continue
540
+ }
541
+
542
+ SchematicPrimitiveParser.#pushOwnerGeometryItem(ownerItems, {
543
+ ownerIndex: String(line.ownerIndex),
544
+ renderOrder: Number(line.renderOrder),
545
+ minX: Math.min(Number(line.x1), Number(line.x2)),
546
+ maxX: Math.max(Number(line.x1), Number(line.x2)),
547
+ minY: Math.min(Number(line.y1), Number(line.y2)),
548
+ maxY: Math.max(Number(line.y1), Number(line.y2))
549
+ })
550
+ }
551
+
552
+ for (const polygon of polygons) {
553
+ if (!polygon.ownerIndex || !polygon.points?.length) {
554
+ continue
555
+ }
556
+
557
+ const xs = polygon.points.map((point) => Number(point.x))
558
+ const ys = polygon.points.map((point) => Number(point.y))
559
+
560
+ SchematicPrimitiveParser.#pushOwnerGeometryItem(ownerItems, {
561
+ ownerIndex: String(polygon.ownerIndex),
562
+ renderOrder: Number(polygon.renderOrder),
563
+ minX: Math.min(...xs),
564
+ maxX: Math.max(...xs),
565
+ minY: Math.min(...ys),
566
+ maxY: Math.max(...ys)
567
+ })
568
+ }
569
+
570
+ for (const ellipse of ellipses) {
571
+ if (!ellipse.ownerIndex) {
572
+ continue
573
+ }
574
+
575
+ SchematicPrimitiveParser.#pushOwnerGeometryItem(ownerItems, {
576
+ ownerIndex: String(ellipse.ownerIndex),
577
+ renderOrder: Number(ellipse.renderOrder),
578
+ minX: Number(ellipse.x) - Number(ellipse.radiusX),
579
+ maxX: Number(ellipse.x) + Number(ellipse.radiusX),
580
+ minY: Number(ellipse.y) - Number(ellipse.radiusY),
581
+ maxY: Number(ellipse.y) + Number(ellipse.radiusY)
582
+ })
583
+ }
584
+
585
+ for (const arc of arcs) {
586
+ if (!arc.ownerIndex) {
587
+ continue
588
+ }
589
+
590
+ const radiusY = Number(arc.radiusY || arc.radius)
591
+
592
+ SchematicPrimitiveParser.#pushOwnerGeometryItem(ownerItems, {
593
+ ownerIndex: String(arc.ownerIndex),
594
+ renderOrder: Number(arc.renderOrder),
595
+ minX: Number(arc.x) - Number(arc.radius),
596
+ maxX: Number(arc.x) + Number(arc.radius),
597
+ minY: Number(arc.y) - radiusY,
598
+ maxY: Number(arc.y) + radiusY
599
+ })
600
+ }
601
+
602
+ return ownerItems
603
+ }
604
+
605
+ /**
606
+ * Stores one owner-geometry candidate for missing-body order inference.
607
+ * @param {Map<string, { renderOrder: number, minX: number, maxX: number, minY: number, maxY: number }[]>} ownerItems
608
+ * @param {{ ownerIndex: string, renderOrder: number, minX: number, maxX: number, minY: number, maxY: number }} item
609
+ * @returns {void}
610
+ */
611
+ static #pushOwnerGeometryItem(ownerItems, item) {
612
+ if (!Number.isFinite(item.renderOrder)) {
613
+ return
614
+ }
615
+
616
+ if (!ownerItems.has(item.ownerIndex)) {
617
+ ownerItems.set(item.ownerIndex, [])
618
+ }
619
+
620
+ ownerItems.get(item.ownerIndex).push({
621
+ ...item
622
+ })
623
+ }
624
+
625
+ /**
626
+ * Infers one missing owner-body render order from contained indexed
627
+ * geometry. Missing-order bodies should sit just behind the earliest
628
+ * indexed sibling primitive contained inside the same owner body.
629
+ * @param {{ x: number, y: number, width: number, height: number, renderOrder: number }} rectangle
630
+ * @param {{ renderOrder: number, minX: number, maxX: number, minY: number, maxY: number }[]} ownerItems
631
+ * @returns {number | null}
632
+ */
633
+ static #inferMissingOwnerRectangleRenderOrder(rectangle, ownerItems) {
634
+ const containedItems = ownerItems.filter(
635
+ (item) =>
636
+ item.minX >= rectangle.x &&
637
+ item.maxX <= rectangle.x + rectangle.width &&
638
+ item.minY >= rectangle.y &&
639
+ item.maxY <= rectangle.y + rectangle.height
640
+ )
641
+
642
+ if (!containedItems.length) {
643
+ return null
644
+ }
645
+
646
+ const earliestContainedRenderOrder = Math.min(
647
+ ...containedItems.map((item) => Number(item.renderOrder))
648
+ )
649
+
650
+ return Number.isFinite(earliestContainedRenderOrder)
651
+ ? earliestContainedRenderOrder - 0.5
652
+ : null
653
+ }
654
+
655
+ /**
656
+ * Returns true when one closed rectangle-like record carries a visible
657
+ * area color even without an explicit `IsSolid=T` flag.
658
+ * @param {Record<string, string | string[]>} fields
659
+ * @param {boolean} isRectangleRecord
660
+ * @returns {boolean}
661
+ */
662
+ static #hasImplicitAreaFill(fields, isRectangleRecord) {
663
+ return (
664
+ isRectangleRecord &&
665
+ !parseBoolean(fields.Transparent) &&
666
+ getField(fields, 'AreaColor') !== ''
667
+ )
668
+ }
669
+
670
+ /**
671
+ * Returns true when one record-225 frame lost its printable style fields
672
+ * and therefore needs the authored dashed white-box defaults restored.
673
+ * @param {Record<string, string | string[]>} fields
674
+ * @param {boolean} isListedRectangle
675
+ * @returns {boolean}
676
+ */
677
+ static #shouldUseFrameFallback(fields, isListedRectangle) {
678
+ if (getField(fields, 'RECORD') !== '225' || isListedRectangle) {
679
+ return false
680
+ }
681
+
682
+ return (
683
+ getField(fields, 'AreaColor') === '' ||
684
+ getField(fields, 'LineStyle') === '' ||
685
+ !/^-?\d+$/.test(getField(fields, 'Color'))
686
+ )
687
+ }
688
+
689
+ /**
690
+ * Collects one record-7 polygon point list in source order.
691
+ * @param {Record<string, string | string[]>} fields
692
+ * @returns {{ x: number, y: number }[]}
693
+ */
694
+ static #collectPolygonPoints(fields) {
695
+ const locationCount = parseNumericField(fields, 'LocationCount')
696
+
697
+ if (locationCount === null || locationCount < 2) {
698
+ return []
699
+ }
700
+
701
+ const points = []
702
+
703
+ for (let index = 1; index <= locationCount; index += 1) {
704
+ const x = parseNumericField(fields, 'X' + index)
705
+ const y = parseNumericField(fields, 'Y' + index)
706
+
707
+ if (x === null || y === null) {
708
+ break
709
+ }
710
+
711
+ points.push({ x, y })
712
+ }
713
+
714
+ return points
715
+ }
716
+ }