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,481 @@
1
+ import { CircuitJsonElementValidator } from './CircuitJsonElementValidator.mjs'
2
+
3
+ const PCB_RENDERED_TYPES = new Set([
4
+ 'pcb_board',
5
+ 'pcb_breakout_point',
6
+ 'pcb_component',
7
+ 'pcb_copper_pour',
8
+ 'pcb_copper_text',
9
+ 'pcb_courtyard',
10
+ 'pcb_courtyard_circle',
11
+ 'pcb_courtyard_line',
12
+ 'pcb_courtyard_outline',
13
+ 'pcb_courtyard_path',
14
+ 'pcb_courtyard_pill',
15
+ 'pcb_courtyard_polygon',
16
+ 'pcb_courtyard_rect',
17
+ 'pcb_cutout',
18
+ 'pcb_fabrication_note_dimension',
19
+ 'pcb_fabrication_note_path',
20
+ 'pcb_fabrication_note_rect',
21
+ 'pcb_fabrication_note_text',
22
+ 'pcb_ground_plane',
23
+ 'pcb_ground_plane_region',
24
+ 'pcb_group',
25
+ 'pcb_hole',
26
+ 'pcb_keepout',
27
+ 'pcb_note_dimension',
28
+ 'pcb_note_line',
29
+ 'pcb_note_path',
30
+ 'pcb_note_rect',
31
+ 'pcb_note_text',
32
+ 'pcb_panel',
33
+ 'pcb_plated_hole',
34
+ 'pcb_port',
35
+ 'pcb_silkscreen_circle',
36
+ 'pcb_silkscreen_graphic',
37
+ 'pcb_silkscreen_line',
38
+ 'pcb_silkscreen_oval',
39
+ 'pcb_silkscreen_path',
40
+ 'pcb_silkscreen_pill',
41
+ 'pcb_silkscreen_rect',
42
+ 'pcb_silkscreen_text',
43
+ 'pcb_smtpad',
44
+ 'pcb_solder_paste',
45
+ 'pcb_text',
46
+ 'pcb_thermal_spoke',
47
+ 'pcb_trace',
48
+ 'pcb_trace_hint',
49
+ 'pcb_via'
50
+ ])
51
+
52
+ const SCHEMATIC_RENDERED_TYPES = new Set([
53
+ 'schematic_arc',
54
+ 'schematic_box',
55
+ 'schematic_circle',
56
+ 'schematic_component',
57
+ 'schematic_debug_object',
58
+ 'schematic_group',
59
+ 'schematic_line',
60
+ 'schematic_net_label',
61
+ 'schematic_path',
62
+ 'schematic_port',
63
+ 'schematic_rect',
64
+ 'schematic_sheet',
65
+ 'schematic_symbol',
66
+ 'schematic_table',
67
+ 'schematic_table_cell',
68
+ 'schematic_text',
69
+ 'schematic_trace',
70
+ 'schematic_voltage_probe'
71
+ ])
72
+
73
+ const ROUTING_DSN_TYPES = new Set([
74
+ 'pcb_board',
75
+ 'pcb_component',
76
+ 'pcb_smtpad',
77
+ 'pcb_trace',
78
+ 'pcb_via',
79
+ 'pcb_plated_hole',
80
+ 'source_net'
81
+ ])
82
+
83
+ const VARIANT_ROWS_BY_SET = {
84
+ sourceComponentFtypes: {
85
+ type: 'source_component',
86
+ group: 'ftype',
87
+ capability: 'bom',
88
+ status: 'grouped'
89
+ },
90
+ pcbBoardShapes: {
91
+ type: 'pcb_board',
92
+ group: 'shape',
93
+ capability: 'pcb',
94
+ status: 'rendered'
95
+ },
96
+ pcbSmtPadShapes: {
97
+ type: 'pcb_smtpad',
98
+ group: 'shape',
99
+ capability: 'pcb',
100
+ status: 'rendered'
101
+ },
102
+ pcbHoleShapes: {
103
+ type: 'pcb_hole',
104
+ group: 'hole_shape',
105
+ capability: 'pcb',
106
+ status: 'rendered'
107
+ },
108
+ pcbPlatedHoleShapes: {
109
+ type: 'pcb_plated_hole',
110
+ group: 'shape',
111
+ capability: 'pcb',
112
+ status: 'rendered'
113
+ },
114
+ pcbPlatedHoleHoleShapes: {
115
+ type: 'pcb_plated_hole',
116
+ group: 'hole_shape',
117
+ capability: 'pcb',
118
+ status: 'rendered'
119
+ },
120
+ pcbSolderPasteShapes: {
121
+ type: 'pcb_solder_paste',
122
+ group: 'shape',
123
+ capability: 'pcb',
124
+ status: 'rendered'
125
+ },
126
+ pcbCutoutShapes: {
127
+ type: 'pcb_cutout',
128
+ group: 'shape',
129
+ capability: 'pcb',
130
+ status: 'rendered'
131
+ },
132
+ pcbCopperPourShapes: {
133
+ type: 'pcb_copper_pour',
134
+ group: 'shape',
135
+ capability: 'pcb',
136
+ status: 'rendered'
137
+ },
138
+ simulationSourceKinds: {
139
+ type: 'simulation_voltage_source',
140
+ group: 'kind',
141
+ capability: 'simulation',
142
+ status: 'summarized'
143
+ },
144
+ simulationWaveShapes: {
145
+ type: 'simulation_voltage_source',
146
+ group: 'wave_shape',
147
+ capability: 'simulation',
148
+ status: 'summarized'
149
+ },
150
+ simulationExperimentMethods: {
151
+ type: 'simulation_experiment',
152
+ group: 'spice_options.method',
153
+ capability: 'simulation',
154
+ status: 'summarized'
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Builds document-level support coverage reports from known element metadata.
160
+ */
161
+ export class CircuitJsonSupportMatrixBuilder {
162
+ /**
163
+ * Builds a support matrix for the known schema snapshot and present rows.
164
+ * @param {object[]} [circuitJson] Parsed element array.
165
+ * @returns {{ sourceFormat: string, totals: object, rows: object[], variantRows: object[], gaps: object[] }}
166
+ */
167
+ static build(circuitJson = []) {
168
+ const elements = Array.isArray(circuitJson) ? circuitJson : []
169
+ const presentTypes = new Set(
170
+ elements
171
+ .map((element) => String(element?.type || ''))
172
+ .filter(Boolean)
173
+ )
174
+ const rows = CircuitJsonElementValidator.knownElementTypes().map(
175
+ (type) =>
176
+ CircuitJsonSupportMatrixBuilder.#row(
177
+ type,
178
+ presentTypes.has(type)
179
+ )
180
+ )
181
+ const variantRows =
182
+ CircuitJsonSupportMatrixBuilder.#variantRows(elements)
183
+
184
+ return {
185
+ sourceFormat: 'circuitjson',
186
+ totals: CircuitJsonSupportMatrixBuilder.#totals(
187
+ rows,
188
+ presentTypes,
189
+ variantRows
190
+ ),
191
+ rows,
192
+ variantRows,
193
+ gaps: rows.flatMap((row) =>
194
+ CircuitJsonSupportMatrixBuilder.#gaps(row)
195
+ )
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Builds one matrix row.
201
+ * @param {string} type Element type.
202
+ * @param {boolean} present Whether the current document contains the type.
203
+ * @returns {object}
204
+ */
205
+ static #row(type, present) {
206
+ const capabilities = CircuitJsonSupportMatrixBuilder.#capabilities(type)
207
+ return {
208
+ type,
209
+ family: CircuitJsonSupportMatrixBuilder.#family(type),
210
+ present,
211
+ status: CircuitJsonSupportMatrixBuilder.#status(capabilities),
212
+ capabilities
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Resolves capability labels for one type.
218
+ * @param {string} type Element type.
219
+ * @returns {Record<string, string>}
220
+ */
221
+ static #capabilities(type) {
222
+ const capabilities = {
223
+ validation: 'known',
224
+ parser: 'preserved',
225
+ indexer: 'indexed',
226
+ diagnostics: CircuitJsonSupportMatrixBuilder.#diagnostics(type),
227
+ schematic: SCHEMATIC_RENDERED_TYPES.has(type) ? 'rendered' : 'none',
228
+ pcb: PCB_RENDERED_TYPES.has(type) ? 'rendered' : 'none',
229
+ scene3d: type === 'cad_component' ? 'external-model' : 'none',
230
+ bom: type === 'source_component' ? 'grouped' : 'none',
231
+ manufacturing: CircuitJsonSupportMatrixBuilder.#manufacturing(type),
232
+ simulation: type.startsWith('simulation_') ? 'preserved' : 'none'
233
+ }
234
+
235
+ if (type === 'pcb_component') {
236
+ capabilities.manufacturing = 'pick-and-place'
237
+ }
238
+
239
+ return capabilities
240
+ }
241
+
242
+ /**
243
+ * Resolves diagnostic support for one type.
244
+ * @param {string} type Element type.
245
+ * @returns {string}
246
+ */
247
+ static #diagnostics(type) {
248
+ return /(?:error|warning)/u.test(type) ? 'normalized' : 'none'
249
+ }
250
+
251
+ /**
252
+ * Resolves manufacturing support for one type.
253
+ * @param {string} type Element type.
254
+ * @returns {string}
255
+ */
256
+ static #manufacturing(type) {
257
+ if (ROUTING_DSN_TYPES.has(type)) return 'routing-dsn'
258
+ return 'none'
259
+ }
260
+
261
+ /**
262
+ * Resolves an overall support status.
263
+ * @param {Record<string, string>} capabilities Capability labels.
264
+ * @returns {'full' | 'partial' | 'metadata'}
265
+ */
266
+ static #status(capabilities) {
267
+ if (
268
+ capabilities.pcb === 'rendered' ||
269
+ capabilities.schematic === 'rendered' ||
270
+ capabilities.scene3d === 'external-model' ||
271
+ capabilities.diagnostics === 'normalized'
272
+ ) {
273
+ return capabilities.manufacturing === 'routing-dsn'
274
+ ? 'partial'
275
+ : 'full'
276
+ }
277
+
278
+ return 'metadata'
279
+ }
280
+
281
+ /**
282
+ * Builds gap rows for present partially supported capabilities.
283
+ * @param {object} row Matrix row.
284
+ * @returns {object[]}
285
+ */
286
+ static #gaps(row) {
287
+ if (!row.present) return []
288
+ if (row.capabilities.manufacturing === 'routing-dsn') {
289
+ return [
290
+ {
291
+ type: row.type,
292
+ capability: 'manufacturing',
293
+ status: 'partial',
294
+ detail: 'Routing exchange metadata is generated without full fabrication packaging.'
295
+ }
296
+ ]
297
+ }
298
+ if (row.status === 'metadata') {
299
+ return [
300
+ {
301
+ type: row.type,
302
+ capability: 'rendering',
303
+ status: 'metadata',
304
+ detail: 'The element is preserved for downstream consumers.'
305
+ }
306
+ ]
307
+ }
308
+ return []
309
+ }
310
+
311
+ /**
312
+ * Builds variant coverage rows from active schema metadata.
313
+ * @param {object[]} elements Parsed element rows.
314
+ * @returns {object[]}
315
+ */
316
+ static #variantRows(elements) {
317
+ const schema = CircuitJsonElementValidator.variantSets()
318
+ const present =
319
+ CircuitJsonSupportMatrixBuilder.#presentVariants(elements)
320
+ return Object.entries(schema).flatMap(([setName, values]) => {
321
+ const definition = VARIANT_ROWS_BY_SET[setName]
322
+ if (!definition) return []
323
+ return values.map((value) =>
324
+ CircuitJsonSupportMatrixBuilder.#variantRow(
325
+ setName,
326
+ definition,
327
+ value,
328
+ present
329
+ )
330
+ )
331
+ })
332
+ }
333
+
334
+ /**
335
+ * Builds one variant coverage row.
336
+ * @param {string} setName Variant set name.
337
+ * @param {object} definition Row definition.
338
+ * @param {string} value Variant value.
339
+ * @param {Map<string, Set<string>>} present Present variant values.
340
+ * @returns {object}
341
+ */
342
+ static #variantRow(setName, definition, value, present) {
343
+ const isPresent = Boolean(present.get(setName)?.has(value))
344
+ return {
345
+ set: setName,
346
+ type: definition.type,
347
+ group: definition.group,
348
+ value,
349
+ present: isPresent,
350
+ capability: definition.capability,
351
+ status: definition.status,
352
+ note: CircuitJsonSupportMatrixBuilder.#variantNote(
353
+ definition,
354
+ value,
355
+ isPresent
356
+ )
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Builds a short human-readable variant support note.
362
+ * @param {object} definition Variant row definition.
363
+ * @param {string} value Variant value.
364
+ * @param {boolean} present Whether the variant is present.
365
+ * @returns {string}
366
+ */
367
+ static #variantNote(definition, value, present) {
368
+ const label =
369
+ definition.type + '.' + definition.group + ' ' + value + ' is '
370
+ if (!present) return label + 'known but not present.'
371
+ if (definition.status === 'rendered') return label + 'rendered.'
372
+ if (definition.status === 'grouped') {
373
+ return label + 'used for BOM grouping.'
374
+ }
375
+ if (definition.status === 'summarized') {
376
+ return label + 'summarized in simulation setup.'
377
+ }
378
+ return label + 'preserved as metadata.'
379
+ }
380
+
381
+ /**
382
+ * Extracts variant values present in the current document.
383
+ * @param {object[]} elements Parsed element rows.
384
+ * @returns {Map<string, Set<string>>}
385
+ */
386
+ static #presentVariants(elements) {
387
+ const present = new Map()
388
+ const add = (setName, value) => {
389
+ const text = String(value || '').trim()
390
+ if (!text) return
391
+ if (!present.has(setName)) present.set(setName, new Set())
392
+ present.get(setName).add(text)
393
+ }
394
+
395
+ for (const element of elements) {
396
+ if (element?.type === 'source_component') {
397
+ add('sourceComponentFtypes', element.ftype)
398
+ }
399
+ if (element?.type === 'pcb_board') {
400
+ add('pcbBoardShapes', element.shape || 'rect')
401
+ }
402
+ if (element?.type === 'pcb_smtpad') {
403
+ add('pcbSmtPadShapes', element.shape)
404
+ }
405
+ if (element?.type === 'pcb_hole') {
406
+ add('pcbHoleShapes', element.hole_shape || element.shape)
407
+ }
408
+ if (element?.type === 'pcb_plated_hole') {
409
+ add('pcbPlatedHoleShapes', element.shape || 'circle')
410
+ add('pcbPlatedHoleHoleShapes', element.hole_shape)
411
+ }
412
+ if (element?.type === 'pcb_solder_paste') {
413
+ add('pcbSolderPasteShapes', element.shape)
414
+ }
415
+ if (element?.type === 'pcb_cutout') {
416
+ add('pcbCutoutShapes', element.shape || 'rect')
417
+ }
418
+ if (element?.type === 'pcb_copper_pour') {
419
+ add('pcbCopperPourShapes', element.shape || 'polygon')
420
+ }
421
+ if (
422
+ element?.type === 'simulation_voltage_source' ||
423
+ element?.type === 'simulation_current_source'
424
+ ) {
425
+ add(
426
+ 'simulationSourceKinds',
427
+ element.source_type || element.sourceType || element.kind
428
+ )
429
+ add('simulationWaveShapes', element.wave_shape)
430
+ }
431
+ if (element?.type === 'simulation_experiment') {
432
+ add(
433
+ 'simulationExperimentMethods',
434
+ element.spice_options?.method
435
+ )
436
+ }
437
+ }
438
+
439
+ return present
440
+ }
441
+
442
+ /**
443
+ * Builds aggregate matrix counts.
444
+ * @param {object[]} rows Matrix rows.
445
+ * @param {Set<string>} presentTypes Present element types.
446
+ * @param {object[]} variantRows Variant coverage rows.
447
+ * @returns {object}
448
+ */
449
+ static #totals(rows, presentTypes, variantRows) {
450
+ return {
451
+ knownElementTypes: rows.length,
452
+ presentElementTypes: rows.filter((row) => row.present).length,
453
+ renderedElementTypes: rows.filter(
454
+ (row) =>
455
+ row.present &&
456
+ (row.capabilities.pcb === 'rendered' ||
457
+ row.capabilities.schematic === 'rendered' ||
458
+ row.capabilities.scene3d === 'external-model')
459
+ ).length,
460
+ diagnosticElementTypes: rows.filter(
461
+ (row) =>
462
+ row.present && row.capabilities.diagnostics === 'normalized'
463
+ ).length,
464
+ unknownPresentElementTypes: [...presentTypes].filter(
465
+ (type) => !rows.some((row) => row.type === type)
466
+ ).length,
467
+ knownVariantValues: variantRows.length,
468
+ presentVariantValues: variantRows.filter((row) => row.present)
469
+ .length
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Resolves the top-level element family.
475
+ * @param {string} type Element type.
476
+ * @returns {string}
477
+ */
478
+ static #family(type) {
479
+ return String(type || '').split('_')[0] || 'unknown'
480
+ }
481
+ }
@@ -1,9 +1,114 @@
1
1
  const MILS_PER_MM = 39.37007874015748
