circuitjson-toolkit 1.0.3 → 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 (54) hide show
  1. package/AGENTS.md +5 -3
  2. package/README.md +21 -2
  3. package/docs/api.md +50 -4
  4. package/docs/model-format.md +21 -3
  5. package/package.json +3 -2
  6. package/spec/library-scope.md +4 -1
  7. package/src/core/CircuitJsonBomBuilder.mjs +143 -0
  8. package/src/core/CircuitJsonDocument.mjs +46 -13
  9. package/src/core/CircuitJsonElementValidator.mjs +990 -0
  10. package/src/core/CircuitJsonIndexer.mjs +773 -4
  11. package/src/core/CircuitJsonManufacturingBuilder.mjs +898 -0
  12. package/src/core/CircuitJsonManufacturingDownloadBuilder.mjs +196 -0
  13. package/src/core/CircuitJsonParser.mjs +22 -6
  14. package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +329 -0
  15. package/src/core/CircuitJsonPcbCopperGeometry.mjs +503 -0
  16. package/src/core/CircuitJsonPcbDrawingStyle.mjs +88 -0
  17. package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +172 -0
  18. package/src/core/CircuitJsonPcbNetMetadata.mjs +247 -0
  19. package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +70 -0
  20. package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +992 -0
  21. package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +872 -0
  22. package/src/core/CircuitJsonPcbPrimitiveFields.mjs +233 -0
  23. package/src/core/CircuitJsonPcbPrimitiveGeometry.mjs +142 -0
  24. package/src/core/CircuitJsonPcbPrimitiveGroups.mjs +305 -0
  25. package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +65 -0
  26. package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +895 -0
  27. package/src/core/CircuitJsonPcbTraceLengthModel.mjs +257 -0
  28. package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +683 -0
  29. package/src/core/CircuitJsonSourceMetadata.mjs +233 -0
  30. package/src/core/CircuitJsonSupportMatrixBuilder.mjs +481 -0
  31. package/src/core/CircuitJsonUnits.mjs +133 -8
  32. package/src/core/PcbBoundsSelectionModel.mjs +250 -0
  33. package/src/core/PcbCandidateSelectionModel.mjs +77 -0
  34. package/src/core/PcbDiagnosticFocusModel.mjs +423 -0
  35. package/src/core/PcbInteractionPrimitiveModel.mjs +560 -0
  36. package/src/core/SelectedPartCircuitJsonExportAdapter.mjs +335 -0
  37. package/src/core/spice/SpiceCompatibilityPreprocessor.mjs +139 -0
  38. package/src/core/spice/SpiceDirectiveParser.mjs +231 -0
  39. package/src/core/spice/SpiceFallbackSimulationEngine.mjs +168 -0
  40. package/src/core/spice/SpiceSimulationDiagnostics.mjs +234 -0
  41. package/src/core/spice/SpiceSimulationGraphBuilder.mjs +421 -0
  42. package/src/core/spice/SpiceSimulationGraphSummary.mjs +90 -0
  43. package/src/core/spice/SpiceSimulationService.mjs +92 -0
  44. package/src/core/spice/SpiceTimeSeriesNormalizer.mjs +132 -0
  45. package/src/index.mjs +8 -0
  46. package/src/renderers.mjs +29 -0
  47. package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +128 -0
  48. package/src/ui/CircuitJsonPcbSvgRenderer.mjs +964 -0
  49. package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +168 -0
  50. package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +138 -0
  51. package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +114 -0
  52. package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +130 -0
  53. package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +994 -0
  54. package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +439 -0
