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,503 @@
1
+ /**
2
+ * Computes copper-to-copper distances from normalized PCB primitives.
3
+ */
4
+ export class CircuitJsonPcbCopperGeometry {
5
+ /**
6
+ * Resolves the nearest edge clearance between two copper primitives.
7
+ * @param {object} left First primitive.
8
+ * @param {object} right Second primitive.
9
+ * @returns {number | null}
10
+ */
11
+ static clearance(left, right) {
12
+ const leftShapes = CircuitJsonPcbCopperGeometry.#shapes(left)
13
+ const rightShapes = CircuitJsonPcbCopperGeometry.#shapes(right)
14
+ if (!leftShapes.length || !rightShapes.length) return null
15
+
16
+ let best = Infinity
17
+ for (const leftShape of leftShapes) {
18
+ for (const rightShape of rightShapes) {
19
+ best = Math.min(
20
+ best,
21
+ CircuitJsonPcbCopperGeometry.#shapeDistance(
22
+ leftShape,
23
+ rightShape
24
+ )
25
+ )
26
+ }
27
+ }
28
+
29
+ return Number.isFinite(best) ? Math.max(best, 0) : null
30
+ }
31
+
32
+ /**
33
+ * Builds copper shapes for one primitive.
34
+ * @param {object} primitive Primitive row.
35
+ * @returns {object[]}
36
+ */
37
+ static #shapes(primitive) {
38
+ if (primitive.kind === 'track') {
39
+ return [
40
+ {
41
+ type: 'capsule',
42
+ start: { x: primitive.x1, y: primitive.y1 },
43
+ end: { x: primitive.x2, y: primitive.y2 },
44
+ radius: Number(primitive.width || 0) / 2
45
+ }
46
+ ]
47
+ }
48
+ if (primitive.kind === 'via') {
49
+ return [
50
+ {
51
+ type: 'circle',
52
+ center: { x: primitive.x, y: primitive.y },
53
+ radius: Number(primitive.diameter || 0) / 2
54
+ }
55
+ ]
56
+ }
57
+ if (primitive.kind === 'zone') {
58
+ return CircuitJsonPcbCopperGeometry.#polygonShape(primitive.points)
59
+ }
60
+ if (primitive.kind === 'pad') {
61
+ return CircuitJsonPcbCopperGeometry.#padShapes(primitive)
62
+ }
63
+ return []
64
+ }
65
+
66
+ /**
67
+ * Builds copper shapes for a pad primitive.
68
+ * @param {object} primitive Pad primitive.
69
+ * @returns {object[]}
70
+ */
71
+ static #padShapes(primitive) {
72
+ const shape = String(primitive.shape || 'rect').toLowerCase()
73
+ if (shape === 'circle') {
74
+ return [
75
+ {
76
+ type: 'circle',
77
+ center: { x: primitive.x, y: primitive.y },
78
+ radius:
79
+ Math.max(
80
+ Number(primitive.width || 0),
81
+ Number(primitive.height || 0)
82
+ ) / 2
83
+ }
84
+ ]
85
+ }
86
+ if (shape === 'pill' || shape === 'rotated_pill') {
87
+ return [
88
+ CircuitJsonPcbCopperGeometry.#pillShape(
89
+ primitive,
90
+ shape === 'rotated_pill'
91
+ )
92
+ ]
93
+ }
94
+ if (shape === 'polygon') {
95
+ return CircuitJsonPcbCopperGeometry.#polygonShape(primitive.points)
96
+ }
97
+ return [
98
+ {
99
+ type: 'polygon',
100
+ points: CircuitJsonPcbCopperGeometry.#rectPoints(primitive)
101
+ }
102
+ ]
103
+ }
104
+
105
+ /**
106
+ * Builds a capsule for a pill pad.
107
+ * @param {object} primitive Pad primitive.
108
+ * @param {boolean} forceRotation Whether rotation should be applied.
109
+ * @returns {object}
110
+ */
111
+ static #pillShape(primitive, forceRotation) {
112
+ const width = Number(primitive.width || primitive.bounds?.width || 0)
113
+ const height = Number(primitive.height || primitive.bounds?.height || 0)
114
+ const radius = Math.min(width, height) / 2
115
+ const run = Math.max(width, height) / 2 - radius
116
+ const angle =
117
+ ((forceRotation ? Number(primitive.rotation || 0) : 0) * Math.PI) /
118
+ 180
119
+ const axis = width >= height ? 0 : Math.PI / 2
120
+ const dx = Math.cos(angle + axis) * run
121
+ const dy = Math.sin(angle + axis) * run
122
+ return {
123
+ type: 'capsule',
124
+ start: { x: Number(primitive.x) - dx, y: Number(primitive.y) - dy },
125
+ end: { x: Number(primitive.x) + dx, y: Number(primitive.y) + dy },
126
+ radius
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Builds a polygon shape when enough points are present.
132
+ * @param {object[]} points Source points.
133
+ * @returns {object[]}
134
+ */
135
+ static #polygonShape(points) {
136
+ const normalized = (Array.isArray(points) ? points : [])
137
+ .map((point) => ({
138
+ x: Number(point?.x),
139
+ y: Number(point?.y)
140
+ }))
141
+ .filter(
142
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
143
+ )
144
+ return normalized.length >= 3
145
+ ? [{ type: 'polygon', points: normalized }]
146
+ : []
147
+ }
148
+
149
+ /**
150
+ * Builds rotated rectangle corner points.
151
+ * @param {object} primitive Rectangular primitive.
152
+ * @returns {{ x: number, y: number }[]}
153
+ */
154
+ static #rectPoints(primitive) {
155
+ const center = { x: Number(primitive.x), y: Number(primitive.y) }
156
+ const width = Number(primitive.width || primitive.bounds?.width || 0)
157
+ const height = Number(primitive.height || primitive.bounds?.height || 0)
158
+ const angle = (Number(primitive.rotation || 0) * Math.PI) / 180
159
+ return [
160
+ { x: -width / 2, y: -height / 2 },
161
+ { x: width / 2, y: -height / 2 },
162
+ { x: width / 2, y: height / 2 },
163
+ { x: -width / 2, y: height / 2 }
164
+ ].map((point) => ({
165
+ x: center.x + point.x * Math.cos(angle) - point.y * Math.sin(angle),
166
+ y: center.y + point.x * Math.sin(angle) + point.y * Math.cos(angle)
167
+ }))
168
+ }
169
+
170
+ /**
171
+ * Computes distance between two primitive shapes.
172
+ * @param {object} left First shape.
173
+ * @param {object} right Second shape.
174
+ * @returns {number}
175
+ */
176
+ static #shapeDistance(left, right) {
177
+ if (left.type === 'circle' && right.type === 'circle') {
178
+ return (
179
+ CircuitJsonPcbCopperGeometry.#pointDistance(
180
+ left.center,
181
+ right.center
182
+ ) -
183
+ left.radius -
184
+ right.radius
185
+ )
186
+ }
187
+ if (left.type === 'capsule' && right.type === 'capsule') {
188
+ return (
189
+ CircuitJsonPcbCopperGeometry.#segmentDistance(
190
+ left.start,
191
+ left.end,
192
+ right.start,
193
+ right.end
194
+ ) -
195
+ left.radius -
196
+ right.radius
197
+ )
198
+ }
199
+ if (left.type === 'circle' && right.type === 'capsule') {
200
+ return CircuitJsonPcbCopperGeometry.#circleCapsuleDistance(
201
+ left,
202
+ right
203
+ )
204
+ }
205
+ if (left.type === 'capsule' && right.type === 'circle') {
206
+ return CircuitJsonPcbCopperGeometry.#circleCapsuleDistance(
207
+ right,
208
+ left
209
+ )
210
+ }
211
+ if (left.type === 'polygon' && right.type === 'polygon') {
212
+ return CircuitJsonPcbCopperGeometry.#polygonDistance(
213
+ left.points,
214
+ right.points
215
+ )
216
+ }
217
+ if (left.type === 'circle' && right.type === 'polygon') {
218
+ return CircuitJsonPcbCopperGeometry.#circlePolygonDistance(
219
+ left,
220
+ right.points
221
+ )
222
+ }
223
+ if (left.type === 'polygon' && right.type === 'circle') {
224
+ return CircuitJsonPcbCopperGeometry.#circlePolygonDistance(
225
+ right,
226
+ left.points
227
+ )
228
+ }
229
+ if (left.type === 'capsule' && right.type === 'polygon') {
230
+ return CircuitJsonPcbCopperGeometry.#capsulePolygonDistance(
231
+ left,
232
+ right.points
233
+ )
234
+ }
235
+ return CircuitJsonPcbCopperGeometry.#capsulePolygonDistance(
236
+ right,
237
+ left.points
238
+ )
239
+ }
240
+
241
+ /**
242
+ * Computes circle-to-capsule edge distance.
243
+ * @param {object} circle Circle shape.
244
+ * @param {object} capsule Capsule shape.
245
+ * @returns {number}
246
+ */
247
+ static #circleCapsuleDistance(circle, capsule) {
248
+ return (
249
+ CircuitJsonPcbCopperGeometry.#pointSegmentDistance(
250
+ circle.center,
251
+ capsule.start,
252
+ capsule.end
253
+ ) -
254
+ circle.radius -
255
+ capsule.radius
256
+ )
257
+ }
258
+
259
+ /**
260
+ * Computes circle-to-polygon edge distance.
261
+ * @param {object} circle Circle shape.
262
+ * @param {object[]} polygon Polygon points.
263
+ * @returns {number}
264
+ */
265
+ static #circlePolygonDistance(circle, polygon) {
266
+ if (
267
+ CircuitJsonPcbCopperGeometry.#pointInPolygon(circle.center, polygon)
268
+ ) {
269
+ return 0
270
+ }
271
+ return (
272
+ Math.min(
273
+ ...CircuitJsonPcbCopperGeometry.#edges(polygon).map((edge) =>
274
+ CircuitJsonPcbCopperGeometry.#pointSegmentDistance(
275
+ circle.center,
276
+ edge[0],
277
+ edge[1]
278
+ )
279
+ )
280
+ ) - circle.radius
281
+ )
282
+ }
283
+
284
+ /**
285
+ * Computes capsule-to-polygon edge distance.
286
+ * @param {object} capsule Capsule shape.
287
+ * @param {object[]} polygon Polygon points.
288
+ * @returns {number}
289
+ */
290
+ static #capsulePolygonDistance(capsule, polygon) {
291
+ if (
292
+ CircuitJsonPcbCopperGeometry.#pointInPolygon(
293
+ capsule.start,
294
+ polygon
295
+ ) ||
296
+ CircuitJsonPcbCopperGeometry.#pointInPolygon(capsule.end, polygon)
297
+ ) {
298
+ return 0
299
+ }
300
+ const distance = Math.min(
301
+ ...CircuitJsonPcbCopperGeometry.#edges(polygon).map((edge) =>
302
+ CircuitJsonPcbCopperGeometry.#segmentDistance(
303
+ capsule.start,
304
+ capsule.end,
305
+ edge[0],
306
+ edge[1]
307
+ )
308
+ )
309
+ )
310
+ return distance - capsule.radius
311
+ }
312
+
313
+ /**
314
+ * Computes polygon-to-polygon edge distance.
315
+ * @param {object[]} left First polygon.
316
+ * @param {object[]} right Second polygon.
317
+ * @returns {number}
318
+ */
319
+ static #polygonDistance(left, right) {
320
+ if (
321
+ left.some((point) =>
322
+ CircuitJsonPcbCopperGeometry.#pointInPolygon(point, right)
323
+ ) ||
324
+ right.some((point) =>
325
+ CircuitJsonPcbCopperGeometry.#pointInPolygon(point, left)
326
+ )
327
+ ) {
328
+ return 0
329
+ }
330
+ return Math.min(
331
+ ...CircuitJsonPcbCopperGeometry.#edges(left).flatMap((leftEdge) =>
332
+ CircuitJsonPcbCopperGeometry.#edges(right).map((rightEdge) =>
333
+ CircuitJsonPcbCopperGeometry.#segmentDistance(
334
+ leftEdge[0],
335
+ leftEdge[1],
336
+ rightEdge[0],
337
+ rightEdge[1]
338
+ )
339
+ )
340
+ )
341
+ )
342
+ }
343
+
344
+ /**
345
+ * Builds polygon edge pairs.
346
+ * @param {object[]} points Polygon points.
347
+ * @returns {object[][]}
348
+ */
349
+ static #edges(points) {
350
+ return points.map((point, index) => [
351
+ point,
352
+ points[(index + 1) % points.length]
353
+ ])
354
+ }
355
+
356
+ /**
357
+ * Computes point distance.
358
+ * @param {object} left First point.
359
+ * @param {object} right Second point.
360
+ * @returns {number}
361
+ */
362
+ static #pointDistance(left, right) {
363
+ return Math.hypot(
364
+ Number(left.x) - Number(right.x),
365
+ Number(left.y) - Number(right.y)
366
+ )
367
+ }
368
+
369
+ /**
370
+ * Computes point-to-segment distance.
371
+ * @param {object} point Point.
372
+ * @param {object} start Segment start.
373
+ * @param {object} end Segment end.
374
+ * @returns {number}
375
+ */
376
+ static #pointSegmentDistance(point, start, end) {
377
+ const dx = end.x - start.x
378
+ const dy = end.y - start.y
379
+ const lengthSq = dx * dx + dy * dy
380
+ if (!lengthSq)
381
+ return CircuitJsonPcbCopperGeometry.#pointDistance(point, start)
382
+ const t = Math.max(
383
+ 0,
384
+ Math.min(
385
+ 1,
386
+ ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSq
387
+ )
388
+ )
389
+ return CircuitJsonPcbCopperGeometry.#pointDistance(point, {
390
+ x: start.x + dx * t,
391
+ y: start.y + dy * t
392
+ })
393
+ }
394
+
395
+ /**
396
+ * Computes segment-to-segment distance.
397
+ * @param {object} leftStart First segment start.
398
+ * @param {object} leftEnd First segment end.
399
+ * @param {object} rightStart Second segment start.
400
+ * @param {object} rightEnd Second segment end.
401
+ * @returns {number}
402
+ */
403
+ static #segmentDistance(leftStart, leftEnd, rightStart, rightEnd) {
404
+ if (
405
+ CircuitJsonPcbCopperGeometry.#segmentsIntersect(
406
+ leftStart,
407
+ leftEnd,
408
+ rightStart,
409
+ rightEnd
410
+ )
411
+ ) {
412
+ return 0
413
+ }
414
+ return Math.min(
415
+ CircuitJsonPcbCopperGeometry.#pointSegmentDistance(
416
+ leftStart,
417
+ rightStart,
418
+ rightEnd
419
+ ),
420
+ CircuitJsonPcbCopperGeometry.#pointSegmentDistance(
421
+ leftEnd,
422
+ rightStart,
423
+ rightEnd
424
+ ),
425
+ CircuitJsonPcbCopperGeometry.#pointSegmentDistance(
426
+ rightStart,
427
+ leftStart,
428
+ leftEnd
429
+ ),
430
+ CircuitJsonPcbCopperGeometry.#pointSegmentDistance(
431
+ rightEnd,
432
+ leftStart,
433
+ leftEnd
434
+ )
435
+ )
436
+ }
437
+
438
+ /**
439
+ * Returns true when two segments intersect.
440
+ * @param {object} leftStart First segment start.
441
+ * @param {object} leftEnd First segment end.
442
+ * @param {object} rightStart Second segment start.
443
+ * @param {object} rightEnd Second segment end.
444
+ * @returns {boolean}
445
+ */
446
+ static #segmentsIntersect(leftStart, leftEnd, rightStart, rightEnd) {
447
+ const o1 = CircuitJsonPcbCopperGeometry.#orientation(
448
+ leftStart,
449
+ leftEnd,
450
+ rightStart
451
+ )
452
+ const o2 = CircuitJsonPcbCopperGeometry.#orientation(
453
+ leftStart,
454
+ leftEnd,
455
+ rightEnd
456
+ )
457
+ const o3 = CircuitJsonPcbCopperGeometry.#orientation(
458
+ rightStart,
459
+ rightEnd,
460
+ leftStart
461
+ )
462
+ const o4 = CircuitJsonPcbCopperGeometry.#orientation(
463
+ rightStart,
464
+ rightEnd,
465
+ leftEnd
466
+ )
467
+ return o1 * o2 < 0 && o3 * o4 < 0
468
+ }
469
+
470
+ /**
471
+ * Computes segment orientation.
472
+ * @param {object} a First point.
473
+ * @param {object} b Second point.
474
+ * @param {object} c Third point.
475
+ * @returns {number}
476
+ */
477
+ static #orientation(a, b, c) {
478
+ return Math.sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x))
479
+ }
480
+
481
+ /**
482
+ * Returns true when a point is inside a polygon.
483
+ * @param {object} point Point.
484
+ * @param {object[]} polygon Polygon points.
485
+ * @returns {boolean}
486
+ */
487
+ static #pointInPolygon(point, polygon) {
488
+ let inside = false
489
+ for (
490
+ let index = 0, previous = polygon.length - 1;
491
+ index < polygon.length;
492
+ previous = index, index += 1
493
+ ) {
494
+ const a = polygon[index]
495
+ const b = polygon[previous]
496
+ const intersects =
497
+ a.y > point.y !== b.y > point.y &&
498
+ point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x
499
+ if (intersects) inside = !inside
500
+ }
501
+ return inside
502
+ }
503
+ }
@@ -0,0 +1,88 @@
1
+ import { CircuitJsonUnits } from './CircuitJsonUnits.mjs'
2
+
3
+ /**
4
+ * Normalizes drawing style fields from PCB documentation rows.
5
+ */
6
+ export class CircuitJsonPcbDrawingStyle {
7
+ /**
8
+ * Resolves drawing style metadata from one element row.
9
+ * @param {object} element Element row.
10
+ * @returns {{ strokeColor?: string, fillColor?: string, dashArray?: string }}
11
+ */
12
+ static fromElement(element) {
13
+ return CircuitJsonPcbDrawingStyle.#clean({
14
+ strokeColor: CircuitJsonPcbDrawingStyle.#safeColor(
15
+ element.stroke_color || element.strokeColor || element.color
16
+ ),
17
+ fillColor: CircuitJsonPcbDrawingStyle.#safeColor(
18
+ element.fill_color || element.fillColor
19
+ ),
20
+ dashArray: CircuitJsonPcbDrawingStyle.#dashArray(element)
21
+ })
22
+ }
23
+
24
+ /**
25
+ * Builds a normalized dash array.
26
+ * @param {object} element Element row.
27
+ * @returns {string}
28
+ */
29
+ static #dashArray(element) {
30
+ const explicit =
31
+ element.stroke_dasharray || element.dash_array || element.dashArray
32
+ if (Array.isArray(explicit)) {
33
+ return CircuitJsonPcbDrawingStyle.#dashValues(explicit)
34
+ }
35
+ if (String(explicit || '').trim()) {
36
+ return CircuitJsonPcbDrawingStyle.#dashValues(
37
+ String(explicit).trim().split(/\s+/u)
38
+ )
39
+ }
40
+ if (element.is_dashed !== true && element.dashed !== true) return ''
41
+
42
+ const dashLength = CircuitJsonUnits.optionalLength(
43
+ element.dash_length ?? element.dashLength
44
+ )
45
+ const dashGap = CircuitJsonUnits.optionalLength(
46
+ element.dash_gap ?? element.dashGap
47
+ )
48
+ return CircuitJsonPcbDrawingStyle.#dashValues([
49
+ dashLength ?? 0.4,
50
+ dashGap ?? dashLength ?? 0.4
51
+ ])
52
+ }
53
+
54
+ /**
55
+ * Normalizes dash values.
56
+ * @param {unknown[]} values Dash values.
57
+ * @returns {string}
58
+ */
59
+ static #dashValues(values) {
60
+ const numbers = values.map(Number).filter((value) => value > 0)
61
+ return numbers.length
62
+ ? numbers.map((value) => String(Number(value.toFixed(6)))).join(' ')
63
+ : ''
64
+ }
65
+
66
+ /**
67
+ * Returns a safe hex color.
68
+ * @param {unknown} value Color candidate.
69
+ * @returns {string}
70
+ */
71
+ static #safeColor(value) {
72
+ const text = String(value || '').trim()
73
+ return /^#[0-9a-f]{3,8}$/iu.test(text) ? text : ''
74
+ }
75
+
76
+ /**
77
+ * Removes empty style fields.
78
+ * @param {object} value Style metadata.
79
+ * @returns {object}
80
+ */
81
+ static #clean(value) {
82
+ return Object.fromEntries(
83
+ Object.entries(value).filter(([_key, entry]) =>
84
+ String(entry || '').trim()
85
+ )
86
+ )
87
+ }
88
+ }