pcb-scene3d-viewer 1.1.21 → 1.1.31

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 (55) hide show
  1. package/README.md +3 -2
  2. package/docs/api.md +27 -1
  3. package/docs/circuitjson.md +63 -18
  4. package/docs/model-format.md +13 -2
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +2 -2
  7. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +25 -5
  8. package/src/PcbAssemblyComponentMeshBuilder.mjs +762 -0
  9. package/src/PcbAssemblyFillGeometryResolver.mjs +579 -0
  10. package/src/PcbAssemblyFillRingNormalizer.mjs +209 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +41 -301
  12. package/src/PcbAssemblyGltfModelMeshParser.mjs +912 -0
  13. package/src/PcbAssemblyGltfValidator.mjs +460 -0
  14. package/src/PcbAssemblyGltfWriter.mjs +801 -0
  15. package/src/PcbAssemblyMeshUtils.mjs +117 -0
  16. package/src/PcbAssemblyModelMeshLoader.mjs +18 -0
  17. package/src/PcbAssemblyPadMeshBuilder.mjs +394 -0
  18. package/src/PcbAssemblyStepWriter.mjs +24 -2
  19. package/src/PcbAssemblyTextModelMeshParser.mjs +602 -0
  20. package/src/PcbModelArchiveExporter.mjs +521 -7
  21. package/src/PcbScene3dCircuitJsonAdapter.mjs +409 -7
  22. package/src/PcbScene3dCircuitJsonModelTransform.mjs +232 -0
  23. package/src/PcbScene3dCompanionBasePlacementAdjuster.mjs +242 -0
  24. package/src/PcbScene3dComponentVisibility.mjs +100 -5
  25. package/src/PcbScene3dController.mjs +25 -55
  26. package/src/PcbScene3dCopperDetailFilter.mjs +86 -9
  27. package/src/PcbScene3dCopperDetailGroupBuilder.mjs +186 -15
  28. package/src/PcbScene3dCopperDrillCutoutBuilder.mjs +258 -0
  29. package/src/PcbScene3dCopperFactory.mjs +99 -85
  30. package/src/PcbScene3dCopperFillMeshBuilder.mjs +393 -0
  31. package/src/PcbScene3dCopperLayerFilter.mjs +32 -3
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +17 -14
  33. package/src/PcbScene3dExternalCompanionFallback.mjs +202 -0
  34. package/src/PcbScene3dExternalModelCenteringPolicy.mjs +21 -0
  35. package/src/PcbScene3dExternalModelOpacity.mjs +51 -0
  36. package/src/PcbScene3dExternalModelPlacementRepair.mjs +5 -2
  37. package/src/PcbScene3dExternalModelSourceOriginPolicy.mjs +41 -0
  38. package/src/PcbScene3dExternalModels.mjs +16 -7
  39. package/src/PcbScene3dFallbackBodyFactory.mjs +105 -0
  40. package/src/PcbScene3dFallbackVisibility.mjs +58 -1
  41. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +68 -0
  42. package/src/PcbScene3dPadFactory.mjs +35 -2
  43. package/src/PcbScene3dRenderGroupVisibility.mjs +8 -1
  44. package/src/PcbScene3dRuntime.mjs +94 -100
  45. package/src/PcbScene3dRuntimeHelpers.mjs +39 -0
  46. package/src/PcbScene3dSelectionIndexBuilder.mjs +87 -0
  47. package/src/PcbScene3dSelectionInspectorRenderer.mjs +50 -11
  48. package/src/PcbScene3dSelectionMarkerFactory.mjs +333 -0
  49. package/src/PcbScene3dSelectionMarkerOverlay.mjs +70 -0
  50. package/src/PcbScene3dSelectionStyler.mjs +52 -2
  51. package/src/PcbScene3dStaticBodyFactory.mjs +263 -0
  52. package/src/PcbScene3dText.mjs +1 -0
  53. package/src/PcbScene3dTransparentMeshSplitter.mjs +623 -0
  54. package/src/PcbScene3dViaFactory.mjs +27 -7
  55. package/src/scene3d.mjs +3 -0
