circuitjson-toolkit 1.0.3 → 1.0.10

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.
@@ -0,0 +1,259 @@
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_outline',
12
+ 'pcb_courtyard_pill',
13
+ 'pcb_courtyard_polygon',
14
+ 'pcb_courtyard_rect',
15
+ 'pcb_cutout',
16
+ 'pcb_fabrication_note_dimension',
17
+ 'pcb_fabrication_note_path',
18
+ 'pcb_fabrication_note_rect',
19
+ 'pcb_fabrication_note_text',
20
+ 'pcb_ground_plane',
21
+ 'pcb_ground_plane_region',
22
+ 'pcb_group',
23
+ 'pcb_hole',
24
+ 'pcb_keepout',
25
+ 'pcb_note_dimension',
26
+ 'pcb_note_line',
27
+ 'pcb_note_path',
28
+ 'pcb_note_rect',
29
+ 'pcb_note_text',
30
+ 'pcb_panel',
31
+ 'pcb_plated_hole',
32
+ 'pcb_port',
33
+ 'pcb_silkscreen_circle',
34
+ 'pcb_silkscreen_graphic',
35
+ 'pcb_silkscreen_line',
36
+ 'pcb_silkscreen_oval',
37
+ 'pcb_silkscreen_path',
38
+ 'pcb_silkscreen_pill',
39
+ 'pcb_silkscreen_rect',
40
+ 'pcb_silkscreen_text',
41
+ 'pcb_smtpad',
42
+ 'pcb_solder_paste',
43
+ 'pcb_text',
44
+ 'pcb_thermal_spoke',
45
+ 'pcb_trace',
46
+ 'pcb_trace_hint',
47
+ 'pcb_via'
48
+ ])
49
+
50
+ const SCHEMATIC_RENDERED_TYPES = new Set([
51
+ 'schematic_arc',
52
+ 'schematic_box',
53
+ 'schematic_circle',
54
+ 'schematic_component',
55
+ 'schematic_debug_object',
56
+ 'schematic_group',
57
+ 'schematic_line',
58
+ 'schematic_net_label',
59
+ 'schematic_path',
60
+ 'schematic_port',
61
+ 'schematic_rect',
62
+ 'schematic_sheet',
63
+ 'schematic_symbol',
64
+ 'schematic_table',
65
+ 'schematic_table_cell',
66
+ 'schematic_text',
67
+ 'schematic_trace',
68
+ 'schematic_voltage_probe'
69
+ ])
70
+
71
+ const ROUTING_DSN_TYPES = new Set([
72
+ 'pcb_board',
73
+ 'pcb_component',
74
+ 'pcb_smtpad',
75
+ 'pcb_trace',
76
+ 'pcb_via',
77
+ 'pcb_plated_hole',
78
+ 'source_net'
79
+ ])
80
+
81
+ /**
82
+ * Builds document-level support coverage reports from known element metadata.
83
+ */
84
+ export class CircuitJsonSupportMatrixBuilder {
85
+ /**
86
+ * Builds a support matrix for the known schema snapshot and present rows.
87
+ * @param {object[]} [circuitJson] Parsed element array.
88
+ * @returns {{ sourceFormat: string, totals: object, rows: object[], gaps: object[] }}
89
+ */
90
+ static build(circuitJson = []) {
91
+ const elements = Array.isArray(circuitJson) ? circuitJson : []
92
+ const presentTypes = new Set(
93
+ elements
94
+ .map((element) => String(element?.type || ''))
95
+ .filter(Boolean)
96
+ )
97
+ const rows = CircuitJsonElementValidator.knownElementTypes().map(
98
+ (type) =>
99
+ CircuitJsonSupportMatrixBuilder.#row(
100
+ type,
101
+ presentTypes.has(type)
102
+ )
103
+ )
104
+
105
+ return {
106
+ sourceFormat: 'circuitjson',
107
+ totals: CircuitJsonSupportMatrixBuilder.#totals(rows, presentTypes),
108
+ rows,
109
+ gaps: rows.flatMap((row) =>
110
+ CircuitJsonSupportMatrixBuilder.#gaps(row)
111
+ )
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Builds one matrix row.
117
+ * @param {string} type Element type.
118
+ * @param {boolean} present Whether the current document contains the type.
119
+ * @returns {object}
120
+ */
121
+ static #row(type, present) {
122
+ const capabilities = CircuitJsonSupportMatrixBuilder.#capabilities(type)
123
+ return {
124
+ type,
125
+ family: CircuitJsonSupportMatrixBuilder.#family(type),
126
+ present,
127
+ status: CircuitJsonSupportMatrixBuilder.#status(capabilities),
128
+ capabilities
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Resolves capability labels for one type.
134
+ * @param {string} type Element type.
135
+ * @returns {Record<string, string>}
136
+ */
137
+ static #capabilities(type) {
138
+ const capabilities = {
139
+ validation: 'known',
140
+ parser: 'preserved',
141
+ indexer: 'indexed',
142
+ diagnostics: CircuitJsonSupportMatrixBuilder.#diagnostics(type),
143
+ schematic: SCHEMATIC_RENDERED_TYPES.has(type) ? 'rendered' : 'none',
144
+ pcb: PCB_RENDERED_TYPES.has(type) ? 'rendered' : 'none',
145
+ bom: type === 'source_component' ? 'grouped' : 'none',
146
+ manufacturing: CircuitJsonSupportMatrixBuilder.#manufacturing(type),
147
+ simulation: type.startsWith('simulation_') ? 'preserved' : 'none'
148
+ }
149
+
150
+ if (type === 'pcb_component') {
151
+ capabilities.manufacturing = 'pick-and-place'
152
+ }
153
+
154
+ return capabilities
155
+ }
156
+
157
+ /**
158
+ * Resolves diagnostic support for one type.
159
+ * @param {string} type Element type.
160
+ * @returns {string}
161
+ */
162
+ static #diagnostics(type) {
163
+ return /(?:error|warning)/u.test(type) ? 'normalized' : 'none'
164
+ }
165
+
166
+ /**
167
+ * Resolves manufacturing support for one type.
168
+ * @param {string} type Element type.
169
+ * @returns {string}
170
+ */
171
+ static #manufacturing(type) {
172
+ if (ROUTING_DSN_TYPES.has(type)) return 'routing-dsn'
173
+ return 'none'
174
+ }
175
+
176
+ /**
177
+ * Resolves an overall support status.
178
+ * @param {Record<string, string>} capabilities Capability labels.
179
+ * @returns {'full' | 'partial' | 'metadata'}
180
+ */
181
+ static #status(capabilities) {
182
+ if (
183
+ capabilities.pcb === 'rendered' ||
184
+ capabilities.schematic === 'rendered' ||
185
+ capabilities.diagnostics === 'normalized'
186
+ ) {
187
+ return capabilities.manufacturing === 'routing-dsn'
188
+ ? 'partial'
189
+ : 'full'
190
+ }
191
+
192
+ return 'metadata'
193
+ }
194
+
195
+ /**
196
+ * Builds gap rows for present partially supported capabilities.
197
+ * @param {object} row Matrix row.
198
+ * @returns {object[]}
199
+ */
200
+ static #gaps(row) {
201
+ if (!row.present) return []
202
+ if (row.capabilities.manufacturing === 'routing-dsn') {
203
+ return [
204
+ {
205
+ type: row.type,
206
+ capability: 'manufacturing',
207
+ status: 'partial',
208
+ detail: 'Routing exchange metadata is generated without full fabrication packaging.'
209
+ }
210
+ ]
211
+ }
212
+ if (row.status === 'metadata') {
213
+ return [
214
+ {
215
+ type: row.type,
216
+ capability: 'rendering',
217
+ status: 'metadata',
218
+ detail: 'The element is preserved for downstream consumers.'
219
+ }
220
+ ]
221
+ }
222
+ return []
223
+ }
224
+
225
+ /**
226
+ * Builds aggregate matrix counts.
227
+ * @param {object[]} rows Matrix rows.
228
+ * @param {Set<string>} presentTypes Present element types.
229
+ * @returns {object}
230
+ */
231
+ static #totals(rows, presentTypes) {
232
+ return {
233
+ knownElementTypes: rows.length,
234
+ presentElementTypes: rows.filter((row) => row.present).length,
235
+ renderedElementTypes: rows.filter(
236
+ (row) =>
237
+ row.present &&
238
+ (row.capabilities.pcb === 'rendered' ||
239
+ row.capabilities.schematic === 'rendered')
240
+ ).length,
241
+ diagnosticElementTypes: rows.filter(
242
+ (row) =>
243
+ row.present && row.capabilities.diagnostics === 'normalized'
244
+ ).length,
245
+ unknownPresentElementTypes: [...presentTypes].filter(
246
+ (type) => !rows.some((row) => row.type === type)
247
+ ).length
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Resolves the top-level element family.
253
+ * @param {string} type Element type.
254
+ * @returns {string}
255
+ */
256
+ static #family(type) {
257
+ return String(type || '').split('_')[0] || 'unknown'
258
+ }
259
+ }
@@ -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
  /**
@@ -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
+ }