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,196 @@
1
+ /**
2
+ * Builds downloadable manufacturing metadata artifacts from parsed documents.
3
+ */
4
+ export class CircuitJsonManufacturingDownloadBuilder {
5
+ /**
6
+ * Returns true when a format is handled by this builder.
7
+ * @param {string} format Export format.
8
+ * @returns {boolean}
9
+ */
10
+ static supportsFormat(format) {
11
+ return [
12
+ 'pick-place-csv',
13
+ 'routing-dsn',
14
+ 'fabrication-notes-json'
15
+ ].includes(String(format || ''))
16
+ }
17
+
18
+ /**
19
+ * Builds a manufacturing metadata download.
20
+ * @param {object} documentModel Parsed document model.
21
+ * @param {string} format Export format.
22
+ * @returns {{ fileName: string, bytes: Uint8Array, contentType: string }}
23
+ */
24
+ static build(documentModel, format) {
25
+ if (format === 'pick-place-csv') {
26
+ return CircuitJsonManufacturingDownloadBuilder.#pickPlaceCsv(
27
+ documentModel
28
+ )
29
+ }
30
+ if (format === 'routing-dsn') {
31
+ return CircuitJsonManufacturingDownloadBuilder.#routingDsn(
32
+ documentModel
33
+ )
34
+ }
35
+ if (format === 'fabrication-notes-json') {
36
+ return CircuitJsonManufacturingDownloadBuilder.#fabricationNotesJson(
37
+ documentModel
38
+ )
39
+ }
40
+ throw new Error('Unsupported manufacturing export format')
41
+ }
42
+
43
+ /**
44
+ * Builds a pick-and-place CSV download.
45
+ * @param {object} documentModel Parsed document model.
46
+ * @returns {{ fileName: string, bytes: Uint8Array, contentType: string }}
47
+ */
48
+ static #pickPlaceCsv(documentModel) {
49
+ const rows = Array.isArray(
50
+ documentModel?.manufacturing?.pickAndPlaceRows
51
+ )
52
+ ? documentModel.manufacturing.pickAndPlaceRows
53
+ : []
54
+ if (!rows.length) {
55
+ throw new Error('No placement metadata is available')
56
+ }
57
+
58
+ const headers = [
59
+ 'Designator',
60
+ 'Component ID',
61
+ 'Source Component ID',
62
+ 'X',
63
+ 'Y',
64
+ 'Rotation',
65
+ 'Layer',
66
+ 'Side',
67
+ 'Value',
68
+ 'Package',
69
+ 'Manufacturer Part Number'
70
+ ]
71
+ const body = rows.map((row) => [
72
+ row.designator,
73
+ row.componentId,
74
+ row.sourceComponentId,
75
+ row.x,
76
+ row.y,
77
+ row.rotation,
78
+ row.layer,
79
+ row.side,
80
+ row.value,
81
+ row.package,
82
+ row.manufacturerPartNumber
83
+ ])
84
+ const csv = CircuitJsonManufacturingDownloadBuilder.#csv([
85
+ headers,
86
+ ...body
87
+ ])
88
+
89
+ return {
90
+ fileName:
91
+ CircuitJsonManufacturingDownloadBuilder.#fileBase(
92
+ documentModel
93
+ ) + '-pick-place.csv',
94
+ bytes: new TextEncoder().encode(csv),
95
+ contentType: 'text/csv;charset=utf-8'
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Builds a routing DSN download.
101
+ * @param {object} documentModel Parsed document model.
102
+ * @returns {{ fileName: string, bytes: Uint8Array, contentType: string }}
103
+ */
104
+ static #routingDsn(documentModel) {
105
+ const dsn = String(documentModel?.manufacturing?.routingDsn || '')
106
+ if (!dsn.trim()) {
107
+ throw new Error('No routing metadata is available')
108
+ }
109
+
110
+ return {
111
+ fileName:
112
+ CircuitJsonManufacturingDownloadBuilder.#fileBase(
113
+ documentModel
114
+ ) + '-routing.dsn',
115
+ bytes: new TextEncoder().encode(dsn),
116
+ contentType: 'application/specctra-dsn'
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Builds a fabrication notes JSON download.
122
+ * @param {object} documentModel Parsed document model.
123
+ * @returns {{ fileName: string, bytes: Uint8Array, contentType: string }}
124
+ */
125
+ static #fabricationNotesJson(documentModel) {
126
+ const notes = Array.isArray(
127
+ documentModel?.manufacturing?.fabricationNotes
128
+ )
129
+ ? documentModel.manufacturing.fabricationNotes
130
+ : []
131
+ if (!notes.length) {
132
+ throw new Error('No fabrication note metadata is available')
133
+ }
134
+
135
+ const payload = {
136
+ fileName: String(documentModel?.fileName || ''),
137
+ notes
138
+ }
139
+
140
+ return {
141
+ fileName:
142
+ CircuitJsonManufacturingDownloadBuilder.#fileBase(
143
+ documentModel
144
+ ) + '-fabrication-notes.json',
145
+ bytes: new TextEncoder().encode(
146
+ JSON.stringify(payload, null, 2) + '\n'
147
+ ),
148
+ contentType: 'application/json;charset=utf-8'
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Serializes CSV rows.
154
+ * @param {unknown[][]} rows CSV row values.
155
+ * @returns {string}
156
+ */
157
+ static #csv(rows) {
158
+ return (
159
+ rows
160
+ .map((row) =>
161
+ row
162
+ .map((value) =>
163
+ CircuitJsonManufacturingDownloadBuilder.#csvCell(
164
+ value
165
+ )
166
+ )
167
+ .join(',')
168
+ )
169
+ .join('\n') + '\n'
170
+ )
171
+ }
172
+
173
+ /**
174
+ * Serializes one CSV cell.
175
+ * @param {unknown} value Cell value.
176
+ * @returns {string}
177
+ */
178
+ static #csvCell(value) {
179
+ const text = String(value ?? '')
180
+ if (!/[",\n\r]/u.test(text)) return text
181
+ return '"' + text.replaceAll('"', '""') + '"'
182
+ }
183
+
184
+ /**
185
+ * Builds a filesystem-safe base file name.
186
+ * @param {object} documentModel Parsed document model.
187
+ * @returns {string}
188
+ */
189
+ static #fileBase(documentModel) {
190
+ const raw = String(documentModel?.fileName || 'manufacturing')
191
+ .replace(/\.[^.]+$/u, '')
192
+ .trim()
193
+ const safe = raw.replace(/[^a-z0-9._-]+/giu, '-').replace(/^-|-$/gu, '')
194
+ return safe || 'manufacturing'
195
+ }
196
+ }
@@ -1,4 +1,8 @@
1
1
  import { CircuitJsonDocument } from './CircuitJsonDocument.mjs'