@@ -0,0 +1,579 @@
1
+ import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
2
+ import { PcbAssemblyFillRingNormalizer } from './PcbAssemblyFillRingNormalizer.mjs'
3
+
4
+ /**
5
+ * Resolves filled PCB detail primitives into exportable polygon loops.
6
+ */
7
+ export class PcbAssemblyFillGeometryResolver {
8
+ /**
9
+ * Resolves one or more independent fill islands.
10
+ * @param {object} source Filled primitive.
11
+ * @returns {{ outer: number[][], holes: number[][][] }[]}
12
+ */
13
+ static resolveAll(source) {
14
+ return PcbAssemblyFillGeometryResolver.inspect(source).loopSets
15
+ }
16
+
17
+ /**
18
+ * Resolves the outer loop and inner cutout loops for one fill primitive.
19
+ * @param {object} source Filled primitive.
20
+ * @returns {{ outer: number[][], holes: number[][][] }}
21
+ */
22
+ static resolve(source) {
23
+ const report = PcbAssemblyFillGeometryResolver.inspect(source)
24
+ return report.loopSets[0] || { outer: [], holes: [] }
25
+ }
26
+
27
+ /**
28
+ * Resolves fill geometry and reports discarded saved-fill rings.
29
+ * @param {object} source Filled primitive.
30
+ * @returns {{ loopSets: { outer: number[][], holes: number[][][] }[], diagnostics: object[] }}
31
+ */
32
+ static inspect(source) {
33
+ const ringReport =
34
+ PcbAssemblyFillGeometryResolver.#inspectRingGeometry(source)
35
+ if (ringReport.hasRingGeometry) {
36
+ return {
37
+ loopSets: ringReport.loopSets,
38
+ diagnostics: ringReport.diagnostics
39
+ }
40
+ }
41
+
42
+ return {
43
+ loopSets: [PcbAssemblyFillGeometryResolver.#resolveLegacy(source)],
44
+ diagnostics: []
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Resolves non-B-Rep fill geometry.
50
+ * @param {object} source Filled primitive.
51
+ * @returns {{ outer: number[][], holes: number[][][] }}
52
+ */
53
+ static #resolveLegacy(source) {
54
+ const contours = PcbAssemblyFillGeometryResolver.#contours(source)
55
+ if (contours.length) {
56
+ return {
57
+ outer: contours[0],
58
+ holes: contours.slice(1)
59
+ }
60
+ }
61
+
62
+ return {
63
+ outer: PcbAssemblyFillGeometryResolver.#outerLoop(source),
64
+ holes: PcbAssemblyFillGeometryResolver.#holeLoops(source)
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Resolves ring-style geometry and diagnostics when present.
70
+ * @param {object} source Filled primitive.
71
+ * @returns {{ hasRingGeometry: boolean, loopSets: { outer: number[][], holes: number[][][] }[], diagnostics: object[] }}
72
+ */
73
+ static #inspectRingGeometry(source) {
74
+ const shapes = PcbAssemblyFillGeometryResolver.#ringShapes(source)
75
+ if (shapes.length) {
76
+ return PcbAssemblyFillGeometryResolver.#inspectRingShapes(shapes)
77
+ }
78
+
79
+ const shape = source?.brep_shape || source?.brepShape || null
80
+ if (!shape) {
81
+ return {
82
+ hasRingGeometry: false,
83
+ loopSets: [],
84
+ diagnostics: []
85
+ }
86
+ }
87
+
88
+ return PcbAssemblyFillGeometryResolver.#inspectRingShapes([shape])
89
+ }
90
+
91
+ /**
92
+ * Resolves ring-style geometry from shape objects.
93
+ * @param {object[]} shapes Ring shape objects.
94
+ * @returns {{ hasRingGeometry: boolean, loopSets: { outer: number[][], holes: number[][][] }[], diagnostics: object[] }}
95
+ */
96
+ static #inspectRingShapes(shapes) {
97
+ const diagnostics = []
98
+ const loopSets = shapes
99
+ .map((shape, shapeIndex) =>
100
+ PcbAssemblyFillGeometryResolver.#ringGeometryFromShape(
101
+ shape,
102
+ shapeIndex,
103
+ diagnostics
104
+ )
105
+ )
106
+ .filter((geometry) =>
107
+ PcbAssemblyFillGeometryResolver.#isValidLoop(geometry.outer)
108
+ )
109
+
110
+ return {
111
+ hasRingGeometry: true,
112
+ loopSets,
113
+ diagnostics
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Resolves ring-style geometry from one shape object.
119
+ * @param {object} shape Ring shape object.
120
+ * @param {number} shapeIndex Shape index.
121
+ * @param {object[]} diagnostics Diagnostic accumulator.
122
+ * @returns {{ outer: number[][], holes: number[][][] }}
123
+ */
124
+ static #ringGeometryFromShape(shape, shapeIndex, diagnostics) {
125
+ const outer = PcbAssemblyFillGeometryResolver.#normalizedRingLoop(
126
+ shape.outer_ring ||
127
+ shape.outerRing ||
128
+ shape.outer ||
129
+ shape.outer_loop ||
130
+ shape.outerLoop,
131
+ {
132
+ role: 'outer',
133
+ shapeIndex,
134
+ ringIndex: 0,
135
+ winding: 'positive'
136
+ },
137
+ diagnostics
138
+ )
139
+ return {
140
+ outer,
141
+ holes: PcbAssemblyFillGeometryResolver.#array(
142
+ shape.inner_rings ||
143
+ shape.innerRings ||
144
+ shape.holes ||
145
+ shape.inner ||
146
+ shape.cutouts
147
+ )
148
+ .map((ring, ringIndex) =>
149
+ PcbAssemblyFillGeometryResolver.#normalizedRingLoop(
150
+ ring,
151
+ {
152
+ role: 'hole',
153
+ shapeIndex,
154
+ ringIndex,
155
+ winding: 'negative'
156
+ },
157
+ diagnostics
158
+ )
159
+ )
160
+ .filter((loop) =>
161
+ PcbAssemblyFillGeometryResolver.#isValidLoop(loop)
162
+ )
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Resolves independent ring-shape islands.
168
+ * @param {object} source Filled primitive.
169
+ * @returns {object[]}
170
+ */
171
+ static #ringShapes(source) {
172
+ return PcbAssemblyFillGeometryResolver.#array(
173
+ source?.brep_shapes ||
174
+ source?.brepShapes ||
175
+ source?.brep_shape_array ||
176
+ source?.brepShapeArray
177
+ )
178
+ }
179
+
180
+ /**
181
+ * Resolves a ring object into normalized loop geometry.
182
+ * @param {object | object[]} ring Ring source.
183
+ * @param {{ role: string, shapeIndex: number, ringIndex: number, winding: 'positive' | 'negative' }} options Normalization options.
184
+ * @param {object[]} diagnostics Diagnostic accumulator.
185
+ * @returns {number[][]}
186
+ */
187
+ static #normalizedRingLoop(ring, options, diagnostics) {
188
+ const report = PcbAssemblyFillRingNormalizer.normalize(
189
+ PcbAssemblyFillGeometryResolver.#expandBulgedPoints(
190
+ ring?.vertices ||
191
+ ring?.cwVertices ||
192
+ ring?.cw_vertices ||
193
+ ring?.points ||
194
+ ring ||
195
+ []
196
+ ),
197
+ options
198
+ )
199
+ if (report.diagnostic) {
200
+ diagnostics.push(report.diagnostic)
201
+ }
202
+ return report.loop
203
+ }
204
+
205
+ /**
206
+ * Samples point-level bulge arcs in one ring.
207
+ * @param {unknown[]} points Candidate ring points.
208
+ * @returns {number[][]}
209
+ */
210
+ static #expandBulgedPoints(points) {
211
+ const vertices = PcbAssemblyFillGeometryResolver.#array(points).map(
212
+ (point) => ({
213
+ point: PcbAssemblyFillGeometryResolver.#point(point),
214
+ bulge: PcbAssemblyFillGeometryResolver.#pointBulge(point)
215
+ })
216
+ )
217
+ const loop =
218
+ PcbAssemblyFillGeometryResolver.#removeClosingVertex(vertices)
219
+ const expanded = []
220
+
221
+ for (let index = 0; index < loop.length; index += 1) {
222
+ const current = loop[index]
223
+ const next = loop[(index + 1) % loop.length]
224
+ if (!current?.point) {
225
+ expanded.push([NaN, NaN])
226
+ continue
227
+ }
228
+
229
+ expanded.push(current.point)
230
+ if (next?.point) {
231
+ expanded.push(
232
+ ...PcbAssemblyFillGeometryResolver.#bulgeArcPoints(
233
+ current.point,
234
+ next.point,
235
+ current.bulge
236
+ )
237
+ )
238
+ }
239
+ }
240
+
241
+ return expanded
242
+ }
243
+
244
+ /**
245
+ * Removes an explicit closing vertex while preserving per-segment bulge data.
246
+ * @param {{ point: number[] | null, bulge: number }[]} vertices Ring vertices.
247
+ * @returns {{ point: number[] | null, bulge: number }[]}
248
+ */
249
+ static #removeClosingVertex(vertices) {
250
+ const first = vertices[0]?.point
251
+ const last = vertices[vertices.length - 1]?.point
252
+
253
+ if (
254
+ first &&
255
+ last &&
256
+ Math.abs(first[0] - last[0]) < 0.001 &&
257
+ Math.abs(first[1] - last[1]) < 0.001
258
+ ) {
259
+ return vertices.slice(0, -1)
260
+ }
261
+
262
+ return vertices
263
+ }
264
+
265
+ /**
266
+ * Resolves a bulge value from object or tuple point forms.
267
+ * @param {unknown} point Candidate point.
268
+ * @returns {number}
269
+ */
270
+ static #pointBulge(point) {
271
+ const value = Array.isArray(point) ? point[2] : point?.bulge
272
+ const bulge = Number(value || 0)
273
+ return Number.isFinite(bulge) ? bulge : 0
274
+ }
275
+
276
+ /**
277
+ * Samples the arc between two points from a polyline bulge value.
278
+ * @param {number[]} start Arc start point.
279
+ * @param {number[]} end Arc end point.
280
+ * @param {number} bulge Polyline bulge value.
281
+ * @returns {number[][]}
282
+ */
283
+ static #bulgeArcPoints(start, end, bulge) {
284
+ if (Math.abs(bulge) <= 0.001) {
285
+ return []
286
+ }
287
+
288
+ const dx = end[0] - start[0]
289
+ const dy = end[1] - start[1]
290
+ const chordLength = Math.hypot(dx, dy)
291
+ if (chordLength <= 0.001) {
292
+ return []
293
+ }
294
+
295
+ const sweep = 4 * Math.atan(bulge)
296
+ const radius = chordLength / (2 * Math.sin(Math.abs(sweep) / 2))
297
+ const midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
298
+ const centerOffset = (chordLength * (1 - bulge * bulge)) / (4 * bulge)
299
+ const center = [
300
+ midpoint[0] - (dy / chordLength) * centerOffset,
301
+ midpoint[1] + (dx / chordLength) * centerOffset
302
+ ]
303
+ const startAngle = Math.atan2(
304
+ start[1] - center[1],
305
+ start[0] - center[0]
306
+ )
307
+ const segments = Math.max(
308
+ Math.ceil(Math.abs(sweep) / (Math.PI / 16)),
309
+ 4
310
+ )
311
+ const points = []
312
+
313
+ for (let index = 1; index < segments; index += 1) {
314
+ const angle = startAngle + (sweep * index) / segments
315
+ points.push([
316
+ center[0] + Math.cos(angle) * radius,
317
+ center[1] + Math.sin(angle) * radius
318
+ ])
319
+ }
320
+
321
+ return points
322
+ }
323
+
324
+ /**
325
+ * Resolves contour loops from a multi-contour fill primitive.
326
+ * @param {object} source Filled primitive.
327
+ * @returns {number[][][]}
328
+ */
329
+ static #contours(source) {
330
+ return PcbAssemblyFillGeometryResolver.#array(source?.contours)
331
+ .map((contour) =>
332
+ PcbAssemblyFillGeometryResolver.#pointOrSegmentLoop(contour)
333
+ )
334
+ .filter((loop) =>
335
+ PcbAssemblyFillGeometryResolver.#isValidLoop(loop)
336
+ )
337
+ }
338
+
339
+ /**
340
+ * Resolves the primary fill outline.
341
+ * @param {object} source Filled primitive.
342
+ * @returns {number[][]}
343
+ */
344
+ static #outerLoop(source) {
345
+ const explicitLoop = PcbAssemblyFillGeometryResolver.#pointLoop(
346
+ source?.points || source?.vertices || source?.polygon || []
347
+ )
348
+ if (explicitLoop.length >= 3) {
349
+ return explicitLoop
350
+ }
351
+
352
+ return PcbAssemblyFillGeometryResolver.#rectangleLoop(source)
353
+ }
354
+
355
+ /**
356
+ * Resolves authored cutout loops.
357
+ * @param {object} source Filled primitive.
358
+ * @returns {number[][][]}
359
+ */
360
+ static #holeLoops(source) {
361
+ return PcbAssemblyFillGeometryResolver.#array(source?.holes)
362
+ .map((hole) =>
363
+ PcbAssemblyFillGeometryResolver.#pointOrSegmentLoop(hole)
364
+ )
365
+ .filter((loop) =>
366
+ PcbAssemblyFillGeometryResolver.#isValidLoop(loop)
367
+ )
368
+ }
369
+
370
+ /**
371
+ * Resolves a contour that may contain points or segment records.
372
+ * @param {unknown[]} entries Contour entries.
373
+ * @returns {number[][]}
374
+ */
375
+ static #pointOrSegmentLoop(entries) {
376
+ const list = PcbAssemblyFillGeometryResolver.#array(entries)
377
+ if (!list.length) {
378
+ return []
379
+ }
380
+
381
+ const pointLoop = PcbAssemblyFillGeometryResolver.#pointLoop(list)
382
+ if (pointLoop.length >= 3) {
383
+ return pointLoop
384
+ }
385
+
386
+ return PcbAssemblyFillGeometryResolver.#segmentLoop(list)
387
+ }
388
+
389
+ /**
390
+ * Resolves a point loop from object or tuple points.
391
+ * @param {unknown[]} points Candidate points.
392
+ * @returns {number[][]}
393
+ */
394
+ static #pointLoop(points) {
395
+ const loop = PcbAssemblyFillGeometryResolver.#array(points)
396
+ .map((point) => PcbAssemblyFillGeometryResolver.#point(point))
397
+ .filter(Boolean)
398
+
399
+ return PcbAssemblyMeshUtils.cleanLoop(loop)
400
+ }
401
+
402
+ /**
403
+ * Resolves a point from an object or tuple.
404
+ * @param {unknown} point Candidate point.
405
+ * @returns {number[] | null}
406
+ */
407
+ static #point(point) {
408
+ const x = Array.isArray(point) ? point[0] : point?.x
409
+ const y = Array.isArray(point) ? point[1] : point?.y
410
+ const normalized = [Number(x), Number(y)]
411
+
412
+ return normalized.every(Number.isFinite) ? normalized : null
413
+ }
414
+
415
+ /**
416
+ * Resolves an ordered loop from line or arc segment records.
417
+ * @param {object[]} segments Segment records.
418
+ * @returns {number[][]}
419
+ */
420
+ static #segmentLoop(segments) {
421
+ const points = []
422
+
423
+ segments.forEach((segment, index) => {
424
+ if (index === 0) {
425
+ const start =
426
+ PcbAssemblyFillGeometryResolver.#segmentStart(segment)
427
+ if (start) points.push(start)
428
+ }
429
+
430
+ points.push(
431
+ ...PcbAssemblyFillGeometryResolver.#segmentTailPoints(segment)
432
+ )
433
+ })
434
+
435
+ return PcbAssemblyMeshUtils.cleanLoop(points)
436
+ }
437
+
438
+ /**
439
+ * Resolves the start point for one segment.
440
+ * @param {object} segment Segment record.
441
+ * @returns {number[] | null}
442
+ */
443
+ static #segmentStart(segment) {
444
+ return (
445
+ PcbAssemblyFillGeometryResolver.#fieldPoint(segment, 'x1', 'y1') ||
446
+ PcbAssemblyFillGeometryResolver.#fieldPoint(
447
+ segment,
448
+ 'startX',
449
+ 'startY'
450
+ ) ||
451
+ PcbAssemblyFillGeometryResolver.#point(segment?.start)
452
+ )
453
+ }
454
+
455
+ /**
456
+ * Resolves the end or sampled arc points for one segment.
457
+ * @param {object} segment Segment record.
458
+ * @returns {number[][]}
459
+ */
460
+ static #segmentTailPoints(segment) {
461
+ const isArc =
462
+ String(segment?.type || '').toLowerCase() === 'arc' ||
463
+ Number.isFinite(Number(segment?.radius))
464
+ if (isArc) {
465
+ return PcbAssemblyFillGeometryResolver.#arcPoints(segment)
466
+ }
467
+
468
+ const end =
469
+ PcbAssemblyFillGeometryResolver.#fieldPoint(segment, 'x2', 'y2') ||
470
+ PcbAssemblyFillGeometryResolver.#fieldPoint(
471
+ segment,
472
+ 'endX',
473
+ 'endY'
474
+ ) ||
475
+ PcbAssemblyFillGeometryResolver.#point(segment?.end)
476
+
477
+ return end ? [end] : []
478
+ }
479
+
480
+ /**
481
+ * Samples an arc segment into tail points.
482
+ * @param {object} arc Arc segment.
483
+ * @returns {number[][]}
484
+ */
485
+ static #arcPoints(arc) {
486
+ const center = PcbAssemblyFillGeometryResolver.#arcCenter(arc)
487
+ const radius = Number(arc?.radius || 0)
488
+ if (!Number.isFinite(radius) || radius <= 0) {
489
+ return []
490
+ }
491
+
492
+ const start = Number(arc?.startAngle || 0)
493
+ const sweep = PcbAssemblyMeshUtils.resolveSweep(arc)
494
+ const segments = Math.max(Math.ceil(Math.abs(sweep) / 8), 4)
495
+ const points = []
496
+
497
+ for (let index = 1; index <= segments; index += 1) {
498
+ const angle = ((start + (sweep * index) / segments) * Math.PI) / 180
499
+ points.push([
500
+ center.x + Math.cos(angle) * radius,
501
+ center.y + Math.sin(angle) * radius
502
+ ])
503
+ }
504
+
505
+ return points
506
+ }
507
+
508
+ /**
509
+ * Resolves the center point for an arc segment.
510
+ * @param {object} arc Arc segment.
511
+ * @returns {{ x: number, y: number }}
512
+ */
513
+ static #arcCenter(arc) {
514
+ return {
515
+ x: Number(arc?.x ?? arc?.cx ?? arc?.centerX ?? arc?.center?.x ?? 0),
516
+ y: Number(arc?.y ?? arc?.cy ?? arc?.centerY ?? arc?.center?.y ?? 0)
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Resolves a rectangle fill loop from corner fields.
522
+ * @param {object} source Fill primitive.
523
+ * @returns {number[][]}
524
+ */
525
+ static #rectangleLoop(source) {
526
+ const x1 = Number(source?.x1 ?? source?.left)
527
+ const y1 = Number(source?.y1 ?? source?.top)
528
+ const x2 = Number(source?.x2 ?? source?.right)
529
+ const y2 = Number(source?.y2 ?? source?.bottom)
530
+ if (![x1, y1, x2, y2].every(Number.isFinite)) {
531
+ return []
532
+ }
533
+
534
+ const minX = Math.min(x1, x2)
535
+ const maxX = Math.max(x1, x2)
536
+ const minY = Math.min(y1, y2)
537
+ const maxY = Math.max(y1, y2)
538
+ if (maxX - minX <= 0 || maxY - minY <= 0) {
539
+ return []
540
+ }
541
+
542
+ return [
543
+ [minX, minY],
544
+ [maxX, minY],
545
+ [maxX, maxY],
546
+ [minX, maxY]
547
+ ]
548
+ }
549
+
550
+ /**
551
+ * Resolves a finite point from paired fields.
552
+ * @param {object} source Source object.
553
+ * @param {string} xField X field.
554
+ * @param {string} yField Y field.
555
+ * @returns {number[] | null}
556
+ */
557
+ static #fieldPoint(source, xField, yField) {
558
+ const point = [Number(source?.[xField]), Number(source?.[yField])]
559
+ return point.every(Number.isFinite) ? point : null
560
+ }
561
+
562
+ /**
563
+ * Checks whether one loop has enough non-collinear area.
564
+ * @param {number[][]} loop Candidate loop.
565
+ * @returns {boolean}
566
+ */
567
+ static #isValidLoop(loop) {
568
+ return PcbAssemblyFillRingNormalizer.isValidLoop(loop)
569
+ }
570
+
571
+ /**
572
+ * Normalizes a value to an array.
573
+ * @param {unknown} value Candidate value.
574
+ * @returns {any[]}
575
+ */
576
+ static #array(value) {
577
+ return Array.isArray(value) ? value : []
578
+ }
579
+ }