circuitjson-toolkit 1.0.10 → 1.0.16

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 (40) hide show
  1. package/AGENTS.md +5 -3
  2. package/docs/model-format.md +15 -0
  3. package/package.json +3 -2
  4. package/src/core/CircuitJsonBomBuilder.mjs +22 -25
  5. package/src/core/CircuitJsonElementValidator.mjs +233 -16
  6. package/src/core/CircuitJsonIndexer.mjs +510 -5
  7. package/src/core/CircuitJsonManufacturingBuilder.mjs +488 -16
  8. package/src/core/CircuitJsonManufacturingDownloadBuilder.mjs +196 -0
  9. package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +329 -0
  10. package/src/core/CircuitJsonPcbCopperGeometry.mjs +503 -0
  11. package/src/core/CircuitJsonPcbDrawingStyle.mjs +88 -0
  12. package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +172 -0
  13. package/src/core/CircuitJsonPcbNetMetadata.mjs +247 -0
  14. package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +70 -0
  15. package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +992 -0
  16. package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +872 -0
  17. package/src/core/CircuitJsonPcbPrimitiveFields.mjs +233 -0
  18. package/src/core/CircuitJsonPcbPrimitiveGeometry.mjs +142 -0
  19. package/src/core/CircuitJsonPcbPrimitiveGroups.mjs +305 -0
  20. package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +65 -0
  21. package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +895 -0
  22. package/src/core/CircuitJsonPcbTraceLengthModel.mjs +257 -0
  23. package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +683 -0
  24. package/src/core/CircuitJsonSourceMetadata.mjs +233 -0
  25. package/src/core/CircuitJsonSupportMatrixBuilder.mjs +227 -5
  26. package/src/core/PcbBoundsSelectionModel.mjs +250 -0
  27. package/src/core/PcbCandidateSelectionModel.mjs +77 -0
  28. package/src/core/PcbDiagnosticFocusModel.mjs +423 -0
  29. package/src/core/PcbInteractionPrimitiveModel.mjs +560 -0
  30. package/src/core/SelectedPartCircuitJsonExportAdapter.mjs +335 -0
  31. package/src/index.mjs +2 -0
  32. package/src/renderers.mjs +29 -0
  33. package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +128 -0
  34. package/src/ui/CircuitJsonPcbSvgRenderer.mjs +964 -0
  35. package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +168 -0
  36. package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +138 -0
  37. package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +114 -0
  38. package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +130 -0
  39. package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +994 -0
  40. package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +439 -0