2
+ const LENGTH_FACTORS_TO_MM = new Map([
3
+ ['mm', 1],
4
+ ['millimeter', 1],
5
+ ['millimeters', 1],
6
+ ['cm', 10],
7
+ ['centimeter', 10],
8
+ ['centimeters', 10],
9
+ ['m', 1000],
10
+ ['meter', 1000],
11
+ ['meters', 1000],
12
+ ['in', 25.4],
13
+ ['inch', 25.4],
14
+ ['inches', 25.4],
15
+ ['mil', 0.0254],
16
+ ['mils', 0.0254],
17
+ ['um', 0.001],
18
+ ['micrometer', 0.001],
19
+ ['micrometers', 0.001]
20
+ ])
21
+ const ANGLE_FACTORS_TO_DEG = new Map([
22
+ ['deg', 1],
23
+ ['degree', 1],
24
+ ['degrees', 1],
25
+ ['rad', 180 / Math.PI],
26
+ ['radian', 180 / Math.PI],
27
+ ['radians', 180 / Math.PI]
28
+ ])
2
29
 
3
30
  /**
4
31
  * Unit helpers for CircuitJSON's millimeter-based PCB dimensions.
5
32
  */
6
33
  export class CircuitJsonUnits {
34
+ /**
35
+ * Converts a length value to millimeters.
36
+ * @param {unknown} value Length candidate.
37
+ * @param {number} [fallback] Fallback millimeter value.
38
+ * @returns {number}
39
+ */
40
+ static length(value, fallback = 0) {
41
+ return (
42
+ CircuitJsonUnits.optionalLength(value) ??
43
+ CircuitJsonUnits.#round(fallback)
44
+ )
45
+ }
46
+
47
+ /**
48
+ * Converts a length value to millimeters, or null when invalid.
49
+ * @param {unknown} value Length candidate.
50
+ * @returns {number | null}
51
+ */
52
+ static optionalLength(value) {
53
+ return CircuitJsonUnits.#parseUnitValue(value, LENGTH_FACTORS_TO_MM)
54
+ }
55
+
56
+ /**
57
+ * Converts an angle value to degrees.
58
+ * @param {unknown} value Angle candidate.
59
+ * @param {number} [fallback] Fallback degree value.
60
+ * @returns {number}
61
+ */
62
+ static angle(value, fallback = 0) {
63
+ return (
64
+ CircuitJsonUnits.optionalAngle(value) ??
65
+ CircuitJsonUnits.#round(fallback)
66
+ )
67
+ }
68
+
69
+ /**
70
+ * Converts an angle value to degrees, or null when invalid.
71
+ * @param {unknown} value Angle candidate.
72
+ * @returns {number | null}
73
+ */
74
+ static optionalAngle(value) {
75
+ return CircuitJsonUnits.#parseUnitValue(value, ANGLE_FACTORS_TO_DEG)
76
+ }
77
+
78
+ /**
79
+ * Converts a point to normalized millimeter coordinates.
80
+ * @param {{ x?: unknown, y?: unknown } | null | undefined} point Point.
81
+ * @returns {{ x: number, y: number }}
82
+ */
83
+ static point(point) {
84
+ return {
85
+ x: CircuitJsonUnits.length(point?.x, 0),
86
+ y: CircuitJsonUnits.length(point?.y, 0)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Converts a point to normalized millimeter coordinates when valid.
92
+ * @param {{ x?: unknown, y?: unknown } | null | undefined} point Point.
93
+ * @returns {{ x: number, y: number } | null}
94
+ */
95
+ static optionalPoint(point) {
96
+ const x = CircuitJsonUnits.optionalLength(point?.x)
97
+ const y = CircuitJsonUnits.optionalLength(point?.y)
98
+ return x === null || y === null ? null : { x, y }
99
+ }
100
+
101
+ /**
102
+ * Converts a size to normalized millimeter dimensions.
103
+ * @param {{ width?: unknown, height?: unknown } | null | undefined} size Size.
104
+ * @returns {{ width: number, height: number } | null}
105
+ */
106
+ static optionalSize(size) {
107
+ const width = CircuitJsonUnits.optionalLength(size?.width)
108
+ const height = CircuitJsonUnits.optionalLength(size?.height)
109
+ return width === null || height === null ? null : { width, height }
110
+ }
111
+
7
112
  /**
8
113
  * Converts millimeters to mils.
9
114
  * @param {unknown} value Millimeter value.
@@ -12,7 +117,7 @@ export class CircuitJsonUnits {
12
117
  */
13
118
  static mmToMil(value, fallback = 0) {
14
119
  return CircuitJsonUnits.#round(
15
- CircuitJsonUnits.#number(value, fallback) * MILS_PER_MM
120
+ CircuitJsonUnits.length(value, fallback) * MILS_PER_MM
16
121
  )
17
122
  }
18
123
 
@@ -29,14 +134,34 @@ export class CircuitJsonUnits {
29
134
  }
30
135
 
31
136
  /**
32
- * Converts a value to a finite number.
33
- * @param {unknown} value Candidate number.
34
- * @param {number} fallback Fallback number.
35
- * @returns {number}
137
+ * Parses one numeric value with an optional unit suffix.
138
+ * @param {unknown} value Value candidate.
139
+ * @param {Map<string, number>} unitFactors Unit factor lookup.
140
+ * @returns {number | null}
36
141
  */
37
- static #number(value, fallback) {
38
- const numeric = Number(value)
39
- return Number.isFinite(numeric) ? numeric : fallback
142
+ static #parseUnitValue(value, unitFactors) {
143
+ if (typeof value === 'number') {
144
+ return Number.isFinite(value)
145
+ ? CircuitJsonUnits.#round(value)
146
+ : null
147
+ }
148
+
149
+ const text = String(value ?? '').trim()
150
+ if (!text) return null
151
+
152
+ const match = text.match(
153
+ /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*([a-z]+)?$/iu
154
+ )
155
+ if (!match) return null
156
+
157
+ const number = Number(match[1])
158
+ if (!Number.isFinite(number)) return null
159
+
160
+ const unit = String(match[2] || '').toLowerCase()
161
+ const factor = unit ? unitFactors.get(unit) : 1
162
+ if (!Number.isFinite(factor)) return null
163
+
164
+ return CircuitJsonUnits.#round(number * factor)
40
165
  }
41
166
 
42
167
  /**