2
+ import { CircuitJsonBomBuilder } from './CircuitJsonBomBuilder.mjs'
3
+ import { CircuitJsonIndexer } from './CircuitJsonIndexer.mjs'
4
+ import { CircuitJsonManufacturingBuilder } from './CircuitJsonManufacturingBuilder.mjs'
5
+ import { CircuitJsonSupportMatrixBuilder } from './CircuitJsonSupportMatrixBuilder.mjs'
2
6
 
3
7
  /**
4
8
  * Parses standalone CircuitJSON files.
@@ -22,10 +26,15 @@ export class CircuitJsonParser {
22
26
  }
23
27
 
24
28
  CircuitJsonDocument.assertModel(parsed)
29
+ const index = CircuitJsonIndexer.index(parsed)
25
30
  return CircuitJsonDocument.attachMetadata(parsed, {
26
31
  fileName: options.fileName || '',
27
32
  fileType: 'circuitjson',
28
- kind: CircuitJsonParser.#resolveKind(parsed)
33
+ kind: CircuitJsonParser.#resolveKind(index),
34
+ diagnostics: index.diagnostics,
35
+ bom: CircuitJsonBomBuilder.build(parsed),
36
+ supportMatrix: CircuitJsonSupportMatrixBuilder.build(parsed),
37
+ manufacturing: CircuitJsonManufacturingBuilder.build(parsed, index)
29
38
  })
30
39
  }
31
40
 
@@ -44,12 +53,19 @@ export class CircuitJsonParser {
44
53
 
45
54
  /**
46
55
  * Resolves a broad document kind from available elements.
47
- * @param {object[]} model CircuitJSON model.
56
+ * @param {{ elementsByType?: Map<string, object[]> }} index Model index.
48
57
  * @returns {string}
49
58
  */