@@ -0,0 +1,992 @@
1
+ import { CircuitJsonIndexer } from './CircuitJsonIndexer.mjs'
2
+ import { CircuitJsonUnits } from './CircuitJsonUnits.mjs'
3
+ import { CircuitJsonPcbPrimitiveGeometry } from './CircuitJsonPcbPrimitiveGeometry.mjs'
4
+ import { CircuitJsonPcbPrimitiveFields } from './CircuitJsonPcbPrimitiveFields.mjs'
5
+ import { CircuitJsonPcbTraceLengthModel } from './CircuitJsonPcbTraceLengthModel.mjs'
6
+ import { CircuitJsonPcbDrawingStyle } from './CircuitJsonPcbDrawingStyle.mjs'
7
+
8
+ /**
9
+ * Builds derived and documentation PCB primitives from CircuitJSON rows.
10
+ */
11
+ export class CircuitJsonPcbPrimitiveArtwork {
12
+ /**
13
+ * Builds supplemental primitive rows.
14
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
15
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
16
+ * @returns {object[]}
17
+ */
18
+ static build(index, componentsByPcbId) {
19
+ return [
20
+ ...CircuitJsonPcbPrimitiveArtwork.#solderMaskPrimitives(
21
+ index,
22
+ componentsByPcbId
23
+ ),
24
+ ...CircuitJsonPcbPrimitiveArtwork.#solderPastePrimitives(
25
+ index,
26
+ componentsByPcbId
27
+ ),
28
+ ...CircuitJsonPcbPrimitiveArtwork.#notePrimitives(
29
+ index,
30
+ componentsByPcbId
31
+ ),
32
+ ...CircuitJsonPcbPrimitiveArtwork.#silkscreenShapePrimitives(
33
+ index,
34
+ componentsByPcbId
35
+ ),
36
+ ...CircuitJsonPcbPrimitiveArtwork.#thermalSpokePrimitives(index),
37
+ ...CircuitJsonPcbPrimitiveArtwork.#routeHintPrimitives(index),
38
+ ...CircuitJsonPcbPrimitiveArtwork.#breakoutPointPrimitives(index),
39
+ ...CircuitJsonPcbPrimitiveArtwork.#panelPrimitives(index)
40
+ ].filter(Boolean)
41
+ }
42
+
43
+ /**
44
+ * Builds board detail primitives.
45
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
46
+ * @returns {object[]}
47
+ */
48
+ static cutoutPrimitives(index) {
49
+ return [
50
+ ['pcb_cutout', 'cutout'],
51
+ ['pcb_board_cutout', 'cutout'],
52
+ ['pcb_keepout', 'keepout'],
53
+ ['pcb_courtyard', 'courtyard'],
54
+ ['pcb_courtyard_circle', 'courtyard'],
55
+ ['pcb_courtyard_line', 'courtyard'],
56
+ ['pcb_courtyard_outline', 'courtyard'],
57
+ ['pcb_courtyard_path', 'courtyard'],
58
+ ['pcb_courtyard_rect', 'courtyard']
59
+ ]
60
+ .flatMap(([type, kind]) =>
61
+ CircuitJsonPcbPrimitiveArtwork.#all(index, type).flatMap(
62
+ (element) =>
63
+ CircuitJsonPcbPrimitiveArtwork.#boardDetailPrimitives(
64
+ element,
65
+ kind
66
+ )
67
+ )
68
+ )
69
+ .filter(Boolean)
70
+ }
71
+
72
+ /**
73
+ * Builds total routed trace length labels.
74
+ * @param {object[]} primitives Primitive rows.
75
+ * @param {{ elementsByType: Map<string, object[]> }} [index] Element index.
76
+ * @returns {object[]}
77
+ */
78
+ static traceLengths(primitives, index = null) {
79
+ return CircuitJsonPcbTraceLengthModel.build(primitives, index)
80
+ }
81
+
82
+ /**
83
+ * Builds generated copper clearance diagnostics.
84
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
85
+ * @param {object[]} primitives Primitive rows.
86
+ * @returns {object[]}
87
+ */
88
+ static clearanceDiagnostics(index, primitives) {
89
+ const board = CircuitJsonPcbPrimitiveArtwork.#all(index, 'pcb_board')[0]
90
+ const minimum = CircuitJsonUnits.optionalLength(
91
+ board?.min_trace_clearance
92
+ )
93
+ if (minimum === null || minimum <= 0) return []
94
+
95
+ const copper = primitives.filter((primitive) =>
96
+ ['pad', 'track', 'via', 'zone'].includes(primitive.kind)
97
+ )
98
+ const diagnostics = []
99
+ for (let leftIndex = 0; leftIndex < copper.length; leftIndex += 1) {
100
+ for (
101
+ let rightIndex = leftIndex + 1;
102
+ rightIndex < copper.length;
103
+ rightIndex += 1
104
+ ) {
105
+ const left = copper[leftIndex]
106
+ const right = copper[rightIndex]
107
+ const actual = CircuitJsonPcbPrimitiveArtwork.#clearance(
108
+ left,
109
+ right
110
+ )
111
+ if (actual === null || actual >= minimum) continue
112
+ diagnostics.push(
113
+ CircuitJsonPcbPrimitiveArtwork.#clearanceDiagnostic(
114
+ left,
115
+ right,
116
+ actual,
117
+ minimum,
118
+ diagnostics.length
119
+ )
120
+ )
121
+ }
122
+ }
123
+
124
+ return diagnostics
125
+ }
126
+
127
+ /**
128
+ * Builds solder-mask opening primitives from pads with mask metadata.
129
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
130
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
131
+ * @returns {object[]}
132
+ */
133
+ static #solderMaskPrimitives(index, componentsByPcbId) {
134
+ return CircuitJsonPcbPrimitiveArtwork.#all(index, 'pcb_smtpad')
135
+ .map((element) => {
136
+ const margins =
137
+ CircuitJsonPcbPrimitiveArtwork.#solderMaskMargins(element)
138
+ if (!margins) return null
139
+ return CircuitJsonPcbPrimitiveArtwork.#expandedPadPrimitive(
140
+ element,
141
+ 'solder-mask',
142
+ 'soldermask',
143
+ margins,
144
+ componentsByPcbId
145
+ )
146
+ })
147
+ .filter(Boolean)
148
+ }
149
+
150
+ /**
151
+ * Builds explicit solder-paste primitives.
152
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
153
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
154
+ * @returns {object[]}
155
+ */
156
+ static #solderPastePrimitives(index, componentsByPcbId) {
157
+ return CircuitJsonPcbPrimitiveArtwork.#all(index, 'pcb_solder_paste')
158
+ .map((element) =>
159
+ CircuitJsonPcbPrimitiveArtwork.#shapePrimitive(
160
+ element,
161
+ 'solder-paste',
162
+ 'paste',
163
+ componentsByPcbId
164
+ )
165
+ )
166
+ .filter(Boolean)
167
+ }
168
+
169
+ /**
170
+ * Builds note and dimension documentation primitives.
171
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
172
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
173
+ * @returns {object[]}
174
+ */
175
+ static #notePrimitives(index, componentsByPcbId) {
176
+ return [
177
+ ...[
178
+ 'pcb_note_text',
179
+ 'pcb_note_line',
180
+ 'pcb_note_path',
181
+ 'pcb_note_rect'
182
+ ].flatMap((type) =>
183
+ CircuitJsonPcbPrimitiveArtwork.#all(index, type).flatMap(
184
+ (element) =>
185
+ CircuitJsonPcbPrimitiveArtwork.#documentationPrimitives(
186
+ element,
187
+ 'note',
188
+ componentsByPcbId
189
+ )
190
+ )
191
+ ),
192
+ ...['pcb_note_dimension', 'pcb_fabrication_note_dimension'].flatMap(
193
+ (type) =>
194
+ CircuitJsonPcbPrimitiveArtwork.#all(index, type).map(
195
+ (element) =>
196
+ CircuitJsonPcbPrimitiveArtwork.#dimensionPrimitive(
197
+ element
198
+ )
199
+ )
200
+ ),
201
+ ...CircuitJsonPcbPrimitiveArtwork.#all(
202
+ index,
203
+ 'pcb_fabrication_note_rect'
204
+ ).map((element) =>
205
+ CircuitJsonPcbPrimitiveArtwork.#shapePrimitive(
206
+ element,
207
+ 'fabrication',
208
+ 'fabrication',
209
+ componentsByPcbId
210
+ )
211
+ )
212
+ ].filter(Boolean)
213
+ }
214
+
215
+ /**
216
+ * Builds silkscreen shape primitives not covered by text/line handling.
217
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
218
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
219
+ * @returns {object[]}
220
+ */
221
+ static #silkscreenShapePrimitives(index, componentsByPcbId) {
222
+ return [
223
+ 'pcb_silkscreen_circle',
224
+ 'pcb_silkscreen_oval',
225
+ 'pcb_silkscreen_pill',
226
+ 'pcb_silkscreen_rect'
227
+ ]
228
+ .flatMap((type) => CircuitJsonPcbPrimitiveArtwork.#all(index, type))
229
+ .map((element) =>
230
+ CircuitJsonPcbPrimitiveArtwork.#shapePrimitive(
231
+ element,
232
+ 'silkscreen',
233
+ 'silkscreen',
234
+ componentsByPcbId
235
+ )
236
+ )
237
+ .filter(Boolean)
238
+ }
239
+
240
+ /**
241
+ * Builds thermal spoke primitives.
242
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
243
+ * @returns {object[]}
244
+ */
245
+ static #thermalSpokePrimitives(index) {
246
+ return CircuitJsonPcbPrimitiveArtwork.#all(index, 'pcb_thermal_spoke')
247
+ .map((element) =>
248
+ CircuitJsonPcbPrimitiveArtwork.#linePrimitive(
249
+ element,
250
+ 'thermal-spoke'
251
+ )
252
+ )
253
+ .filter(Boolean)
254
+ }
255
+
256
+ /**
257
+ * Builds route hint segment primitives.
258
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
259
+ * @returns {object[]}
260
+ */
261
+ static #routeHintPrimitives(index) {
262
+ return CircuitJsonPcbPrimitiveArtwork.#all(index, 'pcb_trace_hint')
263
+ .flatMap((element) => {
264
+ const points = (
265
+ Array.isArray(element.route) ? element.route : []
266
+ )
267
+ .map((point) => CircuitJsonPcbPrimitiveFields.point(point))
268
+ .filter(Boolean)
269
+ const rows = []
270
+ for (let index = 1; index < points.length; index += 1) {
271
+ rows.push(
272
+ CircuitJsonPcbPrimitiveArtwork.#linePrimitive(
273
+ {
274
+ ...element,
275
+ x1: points[index - 1].x,
276
+ y1: points[index - 1].y,
277
+ x2: points[index].x,
278
+ y2: points[index].y
279
+ },
280
+ 'route-hint',
281
+ index - 1
282
+ )
283
+ )
284
+ }
285
+ return rows
286
+ })
287
+ .filter(Boolean)
288
+ }
289
+
290
+ /**
291
+ * Builds breakout point markers.
292
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
293
+ * @returns {object[]}
294
+ */
295
+ static #breakoutPointPrimitives(index) {
296
+ return CircuitJsonPcbPrimitiveArtwork.#all(index, 'pcb_breakout_point')
297
+ .map((element) =>
298
+ CircuitJsonPcbPrimitiveArtwork.#shapePrimitive(
299
+ {
300
+ ...element,
301
+ radius: element.radius ?? 0.16,
302
+ shape: 'circle'
303
+ },
304
+ 'breakout-point',
305
+ 'breakout',
306
+ new Map()
307
+ )
308
+ )
309
+ .filter(Boolean)
310
+ }
311
+
312
+ /**
313
+ * Builds panel outline primitives.
314
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
315
+ * @returns {object[]}
316
+ */
317
+ static #panelPrimitives(index) {
318
+ return CircuitJsonPcbPrimitiveArtwork.#all(index, 'pcb_panel')
319
+ .map((element) =>
320
+ CircuitJsonPcbPrimitiveArtwork.#shapePrimitive(
321
+ element,
322
+ 'panel',
323
+ 'panel',
324
+ new Map()
325
+ )
326
+ )
327
+ .filter(Boolean)
328
+ }
329
+
330
+ /**
331
+ * Builds documentation primitives from common shape or path fields.
332
+ * @param {object} element Element row.
333
+ * @param {string} kind Primitive kind.
334
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
335
+ * @returns {object[]}
336
+ */
337
+ static #documentationPrimitives(element, kind, componentsByPcbId) {
338
+ if (element.text !== undefined) {
339
+ return [
340
+ CircuitJsonPcbPrimitiveArtwork.#textPrimitive(
341
+ element,
342
+ kind,
343
+ componentsByPcbId
344
+ )
345
+ ].filter(Boolean)
346
+ }
347
+ if (CircuitJsonPcbPrimitiveArtwork.#isOpenPath(element)) {
348
+ return CircuitJsonPcbPrimitiveArtwork.#openLinePrimitives(
349
+ element,
350
+ kind
351
+ )
352
+ }
353
+ return [
354
+ CircuitJsonPcbPrimitiveArtwork.#shapePrimitive(
355
+ element,
356
+ kind,
357
+ kind,
358
+ componentsByPcbId
359
+ )
360
+ ].filter(Boolean)
361
+ }
362
+
363
+ /**
364
+ * Builds one expanded rectangular pad-derived primitive.
365
+ * @param {object} element Source pad.
366
+ * @param {string} kind Primitive kind.
367
+ * @param {string} layerSuffix Layer suffix.
368
+ * @param {{ left: number, right: number, top: number, bottom: number }} margins Opening margins.
369
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
370
+ * @returns {object | null}
371
+ */
372
+ static #expandedPadPrimitive(
373
+ element,
374
+ kind,
375
+ layerSuffix,
376
+ margins,
377
+ componentsByPcbId
378
+ ) {
379
+ const center = CircuitJsonPcbPrimitiveFields.center(element)
380
+ const width = CircuitJsonUnits.optionalLength(element.width)
381
+ const height = CircuitJsonUnits.optionalLength(element.height)
382
+ if (!center || width === null || height === null) return null
383
+ const nextWidth = Math.max(width + margins.left + margins.right, 0.01)
384
+ const nextHeight = Math.max(height + margins.top + margins.bottom, 0.01)
385
+ const nextCenter = {
386
+ x: center.x + (margins.right - margins.left) / 2,
387
+ y: center.y + (margins.top - margins.bottom) / 2
388
+ }
389
+
390
+ return CircuitJsonPcbPrimitiveArtwork.#shapePrimitive(
391
+ {
392
+ ...element,
393
+ x: nextCenter.x,
394
+ y: nextCenter.y,
395
+ center: nextCenter,
396
+ width: nextWidth,
397
+ height: nextHeight,
398
+ radius:
399
+ CircuitJsonUnits.optionalLength(element.radius) ??
400
+ Math.min(nextWidth, nextHeight) / 2,
401
+ rotation: CircuitJsonUnits.angle(
402
+ element.ccw_rotation ?? element.rotation,
403
+ 0
404
+ ),
405
+ derived_id_suffix: kind,
406
+ layer: CircuitJsonPcbPrimitiveArtwork.#surfaceLayer(
407
+ element.layer,
408
+ layerSuffix
409
+ )
410
+ },
411
+ kind,
412
+ layerSuffix,
413
+ componentsByPcbId
414
+ )
415
+ }
416
+
417
+ /**
418
+ * Builds one generic center/size primitive.
419
+ * @param {object} element Element row.
420
+ * @param {string} kind Primitive kind.
421
+ * @param {string} layerSuffix Layer suffix.
422
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
423
+ * @returns {object | null}
424
+ */
425
+ static #shapePrimitive(element, kind, layerSuffix, componentsByPcbId) {
426
+ const points = CircuitJsonPcbPrimitiveFields.points(element)
427
+ const center =
428
+ CircuitJsonPcbPrimitiveFields.center(element) ||
429
+ CircuitJsonPcbPrimitiveFields.pointsCenter(points)
430
+ if (!center && points.length < 3) return null
431
+
432
+ const radius = CircuitJsonUnits.optionalLength(element.radius)
433
+ const width =
434
+ CircuitJsonUnits.optionalLength(element.width) ??
435
+ (radius === null ? null : radius * 2)
436
+ const height =
437
+ CircuitJsonUnits.optionalLength(element.height) ??
438
+ (radius === null ? null : radius * 2)
439
+ const bounds = points.length
440
+ ? CircuitJsonPcbPrimitiveGeometry.pointsBounds(points)
441
+ : CircuitJsonPcbPrimitiveGeometry.centerBounds(
442
+ center,
443
+ width ?? 0.5,
444
+ height ?? 0.5
445
+ )
446
+
447
+ return CircuitJsonPcbPrimitiveArtwork.#primitive({
448
+ id: CircuitJsonPcbPrimitiveFields.derivedId(element),
449
+ kind,
450
+ shape: String(
451
+ element.shape || (points.length ? 'polygon' : 'rect')
452
+ ),
453
+ x: center?.x ?? bounds.minX + bounds.width / 2,
454
+ y: center?.y ?? bounds.minY + bounds.height / 2,
455
+ width: width ?? bounds.width,
456
+ height: height ?? bounds.height,
457
+ radius: radius ?? 0,
458
+ rotation: CircuitJsonUnits.angle(
459
+ element.ccw_rotation ?? element.rotation,
460
+ 0
461
+ ),
462
+ points,
463
+ bounds,
464
+ layer: CircuitJsonPcbPrimitiveArtwork.#detailLayer(
465
+ element,
466
+ layerSuffix
467
+ ),
468
+ component: componentsByPcbId.get(
469
+ String(element.pcb_component_id || '').trim()
470
+ ),
471
+ netName: CircuitJsonPcbPrimitiveFields.netName(element),
472
+ anchors: points.length
473
+ ? points.map((point) => ({ point }))
474
+ : CircuitJsonPcbPrimitiveGeometry.cornerAnchors(bounds),
475
+ ...CircuitJsonPcbDrawingStyle.fromElement(element),
476
+ source: element
477
+ })
478
+ }
479
+
480
+ /**
481
+ * Builds one text primitive.
482
+ * @param {object} element Element row.
483
+ * @param {string} kind Primitive kind.
484
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
485
+ * @returns {object | null}
486
+ */
487
+ static #textPrimitive(element, kind, componentsByPcbId) {
488
+ const center = CircuitJsonPcbPrimitiveFields.center(element)
489
+ if (!center) return null
490
+ const fontSize = CircuitJsonUnits.length(
491
+ element.font_size ?? element.height,
492
+ 1
493
+ )
494
+ const bounds = CircuitJsonPcbPrimitiveGeometry.centerBounds(
495
+ center,
496
+ Math.max(
497
+ String(element.text || '').length * fontSize * 0.6,
498
+ fontSize
499
+ ),
500
+ fontSize
501
+ )
502
+ return CircuitJsonPcbPrimitiveArtwork.#primitive({
503
+ id: CircuitJsonIndexer.getElementId(element),
504
+ kind,
505
+ text: String(element.text || ''),
506
+ x: center.x,
507
+ y: center.y,
508
+ fontSize,
509
+ bounds,
510
+ layer: CircuitJsonPcbPrimitiveArtwork.#detailLayer(element, kind),
511
+ component: componentsByPcbId.get(
512
+ String(element.pcb_component_id || '').trim()
513
+ ),
514
+ anchors: [{ point: center }],
515
+ ...CircuitJsonPcbDrawingStyle.fromElement(element),
516
+ source: element
517
+ })
518
+ }
519
+
520
+ /**
521
+ * Builds one line primitive.
522
+ * @param {object} element Element row.
523
+ * @param {string} kind Primitive kind.
524
+ * @param {number} [index] Segment index.
525
+ * @returns {object | null}
526
+ */
527
+ static #linePrimitive(element, kind, index = 0) {
528
+ const start = CircuitJsonPcbPrimitiveFields.point({
529
+ x: element.x1 ?? element.start?.x,
530
+ y: element.y1 ?? element.start?.y
531
+ })
532
+ const end = CircuitJsonPcbPrimitiveFields.point({
533
+ x: element.x2 ?? element.end?.x,
534
+ y: element.y2 ?? element.end?.y
535
+ })
536
+ if (!start || !end) return null
537
+ const width = CircuitJsonUnits.length(
538
+ element.width ??
539
+ element.stroke_width ??
540
+ element.strokeWidth ??
541
+ element.line_width,
542
+ 0.08
543
+ )
544
+ return CircuitJsonPcbPrimitiveArtwork.#primitive({
545
+ id: CircuitJsonIndexer.getElementId(element) + ':' + index,
546
+ kind,
547
+ x1: start.x,
548
+ y1: start.y,
549
+ x2: end.x,
550
+ y2: end.y,
551
+ width,
552
+ bounds: CircuitJsonPcbPrimitiveGeometry.segmentBounds(
553
+ start,
554
+ end,
555
+ width
556
+ ),
557
+ layer: CircuitJsonPcbPrimitiveArtwork.#detailLayer(element, kind),
558
+ netName: CircuitJsonPcbPrimitiveFields.netName(element),
559
+ anchors: [{ point: start }, { point: end }],
560
+ ...CircuitJsonPcbDrawingStyle.fromElement(element),
561
+ source: element
562
+ })
563
+ }
564
+
565
+ /**
566
+ * Returns true when an element should render as open line segments.
567
+ * @param {object} element Element row.
568
+ * @returns {boolean}
569
+ */
570
+ static #isOpenPath(element) {
571
+ const type = String(element.type || '')
572
+ const shape = String(element.shape || '').toLowerCase()
573
+ return (
574
+ type.endsWith('_line') ||
575
+ type.endsWith('_path') ||
576
+ shape === 'line' ||
577
+ shape === 'path'
578
+ )
579
+ }
580
+
581
+ /**
582
+ * Builds open line segment primitives from path points.
583
+ * @param {object} element Element row.
584
+ * @param {string} kind Primitive kind.
585
+ * @returns {object[]}
586
+ */
587
+ static #openLinePrimitives(element, kind) {
588
+ const points = CircuitJsonPcbPrimitiveFields.linePoints(element)
589
+ const rows = []
590
+ for (let index = 1; index < points.length; index += 1) {
591
+ rows.push(
592
+ CircuitJsonPcbPrimitiveArtwork.#linePrimitive(
593
+ {
594
+ ...element,
595
+ x1: points[index - 1].x,
596
+ y1: points[index - 1].y,
597
+ x2: points[index].x,
598
+ y2: points[index].y
599
+ },
600
+ kind,
601
+ index - 1
602
+ )
603
+ )
604
+ }
605
+ return rows.filter(Boolean)
606
+ }
607
+
608
+ /**
609
+ * Builds one dimension primitive.
610
+ * @param {object} element Element row.
611
+ * @returns {object | null}
612
+ */
613
+ static #dimensionPrimitive(element) {
614
+ const line = CircuitJsonPcbPrimitiveArtwork.#linePrimitive(
615
+ element,
616
+ 'dimension'
617
+ )
618
+ return line
619
+ ? {
620
+ ...line,
621
+ text: String(element.text || '')
622
+ }
623
+ : null
624
+ }
625
+
626
+ /**
627
+ * Builds one board detail shape primitive.
628
+ * @param {object} element Element row.
629
+ * @param {string} kind Primitive kind.
630
+ * @returns {object | null}
631
+ */
632
+ static #boardDetailPrimitive(element, kind) {
633
+ const points = CircuitJsonPcbPrimitiveFields.points(element)
634
+ const center =
635
+ CircuitJsonPcbPrimitiveFields.center(element) ||
636
+ CircuitJsonPcbPrimitiveFields.pointsCenter(points)
637
+ if (!center && points.length < 3) return null
638
+
639
+ const width = CircuitJsonUnits.optionalLength(element.width)
640
+ const height = CircuitJsonUnits.optionalLength(element.height)
641
+ const radius = CircuitJsonUnits.optionalLength(element.radius)
642
+ const bounds = points.length
643
+ ? CircuitJsonPcbPrimitiveGeometry.pointsBounds(points)
644
+ : CircuitJsonPcbPrimitiveGeometry.centerBounds(
645
+ center,
646
+ width ?? (radius ?? 0.5) * 2,
647
+ height ?? (radius ?? 0.5) * 2
648
+ )
649
+
650
+ return {
651
+ id: CircuitJsonIndexer.getElementId(element),
652
+ kind,
653
+ shape: String(
654
+ element.shape || (points.length ? 'polygon' : 'rect')
655
+ ),
656
+ x: center?.x ?? bounds.minX + bounds.width / 2,
657
+ y: center?.y ?? bounds.minY + bounds.height / 2,
658
+ width: width ?? bounds.width,
659
+ height: height ?? bounds.height,
660
+ radius: radius ?? 0,
661
+ points,
662
+ bounds,
663
+ layer: CircuitJsonPcbPrimitiveArtwork.#boardDetailLayer(
664
+ element,
665
+ kind
666
+ ),
667
+ side: '',
668
+ componentKey: '',
669
+ componentId: '',
670
+ footprintId: '',
671
+ netName: '',
672
+ anchors: points.length
673
+ ? points.map((point) => ({ point }))
674
+ : CircuitJsonPcbPrimitiveGeometry.cornerAnchors(bounds),
675
+ ...CircuitJsonPcbDrawingStyle.fromElement(element),
676
+ source: element
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Builds board detail primitives for shape or open path rows.
682
+ * @param {object} element Element row.
683
+ * @param {string} kind Primitive kind.
684
+ * @returns {object[]}
685
+ */
686
+ static #boardDetailPrimitives(element, kind) {
687
+ if (CircuitJsonPcbPrimitiveArtwork.#isBoardDetailPath(element)) {
688
+ return CircuitJsonPcbPrimitiveArtwork.#boardDetailLinePrimitives(
689
+ element,
690
+ kind
691
+ )
692
+ }
693
+
694
+ return [
695
+ CircuitJsonPcbPrimitiveArtwork.#boardDetailPrimitive(element, kind)
696
+ ].filter(Boolean)
697
+ }
698
+
699
+ /**
700
+ * Returns true when a board detail row should render as open segments.
701
+ * @param {object} element Element row.
702
+ * @returns {boolean}
703
+ */
704
+ static #isBoardDetailPath(element) {
705
+ const type = String(element.type || '')
706
+ const shape = String(element.shape || '').toLowerCase()
707
+ return (
708
+ type.endsWith('_line') ||
709
+ type.endsWith('_path') ||
710
+ shape === 'line' ||
711
+ shape === 'path'
712
+ )
713
+ }
714
+
715
+ /**
716
+ * Builds open board detail segment primitives.
717
+ * @param {object} element Element row.
718
+ * @param {string} kind Primitive kind.
719
+ * @returns {object[]}
720
+ */
721
+ static #boardDetailLinePrimitives(element, kind) {
722
+ const points = CircuitJsonPcbPrimitiveFields.linePoints(element)
723
+ const rows = []
724
+
725
+ for (let index = 1; index < points.length; index += 1) {
726
+ rows.push(
727
+ CircuitJsonPcbPrimitiveArtwork.#boardDetailLinePrimitive(
728
+ element,
729
+ kind,
730
+ points[index - 1],
731
+ points[index],
732
+ index - 1
733
+ )
734
+ )
735
+ }
736
+
737
+ return rows.filter(Boolean)
738
+ }
739
+
740
+ /**
741
+ * Builds one open board detail segment primitive.
742
+ * @param {object} element Element row.
743
+ * @param {string} kind Primitive kind.
744
+ * @param {{ x: number, y: number }} start Segment start.
745
+ * @param {{ x: number, y: number }} end Segment end.
746
+ * @param {number} index Segment index.
747
+ * @returns {object | null}
748
+ */
749
+ static #boardDetailLinePrimitive(element, kind, start, end, index) {
750
+ const width = CircuitJsonUnits.length(
751
+ element.width ??
752
+ element.stroke_width ??
753
+ element.strokeWidth ??
754
+ element.line_width,
755
+ 0.08
756
+ )
757
+
758
+ return {
759
+ id: CircuitJsonIndexer.getElementId(element) + ':' + index,
760
+ kind,
761
+ x1: start.x,
762
+ y1: start.y,
763
+ x2: end.x,
764
+ y2: end.y,
765
+ width,
766
+ bounds: CircuitJsonPcbPrimitiveGeometry.segmentBounds(
767
+ start,
768
+ end,
769
+ width
770
+ ),
771
+ layer: CircuitJsonPcbPrimitiveArtwork.#boardDetailLayer(
772
+ element,
773
+ kind
774
+ ),
775
+ side: '',
776
+ componentKey: '',
777
+ componentId: '',
778
+ footprintId: '',
779
+ netName: '',
780
+ anchors: [{ point: start }, { point: end }],
781
+ ...CircuitJsonPcbDrawingStyle.fromElement(element),
782
+ source: element
783
+ }
784
+ }
785
+
786
+ /**
787
+ * Adds common primitive metadata.
788
+ * @param {object} primitive Primitive row.
789
+ * @returns {object}
790
+ */
791
+ static #primitive(primitive) {
792
+ const component = primitive.component || {}
793
+ const layer = CircuitJsonPcbPrimitiveFields.layer(primitive.layer)
794
+ return {
795
+ ...primitive,
796
+ layer,
797
+ side: primitive.side ?? CircuitJsonPcbPrimitiveFields.side(layer),
798
+ componentKey: String(component.componentKey || ''),
799
+ componentId: String(component.pcbComponentId || ''),
800
+ footprintId: component.componentKey
801
+ ? 'footprint:' + component.componentKey + ':' + primitive.kind
802
+ : '',
803
+ netName: String(primitive.netName || '').trim()
804
+ }
805
+ }
806
+
807
+ /**
808
+ * Resolves indexed element rows.
809
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
810
+ * @param {string} type Element type.
811
+ * @returns {object[]}
812
+ */
813
+ static #all(index, type) {
814
+ return index.elementsByType.get(type) || []
815
+ }
816
+
817
+ /**
818
+ * Resolves solder-mask margins for one pad.
819
+ * @param {object} element Pad row.
820
+ * @returns {{ left: number, right: number, top: number, bottom: number } | null}
821
+ */
822
+ static #solderMaskMargins(element) {
823
+ const left = CircuitJsonUnits.optionalLength(
824
+ element.soldermask_margin_left ?? element.solderMaskMarginLeft
825
+ )
826
+ const right = CircuitJsonUnits.optionalLength(
827
+ element.soldermask_margin_right ?? element.solderMaskMarginRight
828
+ )
829
+ const top = CircuitJsonUnits.optionalLength(
830
+ element.soldermask_margin_top ?? element.solderMaskMarginTop
831
+ )
832
+ const bottom = CircuitJsonUnits.optionalLength(
833
+ element.soldermask_margin_bottom ?? element.solderMaskMarginBottom
834
+ )
835
+ if ([left, right, top, bottom].some((value) => value !== null)) {
836
+ return {
837
+ left: left ?? 0,
838
+ right: right ?? 0,
839
+ top: top ?? 0,
840
+ bottom: bottom ?? 0
841
+ }
842
+ }
843
+
844
+ const expansion = CircuitJsonPcbPrimitiveFields.optionalLength([
845
+ element.solderMaskExpansion,
846
+ element.solder_mask_expansion,
847
+ element.solderMaskMargin,
848
+ element.solder_mask_margin
849
+ ])
850
+ return expansion === null
851
+ ? null
852
+ : {
853
+ left: expansion,
854
+ right: expansion,
855
+ top: expansion,
856
+ bottom: expansion
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Resolves a drawing layer for a primitive.
862
+ * @param {object} element Element row.
863
+ * @param {string} suffix Layer suffix.
864
+ * @returns {string}
865
+ */
866
+ static #detailLayer(element, suffix) {
867
+ if (['paste', 'soldermask'].includes(suffix)) {
868
+ return CircuitJsonPcbPrimitiveArtwork.#surfaceLayer(
869
+ element.layer,
870
+ suffix
871
+ )
872
+ }
873
+ if (suffix === 'panel') return 'panel'
874
+ if (suffix === 'breakout') return 'breakout_points'
875
+ return CircuitJsonPcbPrimitiveFields.layer(
876
+ element.layer || 'top_fabrication'
877
+ )
878
+ }
879
+
880
+ /**
881
+ * Builds a top/bottom-specific fabrication layer key.
882
+ * @param {unknown} layer Layer candidate.
883
+ * @param {string} suffix Layer suffix.
884
+ * @returns {string}
885
+ */
886
+ static #surfaceLayer(layer, suffix) {
887
+ return (
888
+ (CircuitJsonPcbPrimitiveFields.side(
889
+ CircuitJsonPcbPrimitiveFields.layer(layer)
890
+ ) || 'top') +
891
+ '_' +
892
+ suffix
893
+ )
894
+ }
895
+
896
+ /**
897
+ * Resolves a board detail layer key.
898
+ * @param {object} element Element row.
899
+ * @param {string} kind Primitive kind.
900
+ * @returns {string}
901
+ */
902
+ static #boardDetailLayer(element, kind) {
903
+ if (kind === 'cutout') return 'cutouts'
904
+ if (kind === 'keepout') return 'keepouts'
905
+ if (kind === 'courtyard') {
906
+ return CircuitJsonPcbPrimitiveFields.layer(
907
+ element.layer || 'top_courtyard'
908
+ )
909
+ }
910
+ return CircuitJsonPcbPrimitiveFields.layer(element.layer || 'board')
911
+ }
912
+
913
+ /**
914
+ * Computes bounds-based clearance between two copper primitives.
915
+ * @param {object} left Left primitive.
916
+ * @param {object} right Right primitive.
917
+ * @returns {number | null}
918
+ */
919
+ static #clearance(left, right) {
920
+ if (!left.bounds || !right.bounds) return null
921
+ if (!left.netName || !right.netName || left.netName === right.netName) {
922
+ return null
923
+ }
924
+ if (left.layer && right.layer && left.layer !== right.layer) return null
925
+
926
+ const dx = Math.max(
927
+ right.bounds.minX - left.bounds.maxX,
928
+ left.bounds.minX - right.bounds.maxX,
929
+ 0
930
+ )
931
+ const dy = Math.max(
932
+ right.bounds.minY - left.bounds.maxY,
933
+ left.bounds.minY - right.bounds.maxY,
934
+ 0
935
+ )
936
+ return Math.hypot(dx, dy)
937
+ }
938
+
939
+ /**
940
+ * Builds one generated copper clearance diagnostic.
941
+ * @param {object} left Left primitive.
942
+ * @param {object} right Right primitive.
943
+ * @param {number} actual Actual clearance.
944
+ * @param {number} minimum Minimum clearance.
945
+ * @param {number} index Diagnostic index.
946
+ * @returns {object}
947
+ */
948
+ static #clearanceDiagnostic(left, right, actual, minimum, index) {
949
+ return {
950
+ id: 'clearance:' + index,
951
+ kind: 'error',
952
+ severity: 'error',
953
+ category: 'clearance',
954
+ code: 'pcb_copper_clearance',
955
+ message: 'Copper clearance is below the configured rule.',
956
+ point: {
957
+ x: CircuitJsonPcbPrimitiveArtwork.#rounded(
958
+ (left.bounds.minX +
959
+ left.bounds.maxX +
960
+ right.bounds.minX +
961
+ right.bounds.maxX) /
962
+ 4
963
+ ),
964
+ y: CircuitJsonPcbPrimitiveArtwork.#rounded(
965
+ (left.bounds.minY +
966
+ left.bounds.maxY +
967
+ right.bounds.minY +
968
+ right.bounds.maxY) /
969
+ 4
970
+ )
971
+ },
972
+ componentKey: String(left.componentKey || right.componentKey || ''),
973
+ netName:
974
+ String(left.netName || '') +
975
+ ' / ' +
976
+ String(right.netName || ''),
977
+ clearance: {
978
+ minimum: CircuitJsonPcbPrimitiveArtwork.#rounded(minimum),
979
+ actual: CircuitJsonPcbPrimitiveArtwork.#rounded(actual)
980
+ }
981
+ }
982
+ }
983
+
984
+ /**
985
+ * Rounds a numeric value for deterministic model rows.
986
+ * @param {number} value Numeric value.
987
+ * @returns {number}
988
+ */
989
+ static #rounded(value) {
990
+ return Number(Number(value).toFixed(6))
991
+ }
992
+ }