@@ -0,0 +1,683 @@
1
+ import { CircuitJsonIndexer } from './CircuitJsonIndexer.mjs'
2
+ import { CircuitJsonUnits } from './CircuitJsonUnits.mjs'
3
+ import { CircuitJsonPcbPrimitiveGeometry } from './CircuitJsonPcbPrimitiveGeometry.mjs'
4
+
5
+ const MIN_RING_AREA = 1e-8
6
+
7
+ /**
8
+ * Builds copper area primitives from CircuitJSON PCB area records.
9
+ */
10
+ export class CircuitJsonPcbZonePrimitiveBuilder {
11
+ /**
12
+ * Builds copper area primitives and geometry diagnostics.
13
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
14
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
15
+ * @returns {{ primitives: object[], diagnostics: object[] }}
16
+ */
17
+ static build(index, componentsByPcbId) {
18
+ const rows = CircuitJsonPcbZonePrimitiveBuilder.#areaElements(
19
+ index
20
+ ).map((element) =>
21
+ CircuitJsonPcbZonePrimitiveBuilder.#zonePrimitive(
22
+ element,
23
+ componentsByPcbId
24
+ )
25
+ )
26
+
27
+ return {
28
+ primitives: rows.map((row) => row.primitive).filter(Boolean),
29
+ diagnostics: rows.flatMap((row) => row.diagnostics)
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Builds one copper area primitive.
35
+ * @param {object} element Source element.
36
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
37
+ * @returns {{ primitive: object | null, diagnostics: object[] }}
38
+ */
39
+ static #zonePrimitive(element, componentsByPcbId) {
40
+ if (CircuitJsonPcbZonePrimitiveBuilder.#isBRepElement(element)) {
41
+ return CircuitJsonPcbZonePrimitiveBuilder.#brepPrimitive(
42
+ element,
43
+ componentsByPcbId
44
+ )
45
+ }
46
+ return CircuitJsonPcbZonePrimitiveBuilder.#polygonPrimitive(
47
+ element,
48
+ componentsByPcbId
49
+ )
50
+ }
51
+
52
+ /**
53
+ * Builds one B-Rep copper area primitive.
54
+ * @param {object} element Source element.
55
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
56
+ * @returns {{ primitive: object | null, diagnostics: object[] }}
57
+ */
58
+ static #brepPrimitive(element, componentsByPcbId) {
59
+ const geometry =
60
+ CircuitJsonPcbZonePrimitiveBuilder.#brepGeometry(element)
61
+ const component = CircuitJsonPcbZonePrimitiveBuilder.#component(
62
+ element,
63
+ componentsByPcbId
64
+ )
65
+ const bounds = CircuitJsonPcbZonePrimitiveBuilder.#mergedRingBounds(
66
+ geometry.rings
67
+ )
68
+ const firstOuter = geometry.rings.find((ring) => ring.role === 'outer')
69
+ const primitive = bounds
70
+ ? CircuitJsonPcbZonePrimitiveBuilder.#primitive({
71
+ id: CircuitJsonIndexer.getElementId(element),
72
+ kind: 'zone',
73
+ shape: 'brep',
74
+ points: firstOuter?.points || [],
75
+ rings: geometry.rings,
76
+ bounds,
77
+ layer: CircuitJsonPcbZonePrimitiveBuilder.#layer(
78
+ element.layer
79
+ ),
80
+ component,
81
+ netName: CircuitJsonPcbZonePrimitiveBuilder.#netName(
82
+ element,
83
+ null
84
+ ),
85
+ sourceNetId:
86
+ CircuitJsonPcbZonePrimitiveBuilder.#sourceNetId(element),
87
+ coveredWithSolderMask:
88
+ CircuitJsonPcbZonePrimitiveBuilder.#coveredWithSolderMask(
89
+ element
90
+ ),
91
+ anchors: geometry.rings.flatMap((ring) =>
92
+ ring.points.map((point) => ({ point }))
93
+ ),
94
+ source: element
95
+ })
96
+ : null
97
+
98
+ return {
99
+ primitive,
100
+ diagnostics: geometry.diagnostics.map((diagnostic) =>
101
+ CircuitJsonPcbZonePrimitiveBuilder.#diagnostic(
102
+ diagnostic,
103
+ element,
104
+ primitive
105
+ )
106
+ )
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Builds one polygonal copper area primitive.
112
+ * @param {object} element Source element.
113
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
114
+ * @returns {{ primitive: object | null, diagnostics: object[] }}
115
+ */
116
+ static #polygonPrimitive(element, componentsByPcbId) {
117
+ const points = CircuitJsonPcbZonePrimitiveBuilder.#points(element)
118
+ if (points.length < 3) return { primitive: null, diagnostics: [] }
119
+ const component = CircuitJsonPcbZonePrimitiveBuilder.#component(
120
+ element,
121
+ componentsByPcbId
122
+ )
123
+
124
+ return {
125
+ primitive: CircuitJsonPcbZonePrimitiveBuilder.#primitive({
126
+ id: CircuitJsonIndexer.getElementId(element),
127
+ kind: 'zone',
128
+ shape: String(element.shape || 'polygon'),
129
+ points,
130
+ bounds: CircuitJsonPcbPrimitiveGeometry.pointsBounds(points),
131
+ layer: CircuitJsonPcbZonePrimitiveBuilder.#layer(element.layer),
132
+ component,
133
+ netName: CircuitJsonPcbZonePrimitiveBuilder.#netName(
134
+ element,
135
+ null
136
+ ),
137
+ sourceNetId:
138
+ CircuitJsonPcbZonePrimitiveBuilder.#sourceNetId(element),
139
+ coveredWithSolderMask:
140
+ CircuitJsonPcbZonePrimitiveBuilder.#coveredWithSolderMask(
141
+ element
142
+ ),
143
+ anchors: points.map((point) => ({ point })),
144
+ source: element
145
+ }),
146
+ diagnostics: []
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Normalizes B-Rep geometry into renderable rings.
152
+ * @param {object} element Source element.
153
+ * @returns {{ rings: object[], diagnostics: object[] }}
154
+ */
155
+ static #brepGeometry(element) {
156
+ const rings = []
157
+ const diagnostics = []
158
+ const shapes = CircuitJsonPcbZonePrimitiveBuilder.#brepShapes(element)
159
+
160
+ shapes.forEach((shape, shapeIndex) => {
161
+ const outer = CircuitJsonPcbZonePrimitiveBuilder.#normalizeRing(
162
+ CircuitJsonPcbZonePrimitiveBuilder.#outerRing(shape)
163
+ )
164
+ if (!outer.points) {
165
+ diagnostics.push({
166
+ ...outer,
167
+ code: 'pcb_zone_brep_island_dropped',
168
+ role: 'outer',
169
+ shapeIndex
170
+ })
171
+ return
172
+ }
173
+
174
+ rings.push({
175
+ role: 'outer',
176
+ shapeIndex,
177
+ points: outer.points,
178
+ bounds: outer.bounds
179
+ })
180
+
181
+ CircuitJsonPcbZonePrimitiveBuilder.#innerRings(shape).forEach(
182
+ (ring, ringIndex) => {
183
+ const hole =
184
+ CircuitJsonPcbZonePrimitiveBuilder.#normalizeRing(ring)
185
+ if (!hole.points) {
186
+ diagnostics.push({
187
+ ...hole,
188
+ code: 'pcb_zone_brep_ring_dropped',
189
+ role: 'hole',
190
+ shapeIndex,
191
+ ringIndex
192
+ })
193
+ return
194
+ }
195
+ rings.push({
196
+ role: 'hole',
197
+ shapeIndex,
198
+ ringIndex,
199
+ points: hole.points,
200
+ bounds: hole.bounds
201
+ })
202
+ }
203
+ )
204
+ })
205
+
206
+ return { rings, diagnostics }
207
+ }
208
+
209
+ /**
210
+ * Normalizes one ring into valid points.
211
+ * @param {object | object[]} ring Ring candidate.
212
+ * @returns {{ points?: object[], bounds?: object, reason?: string }}
213
+ */
214
+ static #normalizeRing(ring) {
215
+ const rawPoints = CircuitJsonPcbZonePrimitiveBuilder.#ringVertices(ring)
216
+ const points = rawPoints
217
+ .map((point) => CircuitJsonPcbZonePrimitiveBuilder.#point(point))
218
+ .filter(Boolean)
219
+ .map((point) => ({
220
+ x: CircuitJsonPcbZonePrimitiveBuilder.#round(point.x),
221
+ y: CircuitJsonPcbZonePrimitiveBuilder.#round(point.y)
222
+ }))
223
+ const normalized =
224
+ CircuitJsonPcbZonePrimitiveBuilder.#dropDuplicateClosure(
225
+ CircuitJsonPcbZonePrimitiveBuilder.#dropConsecutiveDuplicates(
226
+ points
227
+ )
228
+ )
229
+ const bounds =
230
+ normalized.length > 0
231
+ ? CircuitJsonPcbPrimitiveGeometry.pointsBounds(normalized)
232
+ : null
233
+
234
+ if (
235
+ CircuitJsonPcbZonePrimitiveBuilder.#uniquePointCount(normalized) < 3
236
+ ) {
237
+ return { bounds, reason: 'too-few-points' }
238
+ }
239
+
240
+ if (
241
+ Math.abs(
242
+ CircuitJsonPcbZonePrimitiveBuilder.#signedArea(normalized)
243
+ ) <= MIN_RING_AREA
244
+ ) {
245
+ return { bounds, reason: 'tiny-area' }
246
+ }
247
+
248
+ return { points: normalized, bounds }
249
+ }
250
+
251
+ /**
252
+ * Builds one geometry diagnostic row.
253
+ * @param {object} diagnostic Normalization diagnostic.
254
+ * @param {object} element Source element.
255
+ * @param {object | null} primitive Built primitive.
256
+ * @returns {object}
257
+ */
258
+ static #diagnostic(diagnostic, element, primitive) {
259
+ const bounds = diagnostic.bounds || primitive?.bounds || null
260
+ const point = bounds
261
+ ? CircuitJsonPcbZonePrimitiveBuilder.#boundsCenter(bounds)
262
+ : { x: 0, y: 0 }
263
+ const sourceId =
264
+ CircuitJsonIndexer.getElementId(element) || 'copper-area'
265
+ const role = diagnostic.role === 'outer' ? 'island' : 'ring'
266
+
267
+ return {
268
+ id:
269
+ sourceId +
270
+ ':brep:' +
271
+ diagnostic.shapeIndex +
272
+ ':' +
273
+ (diagnostic.ringIndex ?? 0) +
274
+ ':' +
275
+ diagnostic.code,
276
+ kind: 'warning',
277
+ severity: 'warning',
278
+ category: 'geometry',
279
+ code: diagnostic.code,
280
+ message:
281
+ 'Copper area ' +
282
+ role +
283
+ ' was ignored because ' +
284
+ CircuitJsonPcbZonePrimitiveBuilder.#reasonMessage(
285
+ diagnostic.reason
286
+ ) +
287
+ '.',
288
+ point,
289
+ bounds,
290
+ relatedPrimitiveIds: primitive?.id ? [primitive.id] : [],
291
+ netName: CircuitJsonPcbZonePrimitiveBuilder.#netName(element, null)
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Returns all copper area elements.
297
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
298
+ * @returns {object[]}
299
+ */
300
+ static #areaElements(index) {
301
+ return [
302
+ 'pcb_copper_pour',
303
+ 'pcb_ground_plane',
304
+ 'pcb_ground_plane_region'
305
+ ].flatMap((type) => index.elementsByType.get(type) || [])
306
+ }
307
+
308
+ /**
309
+ * Returns true when an element carries B-Rep geometry.
310
+ * @param {object} element Source element.
311
+ * @returns {boolean}
312
+ */
313
+ static #isBRepElement(element) {
314
+ return (
315
+ String(element?.shape || '').toLowerCase() === 'brep' ||
316
+ Boolean(element?.brep_shape) ||
317
+ Boolean(element?.brepShape) ||
318
+ Array.isArray(element?.brep_shapes)
319
+ )
320
+ }
321
+
322
+ /**
323
+ * Resolves B-Rep shape rows from common field aliases.
324
+ * @param {object} element Source element.
325
+ * @returns {object[]}
326
+ */
327
+ static #brepShapes(element) {
328
+ return [
329
+ ...(Array.isArray(element?.brep_shapes) ? element.brep_shapes : []),
330
+ ...(Array.isArray(element?.brepShapes) ? element.brepShapes : []),
331
+ element?.brep_shape,
332
+ element?.brepShape
333
+ ].filter(Boolean)
334
+ }
335
+
336
+ /**
337
+ * Resolves the outer ring from a B-Rep shape.
338
+ * @param {object} shape B-Rep shape.
339
+ * @returns {object | object[] | null}
340
+ */
341
+ static #outerRing(shape) {
342
+ return (
343
+ shape?.outerRing ||
344
+ shape?.outer_ring ||
345
+ shape?.outer ||
346
+ shape?.ring ||
347
+ null
348
+ )
349
+ }
350
+
351
+ /**
352
+ * Resolves inner rings from a B-Rep shape.
353
+ * @param {object} shape B-Rep shape.
354
+ * @returns {object[]}
355
+ */
356
+ static #innerRings(shape) {
357
+ const rings =
358
+ shape?.innerRings ||
359
+ shape?.inner_rings ||
360
+ shape?.holes ||
361
+ shape?.inner ||
362
+ []
363
+ return Array.isArray(rings) ? rings : []
364
+ }
365
+
366
+ /**
367
+ * Resolves vertices from a ring candidate.
368
+ * @param {object | object[] | null} ring Ring candidate.
369
+ * @returns {object[]}
370
+ */
371
+ static #ringVertices(ring) {
372
+ if (Array.isArray(ring)) return ring
373
+ return (
374
+ ring?.cwVertices ||
375
+ ring?.ccwVertices ||
376
+ ring?.vertices ||
377
+ ring?.points ||
378
+ []
379
+ )
380
+ }
381
+
382
+ /**
383
+ * Resolves polygon points from common fields.
384
+ * @param {object} element Element row.
385
+ * @returns {{ x: number, y: number }[]}
386
+ */
387
+ static #points(element) {
388
+ const points =
389
+ (Array.isArray(element?.points) && element.points) ||
390
+ (Array.isArray(element?.outline) && element.outline) ||
391
+ (Array.isArray(element?.vertices) && element.vertices) ||
392
+ (Array.isArray(element?.route) && element.route) ||
393
+ (Array.isArray(element?.path) && element.path) ||
394
+ (Array.isArray(element?.shape?.points) && element.shape.points) ||
395
+ []
396
+
397
+ const normalized = points
398
+ .map((point) => CircuitJsonPcbZonePrimitiveBuilder.#point(point))
399
+ .filter(Boolean)
400
+ if (normalized.length) return normalized
401
+ return CircuitJsonPcbZonePrimitiveBuilder.#rectPoints(element)
402
+ }
403
+
404
+ /**
405
+ * Builds rectangle polygon points from center/size metadata.
406
+ * @param {object} element Element row.
407
+ * @returns {{ x: number, y: number }[]}
408
+ */
409
+ static #rectPoints(element) {
410
+ if (String(element?.shape || '').toLowerCase() !== 'rect') return []
411
+ const center = CircuitJsonPcbZonePrimitiveBuilder.#point(
412
+ element?.center || element
413
+ )
414
+ const width = CircuitJsonUnits.optionalLength(element?.width)
415
+ const height = CircuitJsonUnits.optionalLength(element?.height)
416
+ if (!center || width === null || height === null) return []
417
+ const halfWidth = width / 2
418
+ const halfHeight = height / 2
419
+ return [
420
+ { x: center.x - halfWidth, y: center.y - halfHeight },
421
+ { x: center.x + halfWidth, y: center.y - halfHeight },
422
+ { x: center.x + halfWidth, y: center.y + halfHeight },
423
+ { x: center.x - halfWidth, y: center.y + halfHeight }
424
+ ]
425
+ }
426
+
427
+ /**
428
+ * Resolves a component from the source element.
429
+ * @param {object} element Source element.
430
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
431
+ * @returns {object | undefined}
432
+ */
433
+ static #component(element, componentsByPcbId) {
434
+ return componentsByPcbId.get(
435
+ String(element.pcb_component_id || '').trim()
436
+ )
437
+ }
438
+
439
+ /**
440
+ * Adds common primitive metadata.
441
+ * @param {object} primitive Primitive data.
442
+ * @returns {object}
443
+ */
444
+ static #primitive(primitive) {
445
+ const component = primitive.component || {}
446
+ const layer = CircuitJsonPcbZonePrimitiveBuilder.#layer(primitive.layer)
447
+ const source = primitive.source || {}
448
+ return {
449
+ ...primitive,
450
+ layer,
451
+ side:
452
+ primitive.side ??
453
+ CircuitJsonPcbZonePrimitiveBuilder.#side(layer),
454
+ componentKey: String(component.componentKey || ''),
455
+ componentId: String(component.pcbComponentId || ''),
456
+ footprintId: component.componentKey
457
+ ? 'footprint:' + component.componentKey + ':' + primitive.kind
458
+ : '',
459
+ netName: String(primitive.netName || '').trim(),
460
+ groupIds: CircuitJsonPcbZonePrimitiveBuilder.#uniqueStrings([
461
+ source.pcb_group_id,
462
+ source.source_group_id,
463
+ component.pcb_group_id,
464
+ component.positioned_relative_to_pcb_group_id,
465
+ component.sourceGroupId
466
+ ]),
467
+ subcircuitIds: CircuitJsonPcbZonePrimitiveBuilder.#uniqueStrings([
468
+ source.subcircuit_id,
469
+ source.subcircuitId,
470
+ ...(component.subcircuitIds || [])
471
+ ])
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Merges ring bounds.
477
+ * @param {{ bounds?: object }[]} rings Ring rows.
478
+ * @returns {object | null}
479
+ */
480
+ static #mergedRingBounds(rings) {
481
+ const boundsRows = rings.map((ring) => ring.bounds).filter(Boolean)
482
+ if (!boundsRows.length) return null
483
+ return boundsRows.reduce((bounds, row) =>
484
+ bounds
485
+ ? CircuitJsonPcbPrimitiveGeometry.bounds(
486
+ Math.min(bounds.minX, row.minX),
487
+ Math.min(bounds.minY, row.minY),
488
+ Math.max(bounds.maxX, row.maxX),
489
+ Math.max(bounds.maxY, row.maxY)
490
+ )
491
+ : row
492
+ )
493
+ }
494
+
495
+ /**
496
+ * Drops consecutive duplicate points.
497
+ * @param {{ x: number, y: number }[]} points Ring points.
498
+ * @returns {{ x: number, y: number }[]}
499
+ */
500
+ static #dropConsecutiveDuplicates(points) {
501
+ return points.filter(
502
+ (point, index) =>
503
+ index === 0 ||
504
+ !CircuitJsonPcbZonePrimitiveBuilder.#samePoint(
505
+ point,
506
+ points[index - 1]
507
+ )
508
+ )
509
+ }
510
+
511
+ /**
512
+ * Drops a duplicate closing point.
513
+ * @param {{ x: number, y: number }[]} points Ring points.
514
+ * @returns {{ x: number, y: number }[]}
515
+ */
516
+ static #dropDuplicateClosure(points) {
517
+ if (
518
+ points.length > 1 &&
519
+ CircuitJsonPcbZonePrimitiveBuilder.#samePoint(
520
+ points[0],
521
+ points[points.length - 1]
522
+ )
523
+ ) {
524
+ return points.slice(0, -1)
525
+ }
526
+ return points
527
+ }
528
+
529
+ /**
530
+ * Counts unique point coordinates.
531
+ * @param {{ x: number, y: number }[]} points Ring points.
532
+ * @returns {number}
533
+ */
534
+ static #uniquePointCount(points) {
535
+ return new Set(points.map((point) => point.x + ',' + point.y)).size
536
+ }
537
+
538
+ /**
539
+ * Computes signed polygon area.
540
+ * @param {{ x: number, y: number }[]} points Ring points.
541
+ * @returns {number}
542
+ */
543
+ static #signedArea(points) {
544
+ return (
545
+ points.reduce((sum, point, index) => {
546
+ const next = points[(index + 1) % points.length]
547
+ return sum + point.x * next.y - next.x * point.y
548
+ }, 0) / 2
549
+ )
550
+ }
551
+
552
+ /**
553
+ * Resolves the center of bounds.
554
+ * @param {object} bounds Bounds record.
555
+ * @returns {{ x: number, y: number }}
556
+ */
557
+ static #boundsCenter(bounds) {
558
+ return {
559
+ x: bounds.minX + bounds.width / 2,
560
+ y: bounds.minY + bounds.height / 2
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Returns true when two points have equal coordinates.
566
+ * @param {{ x: number, y: number }} left First point.
567
+ * @param {{ x: number, y: number }} right Second point.
568
+ * @returns {boolean}
569
+ */
570
+ static #samePoint(left, right) {
571
+ return left.x === right.x && left.y === right.y
572
+ }
573
+
574
+ /**
575
+ * Resolves a point candidate.
576
+ * @param {object | null | undefined} value Point candidate.
577
+ * @returns {{ x: number, y: number } | null}
578
+ */
579
+ static #point(value) {
580
+ return CircuitJsonUnits.optionalPoint(value)
581
+ }
582
+
583
+ /**
584
+ * Resolves a normalized layer key.
585
+ * @param {unknown} value Layer candidate.
586
+ * @returns {string}
587
+ */
588
+ static #layer(value) {
589
+ const raw =
590
+ typeof value === 'object' && value !== null ? value.name : value
591
+ const text = String(raw ?? '').trim()
592
+ const lowered = text.toLowerCase()
593
+ if (['top', 'front', 'f.cu', '1'].includes(lowered)) return 'top'
594
+ if (['bottom', 'back', 'b.cu', '32'].includes(lowered)) return 'bottom'
595
+ return text
596
+ }
597
+
598
+ /**
599
+ * Resolves a side from a layer key.
600
+ * @param {string} layer Layer key.
601
+ * @returns {'top' | 'bottom' | ''}
602
+ */
603
+ static #side(layer) {
604
+ const text = String(layer || '').toLowerCase()
605
+ if (/\b(bottom|back)\b|\bb[._-]/u.test(text)) return 'bottom'
606
+ if (/\b(top|front)\b|\bf[._-]/u.test(text)) return 'top'
607
+ return ''
608
+ }
609
+
610
+ /**
611
+ * Resolves a net name from common fields.
612
+ * @param {object} element Element row.
613
+ * @param {string | null} fallback Fallback net name.
614
+ * @returns {string}
615
+ */
616
+ static #netName(element, fallback) {
617
+ return String(
618
+ element?.netName ??
619
+ element?.net ??
620
+ element?.net_name ??
621
+ element?.source_net_name ??
622
+ element?.source_net_id ??
623
+ fallback ??
624
+ ''
625
+ ).trim()
626
+ }
627
+
628
+ /**
629
+ * Resolves a source net id.
630
+ * @param {object} element Element row.
631
+ * @returns {string}
632
+ */
633
+ static #sourceNetId(element) {
634
+ return String(
635
+ element?.source_net_id || element?.sourceNetId || ''
636
+ ).trim()
637
+ }
638
+
639
+ /**
640
+ * Resolves optional mask-coverage metadata.
641
+ * @param {object} element Element row.
642
+ * @returns {boolean | undefined}
643
+ */
644
+ static #coveredWithSolderMask(element) {
645
+ if (Object.hasOwn(element, 'covered_with_solder_mask')) {
646
+ return Boolean(element.covered_with_solder_mask)
647
+ }
648
+ if (Object.hasOwn(element, 'coveredWithSolderMask')) {
649
+ return Boolean(element.coveredWithSolderMask)
650
+ }
651
+ return element?.type === 'pcb_copper_pour' ? true : undefined
652
+ }
653
+
654
+ /**
655
+ * Explains one dropped-ring reason.
656
+ * @param {string} reason Reason code.
657
+ * @returns {string}
658
+ */
659
+ static #reasonMessage(reason) {
660
+ if (reason === 'tiny-area') return 'its area is too small'
661
+ return 'it does not contain three usable points'
662
+ }
663
+
664
+ /**
665
+ * Rounds computed geometry values to stable precision.
666
+ * @param {number} value Numeric value.
667
+ * @returns {number}
668
+ */
669
+ static #round(value) {
670
+ return Math.round(Number(value || 0) * 1_000_000) / 1_000_000
671
+ }
672
+
673
+ /**
674
+ * Resolves unique non-empty string values.
675
+ * @param {unknown[]} values Candidate values.
676
+ * @returns {string[]}
677
+ */
678
+ static #uniqueStrings(values) {
679
+ return [...new Set(values.map((value) => String(value || '').trim()))]
680
+ .filter(Boolean)
681
+ .sort()
682
+ }
683
+ }