50
- static #resolveKind(model) {
51
- return model.some((element) => String(element?.type) === 'pcb_board')
52
- ? 'pcb'
53
- : 'circuitjson'
59
+ static #resolveKind(index) {
60
+ if (index.elementsByType?.has('pcb_board')) return 'pcb'
61
+ if (
62
+ [...(index.elementsByType?.keys() || [])].some((type) =>
63
+ String(type).startsWith('schematic_')
64
+ )
65
+ ) {
66
+ return 'schematic'
67
+ }
68
+
69
+ return 'circuitjson'
54
70
  }
55
71
  }
@@ -0,0 +1,329 @@
1
+ import { CircuitJsonUnits } from './CircuitJsonUnits.mjs'
2
+ import { CircuitJsonPcbCopperGeometry } from './CircuitJsonPcbCopperGeometry.mjs'
3
+
4
+ /**
5
+ * Builds copper clearance diagnostics for CircuitJSON PCB primitives.
6
+ */
7
+ export class CircuitJsonPcbClearanceDiagnostics {
8
+ /**
9
+ * Builds generic copper clearance diagnostics when board rules are present.
10
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
11
+ * @param {object[]} primitives Primitive rows.
12
+ * @returns {object[]}
13
+ */
14
+ static build(index, primitives) {
15
+ const minimum = this.#minimumClearance(index)
16
+ if (minimum === null || minimum <= 0) return []
17
+
18
+ const copper = primitives.filter((primitive) =>
19
+ this.#isCopperPrimitive(primitive)
20
+ )
21
+ const keepouts = primitives.filter((primitive) =>
22
+ this.#isKeepoutPrimitive(primitive)
23
+ )
24
+ const diagnostics = []
25
+ for (let leftIndex = 0; leftIndex < copper.length; leftIndex += 1) {
26
+ for (
27
+ let rightIndex = leftIndex + 1;
28
+ rightIndex < copper.length;
29
+ rightIndex += 1
30
+ ) {
31
+ const left = copper[leftIndex]
32
+ const right = copper[rightIndex]
33
+ if (left.netName === right.netName) continue
34
+ if (!this.#sameClearanceLayer(left, right)) continue
35
+ const actual =
36
+ CircuitJsonPcbCopperGeometry.clearance(left, right) ??
37
+ this.#boundsClearance(left.bounds, right.bounds)
38
+ if (actual >= minimum) continue
39
+ diagnostics.push(
40
+ this.#clearanceDiagnostic(
41
+ left,
42
+ right,
43
+ minimum,
44
+ actual,
45
+ diagnostics.length
46
+ )
47
+ )
48
+ }
49
+ }
50
+ for (const copperPrimitive of copper) {
51
+ for (const keepout of keepouts) {
52
+ if (!this.#keepoutAppliesToCopper(copperPrimitive, keepout)) {
53
+ continue
54
+ }
55
+ const actual =
56
+ CircuitJsonPcbCopperGeometry.clearance(
57
+ copperPrimitive,
58
+ keepout
59
+ ) ??
60
+ this.#boundsClearance(
61
+ copperPrimitive.bounds,
62
+ keepout.bounds
63
+ )
64
+ if (actual >= minimum) continue
65
+ diagnostics.push(
66
+ this.#keepoutDiagnostic(
67
+ copperPrimitive,
68
+ keepout,
69
+ minimum,
70
+ actual,
71
+ diagnostics.length
72
+ )
73
+ )
74
+ }
75
+ }
76
+ return diagnostics
77
+ }
78
+
79
+ /**
80
+ * Resolves the configured minimum copper clearance.
81
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
82
+ * @returns {number | null}
83
+ */
84
+ static #minimumClearance(index) {
85
+ for (const board of index.elementsByType.get('pcb_board') || []) {
86
+ const value = CircuitJsonUnits.optionalLength(
87
+ board.min_trace_clearance ??
88
+ board.minimum_trace_clearance ??
89
+ board.minimum_copper_clearance ??
90
+ board.minimumCopperClearance ??
91
+ board.minCopperClearance
92
+ )
93
+ if (value !== null) return value
94
+ }
95
+ return null
96
+ }
97
+
98
+ /**
99
+ * Returns true when a primitive participates in copper spacing checks.
100
+ * @param {object} primitive Primitive row.
101
+ * @returns {boolean}
102
+ */
103
+ static #isCopperPrimitive(primitive) {
104
+ return (
105
+ ['pad', 'track', 'via', 'zone'].includes(primitive.kind) &&
106
+ String(primitive.netName || '').trim() &&
107
+ primitive.bounds
108
+ )
109
+ }
110
+
111
+ /**
112
+ * Returns true when a primitive represents a keepout region.
113
+ * @param {object} primitive Primitive row.
114
+ * @returns {boolean}
115
+ */
116
+ static #isKeepoutPrimitive(primitive) {
117
+ return primitive.kind === 'keepout' && Boolean(primitive.bounds)
118
+ }
119
+
120
+ /**
121
+ * Returns true when two copper primitives should share clearance checks.
122
+ * @param {object} left First primitive.
123
+ * @param {object} right Second primitive.
124
+ * @returns {boolean}
125
+ */
126
+ static #sameClearanceLayer(left, right) {
127
+ if (left.kind === 'via' || right.kind === 'via') return true
128
+ const leftLayer = String(left.layer || '').trim()
129
+ const rightLayer = String(right.layer || '').trim()
130
+ return !leftLayer || !rightLayer || leftLayer === rightLayer
131
+ }
132
+
133
+ /**
134
+ * Returns true when a keepout applies to the copper primitive layer.
135
+ * @param {object} copper Copper primitive.
136
+ * @param {object} keepout Keepout primitive.
137
+ * @returns {boolean}
138
+ */
139
+ static #keepoutAppliesToCopper(copper, keepout) {
140
+ if (copper.kind === 'via') return true
141
+ const keepoutSides = this.#keepoutSides(keepout)
142
+ if (!keepoutSides.length) return true
143
+ const copperSide = this.#surfaceSide(copper.layer || copper.side)
144
+ return !copperSide || keepoutSides.includes(copperSide)
145
+ }
146
+
147
+ /**
148
+ * Resolves the positive distance between two axis-aligned bounds.
149
+ * @param {object} left First bounds.
150
+ * @param {object} right Second bounds.
151
+ * @returns {number}
152
+ */
153
+ static #boundsClearance(left, right) {
154
+ const gapX = Math.max(left.minX - right.maxX, right.minX - left.maxX, 0)
155
+ const gapY = Math.max(left.minY - right.maxY, right.minY - left.maxY, 0)
156
+ return Math.hypot(gapX, gapY)
157
+ }
158
+
159
+ /**
160
+ * Builds one copper clearance diagnostic.
161
+ * @param {object} left First primitive.
162
+ * @param {object} right Second primitive.
163
+ * @param {number} minimum Minimum clearance.
164
+ * @param {number} actual Actual clearance.
165
+ * @param {number} index Diagnostic index.
166
+ * @returns {object}
167
+ */
168
+ static #clearanceDiagnostic(left, right, minimum, actual, index) {
169
+ const leftCenter = this.#boundsCenter(left.bounds)
170
+ const rightCenter = this.#boundsCenter(right.bounds)
171
+ const netName = [left.netName, right.netName].sort().join(' / ')
172
+
173
+ return {
174
+ id: 'clearance:' + index,
175
+ kind: 'error',
176
+ severity: 'error',
177
+ category: 'clearance',
178
+ code: 'pcb_copper_clearance',
179
+ message:
180
+ 'Copper clearance is below the configured minimum for ' +
181
+ netName +
182
+ '.',
183
+ point: {
184
+ x: (leftCenter.x + rightCenter.x) / 2,
185
+ y: (leftCenter.y + rightCenter.y) / 2
186
+ },
187
+ bounds: this.#mergeBounds([left.bounds, right.bounds]),
188
+ relatedPrimitiveIds: [left.id, right.id].filter(Boolean).sort(),
189
+ componentKey: '',
190
+ netName,
191
+ clearance: {
192
+ minimum,
193
+ actual: Number(actual.toFixed(6))
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Builds one keepout clearance diagnostic.
200
+ * @param {object} copper Copper primitive.
201
+ * @param {object} keepout Keepout primitive.
202
+ * @param {number} minimum Minimum clearance.
203
+ * @param {number} actual Actual clearance.
204
+ * @param {number} index Diagnostic index.
205
+ * @returns {object}
206
+ */
207
+ static #keepoutDiagnostic(copper, keepout, minimum, actual, index) {
208
+ const copperCenter = this.#boundsCenter(copper.bounds)
209
+ const keepoutCenter = this.#boundsCenter(keepout.bounds)
210
+ const keepoutId = String(keepout.id || '')
211
+
212
+ return {
213
+ id: 'keepout-clearance:' + index,
214
+ kind: 'error',
215
+ severity: 'error',
216
+ category: 'clearance',
217
+ code: 'pcb_keepout_clearance',
218
+ message:
219
+ 'Copper clearance is below the configured minimum around keepout ' +
220
+ keepoutId +
221
+ '.',
222
+ point: {
223
+ x: (copperCenter.x + keepoutCenter.x) / 2,
224
+ y: (copperCenter.y + keepoutCenter.y) / 2
225
+ },
226
+ bounds: this.#mergeBounds([copper.bounds, keepout.bounds]),
227
+ relatedPrimitiveIds: [copper.id, keepout.id].filter(Boolean).sort(),
228
+ componentKey: String(copper.componentKey || ''),
229
+ netName: String(copper.netName || ''),
230
+ keepoutId,
231
+ clearance: {
232
+ minimum,
233
+ actual: Number(actual.toFixed(6))
234
+ }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Resolves the center point of bounds.
240
+ * @param {object} bounds Bounds record.
241
+ * @returns {{ x: number, y: number }}
242
+ */
243
+ static #boundsCenter(bounds) {
244
+ return {
245
+ x: bounds.minX + bounds.width / 2,
246
+ y: bounds.minY + bounds.height / 2
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Merges bounds rows.
252
+ * @param {object[]} rows Bounds rows.
253
+ * @returns {object | null}
254
+ */
255
+ static #mergeBounds(rows) {
256
+ const validRows = rows.filter(Boolean)
257
+ if (!validRows.length) return null
258
+ const minX = Math.min(...validRows.map((bounds) => bounds.minX))
259
+ const minY = Math.min(...validRows.map((bounds) => bounds.minY))
260
+ const maxX = Math.max(...validRows.map((bounds) => bounds.maxX))
261
+ const maxY = Math.max(...validRows.map((bounds) => bounds.maxY))
262
+ return {
263
+ minX: this.#round(minX),
264
+ minY: this.#round(minY),
265
+ maxX: this.#round(maxX),
266
+ maxY: this.#round(maxY),
267
+ width: this.#round(maxX - minX),
268
+ height: this.#round(maxY - minY)
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Resolves keepout surface sides from source layer fields.
274
+ * @param {object} keepout Keepout primitive.
275
+ * @returns {string[]}
276
+ */
277
+ static #keepoutSides(keepout) {
278
+ const source = keepout.source || {}
279
+ const layers = [
280
+ ...(Array.isArray(source.layers) ? source.layers : []),
281
+ source.layer,
282
+ source.side
283
+ ]
284
+ return [
285
+ ...new Set(
286
+ layers.map((layer) => this.#surfaceSide(layer)).filter(Boolean)
287
+ )
288
+ ]
289
+ }
290
+
291
+ /**
292
+ * Resolves top or bottom from common layer values.
293
+ * @param {unknown} layer Layer value.
294
+ * @returns {'top' | 'bottom' | ''}
295
+ */
296
+ static #surfaceSide(layer) {
297
+ const value =
298
+ typeof layer === 'object' && layer !== null ? layer.name : layer
299
+ const normalized = String(value || '')
300
+ .trim()
301
+ .toLowerCase()
302
+ if (
303
+ normalized === 'top' ||
304
+ normalized === 'front' ||
305
+ normalized === 'f.cu' ||
306
+ normalized === '1'
307
+ ) {
308
+ return 'top'
309
+ }
310
+ if (
311
+ normalized === 'bottom' ||
312
+ normalized === 'back' ||
313
+ normalized === 'b.cu' ||
314
+ normalized === '32'
315
+ ) {
316
+ return 'bottom'
317
+ }
318
+ return ''
319
+ }
320
+
321
+ /**
322
+ * Rounds one computed geometry value.
323
+ * @param {number} value Numeric value.
324
+ * @returns {number}
325
+ */
326
+ static #round(value) {
327
+ return Number(Number(value).toFixed(6))
328
+ }
329
+ }