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,335 @@
1
+ import { CircuitJsonDocument } from './CircuitJsonDocument.mjs'
2
+
3
+ /**
4
+ * Builds standards-shaped CircuitJSON for selected-part ZIP exports.
5
+ */
6
+ export class SelectedPartCircuitJsonExportAdapter {
7
+ /**
8
+ * Builds a CircuitJSON element array for one selected part.
9
+ * @param {{ designator?: string, symbol?: object, footprint?: object }} selectedPart Selected part data.
10
+ * @param {object} documentModel Active document model.
11
+ * @param {string} partName Export artifact name.
12
+ * @returns {object[]}
13
+ */
14
+ static build(selectedPart, documentModel, partName) {
15
+ const designator = selectedPart.designator || 'selected-part'
16
+ const idToken =
17
+ SelectedPartCircuitJsonExportAdapter.#safeIdentifier(designator)
18
+ const sourceComponentId = 'source_component_' + idToken
19
+ const pcbComponentId = 'pcb_component_' + idToken
20
+ const circuitJson = [
21
+ {
22
+ type: 'source_project_metadata',
23
+ name: documentModel?.fileName || 'Selected part export',
24
+ software_used_string:
25
+ documentModel?.sourceFormat || documentModel?.fileType || ''
26
+ },
27
+ {
28
+ type: 'source_component',
29
+ source_component_id: sourceComponentId,
30
+ name: partName,
31
+ ftype: 'simple_chip',
32
+ manufacturer_part_number: selectedPart.symbol?.value || '',
33
+ supplier_part_numbers: {}
34
+ },
35
+ {
36
+ type: 'schematic_component',
37
+ schematic_component_id: 'schematic_component_' + idToken,
38
+ source_component_id: sourceComponentId,
39
+ center: { x: 0, y: 0 },
40
+ size: SelectedPartCircuitJsonExportAdapter.#schematicSize(
41
+ selectedPart
42
+ ),
43
+ rotation: 0
44
+ },
45
+ {
46
+ type: 'pcb_component',
47
+ pcb_component_id: pcbComponentId,
48
+ source_component_id: sourceComponentId,
49
+ center: SelectedPartCircuitJsonExportAdapter.#footprintCenter(
50
+ selectedPart
51
+ ),
52
+ layer: 'top',
53
+ rotation: 0,
54
+ width: SelectedPartCircuitJsonExportAdapter.#footprintSize(
55
+ selectedPart
56
+ ).width,
57
+ height: SelectedPartCircuitJsonExportAdapter.#footprintSize(
58
+ selectedPart
59
+ ).height
60
+ },
61
+ ...SelectedPartCircuitJsonExportAdapter.#sourcePorts(
62
+ selectedPart,
63
+ sourceComponentId
64
+ ),
65
+ ...SelectedPartCircuitJsonExportAdapter.#pcbPads(
66
+ selectedPart,
67
+ pcbComponentId
68
+ )
69
+ ]
70
+
71
+ CircuitJsonDocument.assertModel(circuitJson)
72
+ return circuitJson
73
+ }
74
+
75
+ /**
76
+ * Builds source port entries.
77
+ * @param {{ symbol?: { pins?: object[] } }} selectedPart Selected part data.
78
+ * @param {string} sourceComponentId Source component id.
79
+ * @returns {object[]}
80
+ */
81
+ static #sourcePorts(selectedPart, sourceComponentId) {
82
+ return SelectedPartCircuitJsonExportAdapter.#array(
83
+ selectedPart.symbol?.pins
84
+ ).map((pin, index) => {
85
+ const pinName = String(pin.name || index + 1)
86
+ const pinNumber = String(pin.number || index + 1)
87
+ const entry = {
88
+ type: 'source_port',
89
+ source_port_id:
90
+ sourceComponentId +
91
+ '_port_' +
92
+ SelectedPartCircuitJsonExportAdapter.#safeIdentifier(
93
+ pinNumber
94
+ ),
95
+ source_component_id: sourceComponentId,
96
+ name: pinName,
97
+ port_hints: [pinNumber]
98
+ }
99
+ const numericPinNumber =
100
+ SelectedPartCircuitJsonExportAdapter.#numericPinNumber(
101
+ pinNumber
102
+ )
103
+ if (numericPinNumber !== null) entry.pin_number = numericPinNumber
104
+ return entry
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Builds PCB SMT pad entries.
110
+ * @param {{ footprint?: { pads?: object[] } }} selectedPart Selected part data.
111
+ * @param {string} pcbComponentId PCB component id.
112
+ * @returns {object[]}
113
+ */
114
+ static #pcbPads(selectedPart, pcbComponentId) {
115
+ return SelectedPartCircuitJsonExportAdapter.#array(
116
+ selectedPart.footprint?.pads
117
+ ).map((pad, index) =>
118
+ SelectedPartCircuitJsonExportAdapter.#pcbPad(
119
+ pad,
120
+ index,
121
+ pcbComponentId
122
+ )
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Builds one PCB SMT pad entry.
128
+ * @param {object} pad Pad data.
129
+ * @param {number} index Pad index.
130
+ * @param {string} pcbComponentId PCB component id.
131
+ * @returns {object}
132
+ */
133
+ static #pcbPad(pad, index, pcbComponentId) {
134
+ const padNumber = String(pad.number || index + 1)
135
+ const width = SelectedPartCircuitJsonExportAdapter.#number(pad.width, 1)
136
+ const height = SelectedPartCircuitJsonExportAdapter.#number(
137
+ pad.height,
138
+ 1
139
+ )
140
+ const rotation = SelectedPartCircuitJsonExportAdapter.#number(
141
+ pad.ccw_rotation ?? pad.rotation,
142
+ 0
143
+ )
144
+ const shape =
145
+ rotation !== 0
146
+ ? 'rotated_rect'
147
+ : SelectedPartCircuitJsonExportAdapter.#padShape(
148
+ pad,
149
+ width,
150
+ height
151
+ )
152
+ const entry = {
153
+ type: 'pcb_smtpad',
154
+ shape,
155
+ pcb_smtpad_id:
156
+ pcbComponentId +
157
+ '_pad_' +
158
+ SelectedPartCircuitJsonExportAdapter.#safeIdentifier(padNumber),
159
+ pcb_component_id: pcbComponentId,
160
+ port_hints: [padNumber],
161
+ x: SelectedPartCircuitJsonExportAdapter.#number(pad.x, 0),
162
+ y: SelectedPartCircuitJsonExportAdapter.#number(pad.y, 0),
163
+ layer: SelectedPartCircuitJsonExportAdapter.#padLayer(pad)
164
+ }
165
+
166
+ if (shape === 'circle') {
167
+ entry.radius = Math.max(width, height) / 2
168
+ } else {
169
+ entry.width = width
170
+ entry.height = height
171
+ }
172
+
173
+ if (shape === 'rotated_rect') entry.ccw_rotation = rotation
174
+ return entry
175
+ }
176
+
177
+ /**
178
+ * Resolves the schematic component size.
179
+ * @param {{ symbol?: { pins?: object[] } }} selectedPart Selected part data.
180
+ * @returns {{ width: number, height: number }}
181
+ */
182
+ static #schematicSize(selectedPart) {
183
+ const pinCount = SelectedPartCircuitJsonExportAdapter.#array(
184
+ selectedPart.symbol?.pins
185
+ ).length
186
+ const edge = Math.max(2.54, Math.ceil(Math.sqrt(pinCount || 1)) * 2.54)
187
+ return { width: edge, height: edge }
188
+ }
189
+
190
+ /**
191
+ * Resolves footprint size from owned pads.
192
+ * @param {{ footprint?: { pads?: object[] } }} selectedPart Selected part data.
193
+ * @returns {{ width: number, height: number }}
194
+ */
195
+ static #footprintSize(selectedPart) {
196
+ const bounds =
197
+ SelectedPartCircuitJsonExportAdapter.#footprintBounds(selectedPart)
198
+ return {
199
+ width: Math.max(bounds.maxX - bounds.minX, 1),
200
+ height: Math.max(bounds.maxY - bounds.minY, 1)
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Resolves footprint center from owned pads.
206
+ * @param {{ footprint?: { pads?: object[] } }} selectedPart Selected part data.
207
+ * @returns {{ x: number, y: number }}
208
+ */
209
+ static #footprintCenter(selectedPart) {
210
+ const bounds =
211
+ SelectedPartCircuitJsonExportAdapter.#footprintBounds(selectedPart)
212
+ return {
213
+ x: (bounds.minX + bounds.maxX) / 2,
214
+ y: (bounds.minY + bounds.maxY) / 2
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Resolves footprint pad bounds.
220
+ * @param {{ footprint?: { pads?: object[] } }} selectedPart Selected part data.
221
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
222
+ */
223
+ static #footprintBounds(selectedPart) {
224
+ const pads = SelectedPartCircuitJsonExportAdapter.#array(
225
+ selectedPart.footprint?.pads
226
+ )
227
+ if (!pads.length)
228
+ return { minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5 }
229
+
230
+ return pads.reduce(
231
+ (bounds, pad) => {
232
+ const x = SelectedPartCircuitJsonExportAdapter.#number(pad.x, 0)
233
+ const y = SelectedPartCircuitJsonExportAdapter.#number(pad.y, 0)
234
+ const halfWidth =
235
+ SelectedPartCircuitJsonExportAdapter.#number(pad.width, 1) /
236
+ 2
237
+ const halfHeight =
238
+ SelectedPartCircuitJsonExportAdapter.#number(
239
+ pad.height,
240
+ 1
241
+ ) / 2
242
+ return {
243
+ minX: Math.min(bounds.minX, x - halfWidth),
244
+ minY: Math.min(bounds.minY, y - halfHeight),
245
+ maxX: Math.max(bounds.maxX, x + halfWidth),
246
+ maxY: Math.max(bounds.maxY, y + halfHeight)
247
+ }
248
+ },
249
+ {
250
+ minX: Infinity,
251
+ minY: Infinity,
252
+ maxX: -Infinity,
253
+ maxY: -Infinity
254
+ }
255
+ )
256
+ }
257
+
258
+ /**
259
+ * Resolves an SMT pad shape.
260
+ * @param {object} pad Pad data.
261
+ * @param {number} width Pad width.
262
+ * @param {number} height Pad height.
263
+ * @returns {string}
264
+ */
265
+ static #padShape(pad, width, height) {
266
+ const rawShape = String(
267
+ pad.shape || pad.shapeTopName || pad.shapeName || ''
268
+ ).toLowerCase()
269
+ if (
270
+ rawShape.includes('circle') ||
271
+ rawShape.includes('round') ||
272
+ rawShape.includes('oval')
273
+ ) {
274
+ return width === height ? 'circle' : 'rect'
275
+ }
276
+ return 'rect'
277
+ }
278
+
279
+ /**
280
+ * Resolves an SMT pad layer.
281
+ * @param {object} pad Pad data.
282
+ * @returns {string}
283
+ */
284
+ static #padLayer(pad) {
285
+ const layer = String(pad.layer || pad.layerName || '').toLowerCase()
286
+ if (
287
+ layer.includes('bottom') ||
288
+ layer === 'bottom' ||
289
+ pad.layerId === 32
290
+ ) {
291
+ return 'bottom'
292
+ }
293
+ return 'top'
294
+ }
295
+
296
+ /**
297
+ * Returns a number pin when a pin token is numeric.
298
+ * @param {unknown} value Candidate pin number.
299
+ * @returns {number | null}
300
+ */
301
+ static #numericPinNumber(value) {
302
+ const text = String(value || '').trim()
303
+ const parsed = Number(text)
304
+ return text && Number.isFinite(parsed) ? parsed : null
305
+ }
306
+
307
+ /**
308
+ * Creates a safe CircuitJSON id token.
309
+ * @param {unknown} value Raw value.
310
+ * @returns {string}
311
+ */
312
+ static #safeIdentifier(value) {
313
+ return String(value || 'selected_part').replace(/[^a-z0-9_]/giu, '_')
314
+ }
315
+
316
+ /**
317
+ * Normalizes a possible array.
318
+ * @param {unknown} value Candidate array.
319
+ * @returns {object[]}
320
+ */
321
+ static #array(value) {
322
+ return Array.isArray(value) ? value : []
323
+ }
324
+
325
+ /**
326
+ * Reads a finite number with fallback.
327
+ * @param {unknown} value Candidate number.
328
+ * @param {number} fallback Fallback number.
329
+ * @returns {number}
330
+ */
331
+ static #number(value, fallback) {
332
+ const parsed = Number(value)
333
+ return Number.isFinite(parsed) ? parsed : fallback
334
+ }
335
+ }
@@ -0,0 +1,139 @@
1
+ const PSPICE_NUMBER_TOKEN = String.raw`([+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[eE][+-]?\d+)?)`
2
+ const PSPICE_COMPARISON_OPERATOR = '(?:<=|>=|==|!=|(?<![!<>=])=(?!=)|<|>)'
3
+ const PSPICE_COMPARISON_OPERAND = String.raw`(?:V\s*\([^)]*\)|\{[^}\r\n]+\}|${PSPICE_NUMBER_TOKEN}(?:[a-zA-Z]+)?|[A-Za-z_][\w.$]*)`
4
+ const PSPICE_COMPARISON_EXPRESSION = String.raw`${PSPICE_COMPARISON_OPERAND}\s*${PSPICE_COMPARISON_OPERATOR}\s*${PSPICE_COMPARISON_OPERAND}`
5
+ const PSPICE_COMPARISON_BEFORE_CARET_PATTERN = new RegExp(
6
+ String.raw`${PSPICE_COMPARISON_EXPRESSION}\s*$`,
7
+ 'i'
8
+ )
9
+ const PSPICE_COMPARISON_AFTER_CARET_PATTERN = new RegExp(
10
+ String.raw`^\s*\+?\s*${PSPICE_COMPARISON_EXPRESSION}`,
11
+ 'i'
12
+ )
13
+
14
+ /**
15
+ * Rewrites narrow, well-understood SPICE compatibility syntax.
16
+ */
17
+ export class SpiceCompatibilityPreprocessor {
18
+ /**
19
+ * Returns a netlist with supported compatibility syntax rewritten.
20
+ * @param {string} spiceString Raw SPICE netlist text.
21
+ * @returns {string}
22
+ */
23
+ static rewrite(spiceString) {
24
+ return SpiceCompatibilityPreprocessor.#rewriteValueBooleanCarets(
25
+ SpiceCompatibilityPreprocessor.#rewriteResistorTemperaturePairs(
26
+ spiceString
27
+ )
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Rewrites resistor TC pairs to separate TC1 and TC2 assignments.
33
+ * @param {string} spiceString Raw SPICE netlist text.
34
+ * @returns {string}
35
+ */
36
+ static #rewriteResistorTemperaturePairs(spiceString) {
37
+ return String(spiceString || '')
38
+ .split(/\r?\n/)
39
+ .map((line) => {
40
+ if (!/^\s*r/i.test(line)) return line
41
+
42
+ return line.replace(
43
+ new RegExp(
44
+ String.raw`\bTC\s*=\s*${PSPICE_NUMBER_TOKEN}\s*,\s*${PSPICE_NUMBER_TOKEN}\b`,
45
+ 'gi'
46
+ ),
47
+ 'TC1=$1 TC2=$2'
48
+ )
49
+ })
50
+ .join('\n')
51
+ }
52
+
53
+ /**
54
+ * Rewrites boolean caret operators inside VALUE expression blocks.
55
+ * @param {string} spiceString Raw SPICE netlist text.
56
+ * @returns {string}
57
+ */
58
+ static #rewriteValueBooleanCarets(spiceString) {
59
+ let result = ''
60
+ let cursor = 0
61
+ const valueStartPattern = /\bVALUE\s*\{/gi
62
+
63
+ for (;;) {
64
+ valueStartPattern.lastIndex = cursor
65
+ const match = valueStartPattern.exec(spiceString)
66
+ if (!match) break
67
+
68
+ const blockStart = match.index
69
+ const firstBraceIndex = spiceString.indexOf('{', blockStart)
70
+ const blockEnd =
71
+ SpiceCompatibilityPreprocessor.#findBalancedBlockEnd(
72
+ spiceString,
73
+ firstBraceIndex
74
+ )
75
+
76
+ if (blockEnd === -1) break
77
+
78
+ result += spiceString.slice(cursor, blockStart)
79
+ const block = spiceString.slice(blockStart, blockEnd)
80
+ result += block.replace(/\s+\^\s+/g, (operator, offset, full) => {
81
+ if (
82
+ SpiceCompatibilityPreprocessor.#isValueBooleanCaret(
83
+ full,
84
+ offset,
85
+ operator.length
86
+ )
87
+ ) {
88
+ return operator.replace('^', '!=')
89
+ }
90
+
91
+ return operator
92
+ })
93
+ cursor = blockEnd
94
+ }
95
+
96
+ return result + spiceString.slice(cursor)
97
+ }
98
+
99
+ /**
100
+ * Finds the exclusive end offset of a balanced brace block.
101
+ * @param {string} text Source text.
102
+ * @param {number} firstBraceIndex Offset of the opening brace.
103
+ * @returns {number}
104
+ */
105
+ static #findBalancedBlockEnd(text, firstBraceIndex) {
106
+ if (firstBraceIndex < 0) return -1
107
+
108
+ let depth = 0
109
+ for (let index = firstBraceIndex; index < text.length; index += 1) {
110
+ const character = text[index]
111
+ if (character === '{') {
112
+ depth += 1
113
+ } else if (character === '}') {
114
+ depth -= 1
115
+ if (depth === 0) return index + 1
116
+ }
117
+ }
118
+
119
+ return -1
120
+ }
121
+
122
+ /**
123
+ * Returns true when a caret separates two comparison expressions.
124
+ * @param {string} block VALUE block text.
125
+ * @param {number} caretOffset Caret operator offset.
126
+ * @param {number} operatorLength Operator token length.
127
+ * @returns {boolean}
128
+ */
129
+ static #isValueBooleanCaret(block, caretOffset, operatorLength) {
130
+ return (
131
+ PSPICE_COMPARISON_BEFORE_CARET_PATTERN.test(
132
+ block.slice(0, caretOffset)
133
+ ) &&
134
+ PSPICE_COMPARISON_AFTER_CARET_PATTERN.test(
135
+ block.slice(caretOffset + operatorLength)
136
+ )
137
+ )
138
+ }
139
+ }
@@ -0,0 +1,231 @@
1
+ const NUMBER_SUFFIX_MULTIPLIERS = {
2
+ t: 1e12,
3
+ g: 1e9,
4
+ meg: 1e6,
5
+ k: 1e3,
6
+ m: 1e-3,
7
+ ms: 1e-3,
8
+ u: 1e-6,
9
+ us: 1e-6,
10
+ n: 1e-9,
11
+ ns: 1e-9,
12
+ p: 1e-12,
13
+ ps: 1e-12,
14
+ f: 1e-15,
15
+ fs: 1e-15,
16
+ s: 1
17
+ }
18
+ const VOLTAGE_PROBE_COMMENT_PATTERN =
19
+ /^\s*\*\s*(?:ecadforge_voltage_probe|circuitjson_voltage_probe|simulation_voltage_probe)\s+(.+)\s*$/
20
+ const CURRENT_PROBE_COMMENT_PATTERN =
21
+ /^\s*\*\s*(?:ecadforge_current_probe|circuitjson_current_probe|simulation_current_probe)\s+(.+)\s*$/
22
+
23
+ /**
24
+ * Parses SPICE directives and metadata comments used by simulation helpers.
25
+ */
26
+ export class SpiceDirectiveParser {
27
+ /**
28
+ * Parses transient directive timing parameters.
29
+ * @param {string} spiceString SPICE netlist text.
30
+ * @returns {{ tstep?: number, tstop?: number, tstart?: number, tmax?: number, uic?: boolean } | null}
31
+ */
32
+ static parseTransient(spiceString) {
33
+ for (const rawLine of String(spiceString || '').split(/\r?\n/)) {
34
+ const line = rawLine.trim()
35
+ if (!line || line.startsWith('*')) continue
36
+ if (!line.toLowerCase().startsWith('.tran')) continue
37
+
38
+ const [withoutComment = ''] = line.split(';')
39
+ const tokens = withoutComment.split(/\s+/).filter(Boolean)
40
+ const values = []
41
+ let uic = false
42
+
43
+ for (const token of tokens.slice(1)) {
44
+ if (token.toLowerCase() === 'uic') {
45
+ uic = true
46
+ continue
47
+ }
48
+
49
+ const value = SpiceDirectiveParser.parseNumber(token)
50
+ if (value !== undefined) values.push(value)
51
+ }
52
+
53
+ return {
54
+ ...(values[0] !== undefined ? { tstep: values[0] } : {}),
55
+ ...(values[1] !== undefined ? { tstop: values[1] } : {}),
56
+ ...(values[2] !== undefined ? { tstart: values[2] } : {}),
57
+ ...(values[3] !== undefined ? { tmax: values[3] } : {}),
58
+ ...(uic ? { uic: true } : {})
59
+ }
60
+ }
61
+
62
+ return null
63
+ }
64
+
65
+ /**
66
+ * Parses requested transient plot tokens from the first PRINT directive.
67
+ * @param {string} spiceString SPICE netlist text.
68
+ * @returns {Map<string, string> | null}
69
+ */
70
+ static parseRequestedPlots(spiceString) {
71
+ const match = String(spiceString || '').match(/\.print\s+tran\s+(.*)/i)
72
+ if (!match?.[1]) return null
73
+
74
+ const tokens = match[1].match(/[VI]\s*\([^)]+\)/gi)
75
+ if (!tokens) return null
76
+
77
+ const plots = new Map()
78
+ for (const token of tokens) {
79
+ const normalizedToken = SpiceDirectiveParser.normalizeVector(token)
80
+ if (!plots.has(normalizedToken)) {
81
+ plots.set(normalizedToken, token)
82
+ }
83
+ }
84
+
85
+ return plots
86
+ }
87
+
88
+ /**
89
+ * Extracts voltage probe metadata comments from a netlist.
90
+ * @param {string} spiceString SPICE netlist text.
91
+ * @returns {Map<string, object>}
92
+ */
93
+ static extractVoltageProbeMetadata(spiceString) {
94
+ return SpiceDirectiveParser.#extractProbeMetadata(
95
+ spiceString,
96
+ VOLTAGE_PROBE_COMMENT_PATTERN,
97
+ (parsed) => {
98
+ if (
99
+ typeof parsed.simulation_voltage_probe_id !== 'string' ||
100
+ typeof parsed.spice_vector !== 'string' ||
101
+ typeof parsed.source_node_name !== 'string'
102
+ ) {
103
+ return null
104
+ }
105
+
106
+ return {
107
+ simulation_voltage_probe_id:
108
+ parsed.simulation_voltage_probe_id,
109
+ name:
110
+ typeof parsed.name === 'string'
111
+ ? parsed.name
112
+ : undefined,
113
+ spice_vector: parsed.spice_vector,
114
+ source_node_name: parsed.source_node_name,
115
+ reference_node_name:
116
+ typeof parsed.reference_node_name === 'string'
117
+ ? parsed.reference_node_name
118
+ : undefined
119
+ }
120
+ }
121
+ )
122
+ }
123
+
124
+ /**
125
+ * Extracts current probe metadata comments from a netlist.
126
+ * @param {string} spiceString SPICE netlist text.
127
+ * @returns {Map<string, object>}
128
+ */
129
+ static extractCurrentProbeMetadata(spiceString) {
130
+ return SpiceDirectiveParser.#extractProbeMetadata(
131
+ spiceString,
132
+ CURRENT_PROBE_COMMENT_PATTERN,
133
+ (parsed) => {
134
+ if (
135
+ typeof parsed.simulation_current_probe_id !== 'string' ||
136
+ typeof parsed.spice_vector !== 'string'
137
+ ) {
138
+ return null
139
+ }
140
+
141
+ return {
142
+ simulation_current_probe_id:
143
+ parsed.simulation_current_probe_id,
144
+ name:
145
+ typeof parsed.name === 'string'
146
+ ? parsed.name
147
+ : undefined,
148
+ spice_vector: parsed.spice_vector,
149
+ source_component_id:
150
+ typeof parsed.source_component_id === 'string'
151
+ ? parsed.source_component_id
152
+ : undefined,
153
+ source_trace_id:
154
+ typeof parsed.source_trace_id === 'string'
155
+ ? parsed.source_trace_id
156
+ : undefined
157
+ }
158
+ }
159
+ )
160
+ }
161
+
162
+ /**
163
+ * Normalizes a simulator vector token for map lookups.
164
+ * @param {string} value Raw vector token.
165
+ * @returns {string}
166
+ */
167
+ static normalizeVector(value) {
168
+ return String(value || '')
169
+ .toLowerCase()
170
+ .replace(/\s/g, '')
171
+ }
172
+
173
+ /**
174
+ * Parses a SPICE numeric token with common suffix multipliers.
175
+ * @param {string} token Numeric token.
176
+ * @returns {number | undefined}
177
+ */
178
+ static parseNumber(token) {
179
+ const normalized = String(token || '')
180
+ .replace(/[,]/g, '')
181
+ .toLowerCase()
182
+ const match = normalized.match(
183
+ /^([+-]?\d*\.?\d+(?:e[+-]?\d+)?)([a-z]+)?$/i
184
+ )
185
+
186
+ if (!match) return undefined
187
+
188
+ const base = Number.parseFloat(match[1] || '')
189
+ if (!Number.isFinite(base)) return undefined
190
+
191
+ const suffix = match[2] || ''
192
+ if (!suffix) return base
193
+
194
+ const multiplier =
195
+ NUMBER_SUFFIX_MULTIPLIERS[suffix] ??
196
+ NUMBER_SUFFIX_MULTIPLIERS[suffix.replace(/s$/, '')] ??
197
+ 1
198
+
199
+ return base * multiplier
200
+ }
201
+
202
+ /**
203
+ * Extracts JSON probe metadata comments with a validator callback.
204
+ * @param {string} spiceString SPICE netlist text.
205
+ * @param {RegExp} pattern Comment matcher.
206
+ * @param {(parsed: object) => object | null} shapeMetadata Metadata shaper.
207
+ * @returns {Map<string, object>}
208
+ */
209
+ static #extractProbeMetadata(spiceString, pattern, shapeMetadata) {
210
+ const metadata = new Map()
211
+
212
+ for (const line of String(spiceString || '').split(/\r?\n/)) {
213
+ const match = line.match(pattern)
214
+ if (!match?.[1]) continue
215
+
216
+ try {
217
+ const shapedMetadata = shapeMetadata(JSON.parse(match[1]))
218
+ if (!shapedMetadata) continue
219
+
220
+ metadata.set(
221
+ SpiceDirectiveParser.normalizeVector(
222
+ shapedMetadata.spice_vector
223
+ ),
224
+ shapedMetadata
225
+ )
226
+ } catch {}
227
+ }
228
+
229
+ return metadata
230
+ }
231
+ }