altium-toolkit 0.1.1 → 0.1.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.
- package/README.md +24 -6
- package/docs/api.md +42 -4
- package/docs/model-format.md +95 -5
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
- package/docs/testing.md +7 -2
- package/package.json +6 -2
- package/spec/library-scope.md +7 -1
- package/src/core/altium/AltiumParser.mjs +22 -325
- package/src/core/altium/NormalizedModelSchema.mjs +28 -0
- package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
- package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
- package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
- package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
- package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
- package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
- package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
- package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
- package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
- package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
- package/src/core/altium/PcbLibModelParser.mjs +202 -0
- package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
- package/src/core/altium/PcbModelParser.mjs +618 -66
- package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
- package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
- package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
- package/src/core/altium/PcbPadStackParser.mjs +903 -0
- package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
- package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
- package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
- package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
- package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
- package/src/core/altium/PcbRuleParser.mjs +587 -0
- package/src/core/altium/PcbStreamExtractor.mjs +127 -4
- package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
- package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
- package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
- package/src/core/altium/PcbViaStackParser.mjs +548 -0
- package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
- package/src/core/altium/PrjPcbModelParser.mjs +797 -0
- package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
- package/src/parser.mjs +13 -0
- package/src/renderers.mjs +5 -0
- package/src/styles/altium-renderers.css +11 -6
- package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
- package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
- package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
- package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
- package/src/ui/PcbSvgRenderer.mjs +101 -109
- package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
- package/src/ui/SchematicSheetZoneRenderer.mjs +104 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { ParserUtils } from './ParserUtils.mjs'
|
|
6
|
+
|
|
7
|
+
const { getField, parseBoolean, parseNumericField } = ParserUtils
|
|
8
|
+
|
|
9
|
+
const COMMON_RULE_FIELDS = new Set([
|
|
10
|
+
'SELECTION',
|
|
11
|
+
'LAYER',
|
|
12
|
+
'LOCKED',
|
|
13
|
+
'POLYGONOUTLINE',
|
|
14
|
+
'USERROUTED',
|
|
15
|
+
'KEEPOUT',
|
|
16
|
+
'UNIONINDEX',
|
|
17
|
+
'RULEKIND',
|
|
18
|
+
'NETSCOPE',
|
|
19
|
+
'LAYERKIND',
|
|
20
|
+
'SCOPE1EXPRESSION',
|
|
21
|
+
'SCOPE2EXPRESSION',
|
|
22
|
+
'NAME',
|
|
23
|
+
'ENABLED',
|
|
24
|
+
'PRIORITY',
|
|
25
|
+
'COMMENT',
|
|
26
|
+
'UNIQUEID',
|
|
27
|
+
'DEFINEDBYLOGICALDOCUMENT'
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
const RULE_TYPES = new Map([
|
|
31
|
+
['WIDTH', { kind: 'width', category: 'routing', displayName: 'Width' }],
|
|
32
|
+
[
|
|
33
|
+
'CLEARANCE',
|
|
34
|
+
{
|
|
35
|
+
kind: 'clearance',
|
|
36
|
+
category: 'electrical',
|
|
37
|
+
displayName: 'Clearance'
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
'ROUTINGVIAS',
|
|
42
|
+
{
|
|
43
|
+
kind: 'routing-vias',
|
|
44
|
+
category: 'routing',
|
|
45
|
+
displayName: 'Routing Vias'
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
'ROUTINGLAYERS',
|
|
50
|
+
{
|
|
51
|
+
kind: 'routing-layers',
|
|
52
|
+
category: 'routing',
|
|
53
|
+
displayName: 'Routing Layers'
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
'DIFFERENTIALPAIRROUTING',
|
|
58
|
+
{
|
|
59
|
+
kind: 'differential-pair-routing',
|
|
60
|
+
category: 'routing',
|
|
61
|
+
displayName: 'Differential Pair Routing'
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
'SHORTCIRCUIT',
|
|
66
|
+
{
|
|
67
|
+
kind: 'short-circuit',
|
|
68
|
+
category: 'electrical',
|
|
69
|
+
displayName: 'Short Circuit'
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
'UNROUTEDNET',
|
|
74
|
+
{
|
|
75
|
+
kind: 'unrouted-net',
|
|
76
|
+
category: 'electrical',
|
|
77
|
+
displayName: 'Unrouted Net'
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
[
|
|
81
|
+
'SILKSCREENOVERCOMPONENTPADS',
|
|
82
|
+
{
|
|
83
|
+
kind: 'silkscreen-over-component-pads',
|
|
84
|
+
category: 'manufacturing',
|
|
85
|
+
displayName: 'Silkscreen Over Component Pads'
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Normalizes native Altium PCB design-rule records from Rules6/Data.
|
|
92
|
+
*/
|
|
93
|
+
export class PcbRuleParser {
|
|
94
|
+
/**
|
|
95
|
+
* Parses normalized PCB design rules in native stream order.
|
|
96
|
+
* @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records
|
|
97
|
+
* @returns {Array<Record<string, unknown> & { ruleIndex: number, name: string, ruleKind: string, constraints: Record<string, string>, constraintValues: Record<string, Record<string, unknown>>, typedConstraints: Record<string, Record<string, unknown>> }>}
|
|
98
|
+
*/
|
|
99
|
+
static parse(records) {
|
|
100
|
+
return records
|
|
101
|
+
.filter((record) => record.sourceStream === 'Rules6/Data')
|
|
102
|
+
.map((record, index) =>
|
|
103
|
+
PcbRuleParser.#normalizeRuleRecord(record.fields, index)
|
|
104
|
+
)
|
|
105
|
+
.filter((rule) => rule.name || rule.ruleKind || rule.uniqueId)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Normalizes one native design-rule record.
|
|
110
|
+
* @param {Record<string, string | string[]>} fields
|
|
111
|
+
* @param {number} ruleIndex
|
|
112
|
+
* @returns {{ ruleIndex: number, name: string, ruleKind: string, enabled: boolean | null, priority: number | null, uniqueId: string, comment: string, selection: boolean | null, layer: string, locked: boolean | null, polygonOutline: boolean | null, userRouted: boolean | null, keepout: boolean | null, unionIndex: number | null, netScope: string, layerKind: string, scope1Expression: string, scope2Expression: string, scope1: { rawExpression: string, predicate: string, arguments: string[], isAll: boolean }, scope2: { rawExpression: string, predicate: string, arguments: string[], isAll: boolean }, ruleType: { rawKind: string, kind: string, category: string, displayName: string }, constraints: Record<string, string>, constraintValues: Record<string, Record<string, unknown>>, typedConstraints: Record<string, Record<string, unknown>> }}
|
|
113
|
+
*/
|
|
114
|
+
static #normalizeRuleRecord(fields, ruleIndex) {
|
|
115
|
+
const scope1Expression = getField(fields, 'SCOPE1EXPRESSION')
|
|
116
|
+
const scope2Expression = getField(fields, 'SCOPE2EXPRESSION')
|
|
117
|
+
const ruleKind = getField(fields, 'RULEKIND')
|
|
118
|
+
const constraints = PcbRuleParser.#parseConstraints(fields)
|
|
119
|
+
const constraintValues =
|
|
120
|
+
PcbRuleParser.#parseConstraintValues(constraints)
|
|
121
|
+
const ruleType = PcbRuleParser.#parseRuleType(ruleKind)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
ruleIndex,
|
|
125
|
+
name: getField(fields, 'NAME'),
|
|
126
|
+
ruleKind,
|
|
127
|
+
enabled: PcbRuleParser.#parseOptionalBoolean(fields, 'ENABLED'),
|
|
128
|
+
priority: parseNumericField(fields, 'PRIORITY'),
|
|
129
|
+
uniqueId: getField(fields, 'UNIQUEID') || getField(fields, 'UID'),
|
|
130
|
+
comment: getField(fields, 'COMMENT'),
|
|
131
|
+
selection: PcbRuleParser.#parseOptionalBoolean(fields, 'SELECTION'),
|
|
132
|
+
layer: getField(fields, 'LAYER'),
|
|
133
|
+
locked: PcbRuleParser.#parseOptionalBoolean(fields, 'LOCKED'),
|
|
134
|
+
polygonOutline: PcbRuleParser.#parseOptionalBoolean(
|
|
135
|
+
fields,
|
|
136
|
+
'POLYGONOUTLINE'
|
|
137
|
+
),
|
|
138
|
+
userRouted: PcbRuleParser.#parseOptionalBoolean(
|
|
139
|
+
fields,
|
|
140
|
+
'USERROUTED'
|
|
141
|
+
),
|
|
142
|
+
keepout: PcbRuleParser.#parseOptionalBoolean(fields, 'KEEPOUT'),
|
|
143
|
+
unionIndex: parseNumericField(fields, 'UNIONINDEX'),
|
|
144
|
+
netScope: getField(fields, 'NETSCOPE'),
|
|
145
|
+
layerKind: getField(fields, 'LAYERKIND'),
|
|
146
|
+
scope1Expression,
|
|
147
|
+
scope2Expression,
|
|
148
|
+
scope1: PcbRuleParser.parseScopeExpression(scope1Expression),
|
|
149
|
+
scope2: PcbRuleParser.parseScopeExpression(scope2Expression),
|
|
150
|
+
ruleType,
|
|
151
|
+
constraints,
|
|
152
|
+
constraintValues,
|
|
153
|
+
typedConstraints: PcbRuleParser.#parseTypedConstraints(
|
|
154
|
+
ruleType,
|
|
155
|
+
constraintValues
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parses one Altium rule scope predicate expression.
|
|
162
|
+
* @param {string} expression
|
|
163
|
+
* @returns {{ rawExpression: string, predicate: string, arguments: string[], isAll: boolean }}
|
|
164
|
+
*/
|
|
165
|
+
static parseScopeExpression(expression) {
|
|
166
|
+
const rawExpression = String(expression || '').trim()
|
|
167
|
+
|
|
168
|
+
if (!rawExpression) {
|
|
169
|
+
return {
|
|
170
|
+
rawExpression: '',
|
|
171
|
+
predicate: '',
|
|
172
|
+
arguments: [],
|
|
173
|
+
isAll: false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (rawExpression.toUpperCase() === 'ALL') {
|
|
178
|
+
return {
|
|
179
|
+
rawExpression,
|
|
180
|
+
predicate: 'All',
|
|
181
|
+
arguments: [],
|
|
182
|
+
isAll: true
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const match = rawExpression.match(/^\s*([A-Za-z0-9_]+)\((.*)\)\s*$/u)
|
|
187
|
+
if (!match) {
|
|
188
|
+
return {
|
|
189
|
+
rawExpression,
|
|
190
|
+
predicate: rawExpression,
|
|
191
|
+
arguments: [],
|
|
192
|
+
isAll: false
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
rawExpression,
|
|
198
|
+
predicate: match[1],
|
|
199
|
+
arguments: PcbRuleParser.#parseScopeArguments(match[2]),
|
|
200
|
+
isAll: false
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Parses one comma-separated Altium scope argument list.
|
|
206
|
+
* @param {string} text
|
|
207
|
+
* @returns {string[]}
|
|
208
|
+
*/
|
|
209
|
+
static #parseScopeArguments(text) {
|
|
210
|
+
const args = []
|
|
211
|
+
let token = ''
|
|
212
|
+
let inQuote = false
|
|
213
|
+
|
|
214
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
215
|
+
const character = text[index]
|
|
216
|
+
|
|
217
|
+
if (character === "'") {
|
|
218
|
+
if (inQuote && text[index + 1] === "'") {
|
|
219
|
+
token += "'"
|
|
220
|
+
index += 1
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
inQuote = !inQuote
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (character === ',' && !inQuote) {
|
|
228
|
+
PcbRuleParser.#pushScopeArgument(args, token)
|
|
229
|
+
token = ''
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
token += character
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
PcbRuleParser.#pushScopeArgument(args, token)
|
|
237
|
+
return args
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Adds one trimmed scope argument when it is not empty.
|
|
242
|
+
* @param {string[]} args
|
|
243
|
+
* @param {string} token
|
|
244
|
+
*/
|
|
245
|
+
static #pushScopeArgument(args, token) {
|
|
246
|
+
const value = token.trim()
|
|
247
|
+
|
|
248
|
+
if (value) {
|
|
249
|
+
args.push(value)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Keeps rule-specific fields as raw string constraints.
|
|
255
|
+
* @param {Record<string, string | string[]>} fields
|
|
256
|
+
* @returns {Record<string, string>}
|
|
257
|
+
*/
|
|
258
|
+
static #parseConstraints(fields) {
|
|
259
|
+
const constraints = {}
|
|
260
|
+
|
|
261
|
+
for (const key of Object.keys(fields || {})) {
|
|
262
|
+
const normalizedKey = key.toUpperCase()
|
|
263
|
+
const value = getField(fields, key)
|
|
264
|
+
|
|
265
|
+
if (!COMMON_RULE_FIELDS.has(normalizedKey) && value) {
|
|
266
|
+
constraints[normalizedKey] = value
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return constraints
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Parses all raw constraints into typed values while preserving their keys.
|
|
275
|
+
* @param {Record<string, string>} constraints
|
|
276
|
+
* @returns {Record<string, Record<string, unknown>>}
|
|
277
|
+
*/
|
|
278
|
+
static #parseConstraintValues(constraints) {
|
|
279
|
+
return Object.fromEntries(
|
|
280
|
+
Object.entries(constraints).map(([key, raw]) => [
|
|
281
|
+
key,
|
|
282
|
+
PcbRuleParser.#parseConstraintValue(raw)
|
|
283
|
+
])
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Parses one rule-specific constraint value.
|
|
289
|
+
* @param {string} raw
|
|
290
|
+
* @returns {Record<string, unknown>}
|
|
291
|
+
*/
|
|
292
|
+
static #parseConstraintValue(raw) {
|
|
293
|
+
const text = String(raw || '').trim()
|
|
294
|
+
const numeric = PcbRuleParser.#parseNumericUnit(text)
|
|
295
|
+
|
|
296
|
+
if (numeric) {
|
|
297
|
+
return numeric
|
|
298
|
+
}
|
|
299
|
+
if (/^(TRUE|FALSE|T|F)$/iu.test(text)) {
|
|
300
|
+
return {
|
|
301
|
+
raw: text,
|
|
302
|
+
type: 'boolean',
|
|
303
|
+
value: parseBoolean(text)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
raw: text,
|
|
309
|
+
type: 'string',
|
|
310
|
+
value: text
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Parses a numeric value with a common Altium unit suffix.
|
|
316
|
+
* @param {string} text
|
|
317
|
+
* @returns {Record<string, unknown> | null}
|
|
318
|
+
*/
|
|
319
|
+
static #parseNumericUnit(text) {
|
|
320
|
+
const match = text.match(
|
|
321
|
+
/^\s*(-?\d+(?:\.\d+)?(?:E[+-]?\d+)?)\s*([A-Za-z%]*)\s*$/iu
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if (!match) {
|
|
325
|
+
return null
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const value = Number(match[1])
|
|
329
|
+
if (!Number.isFinite(value)) {
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const unit = PcbRuleParser.#normalizeUnit(match[2])
|
|
334
|
+
if (PcbRuleParser.#isLengthUnit(unit)) {
|
|
335
|
+
return {
|
|
336
|
+
raw: text,
|
|
337
|
+
type: 'length',
|
|
338
|
+
value,
|
|
339
|
+
unit,
|
|
340
|
+
valueMil: PcbRuleParser.#roundUnit(
|
|
341
|
+
PcbRuleParser.#toMil(value, unit)
|
|
342
|
+
),
|
|
343
|
+
valueMm: PcbRuleParser.#roundUnit(
|
|
344
|
+
PcbRuleParser.#toMm(value, unit)
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (unit === 'deg') {
|
|
349
|
+
return {
|
|
350
|
+
raw: text,
|
|
351
|
+
type: 'angle',
|
|
352
|
+
value,
|
|
353
|
+
unit,
|
|
354
|
+
valueDeg: value
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (unit === '%') {
|
|
358
|
+
return {
|
|
359
|
+
raw: text,
|
|
360
|
+
type: 'percent',
|
|
361
|
+
value,
|
|
362
|
+
unit,
|
|
363
|
+
ratio: PcbRuleParser.#roundUnit(value / 100)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
raw: text,
|
|
369
|
+
type: 'number',
|
|
370
|
+
value,
|
|
371
|
+
unit: unit || null
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Normalizes a parsed unit suffix.
|
|
377
|
+
* @param {string} rawUnit
|
|
378
|
+
* @returns {string}
|
|
379
|
+
*/
|
|
380
|
+
static #normalizeUnit(rawUnit) {
|
|
381
|
+
const unit = String(rawUnit || '')
|
|
382
|
+
.trim()
|
|
383
|
+
.toLowerCase()
|
|
384
|
+
|
|
385
|
+
if (unit === 'mils') {
|
|
386
|
+
return 'mil'
|
|
387
|
+
}
|
|
388
|
+
if (unit === 'millimeter' || unit === 'millimeters') {
|
|
389
|
+
return 'mm'
|
|
390
|
+
}
|
|
391
|
+
if (unit === 'inch' || unit === 'inches') {
|
|
392
|
+
return 'in'
|
|
393
|
+
}
|
|
394
|
+
if (unit === 'degree' || unit === 'degrees') {
|
|
395
|
+
return 'deg'
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return unit
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Returns whether a normalized unit represents a length.
|
|
403
|
+
* @param {string} unit
|
|
404
|
+
* @returns {boolean}
|
|
405
|
+
*/
|
|
406
|
+
static #isLengthUnit(unit) {
|
|
407
|
+
return unit === 'mil' || unit === 'mm' || unit === 'in' || unit === 'um'
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Converts one normalized length to mils.
|
|
412
|
+
* @param {number} value
|
|
413
|
+
* @param {string} unit
|
|
414
|
+
* @returns {number}
|
|
415
|
+
*/
|
|
416
|
+
static #toMil(value, unit) {
|
|
417
|
+
if (unit === 'mm') {
|
|
418
|
+
return value / 0.0254
|
|
419
|
+
}
|
|
420
|
+
if (unit === 'in') {
|
|
421
|
+
return value * 1000
|
|
422
|
+
}
|
|
423
|
+
if (unit === 'um') {
|
|
424
|
+
return value / 25.4
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return value
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Converts one normalized length to millimeters.
|
|
432
|
+
* @param {number} value
|
|
433
|
+
* @param {string} unit
|
|
434
|
+
* @returns {number}
|
|
435
|
+
*/
|
|
436
|
+
static #toMm(value, unit) {
|
|
437
|
+
if (unit === 'mil') {
|
|
438
|
+
return value * 0.0254
|
|
439
|
+
}
|
|
440
|
+
if (unit === 'in') {
|
|
441
|
+
return value * 25.4
|
|
442
|
+
}
|
|
443
|
+
if (unit === 'um') {
|
|
444
|
+
return value / 1000
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return value
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Rounds converted values to avoid floating-point noise in model output.
|
|
452
|
+
* @param {number} value
|
|
453
|
+
* @returns {number}
|
|
454
|
+
*/
|
|
455
|
+
static #roundUnit(value) {
|
|
456
|
+
return Number(value.toFixed(6))
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Parses one native rule kind into a stable typed descriptor.
|
|
461
|
+
* @param {string} ruleKind
|
|
462
|
+
* @returns {{ rawKind: string, kind: string, category: string, displayName: string }}
|
|
463
|
+
*/
|
|
464
|
+
static #parseRuleType(ruleKind) {
|
|
465
|
+
const rawKind = String(ruleKind || '').trim()
|
|
466
|
+
const key = rawKind.replace(/[^A-Za-z0-9]/gu, '').toUpperCase()
|
|
467
|
+
const mapped = RULE_TYPES.get(key)
|
|
468
|
+
|
|
469
|
+
if (mapped) {
|
|
470
|
+
return {
|
|
471
|
+
rawKind,
|
|
472
|
+
...mapped
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
rawKind,
|
|
478
|
+
kind: PcbRuleParser.#toKebabCase(rawKind),
|
|
479
|
+
category: 'custom',
|
|
480
|
+
displayName: PcbRuleParser.#displayRuleKind(rawKind)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Builds semantic typed constraint aliases for known rule kinds.
|
|
486
|
+
* @param {{ kind: string }} ruleType
|
|
487
|
+
* @param {Record<string, Record<string, unknown>>} constraintValues
|
|
488
|
+
* @returns {Record<string, Record<string, unknown>>}
|
|
489
|
+
*/
|
|
490
|
+
static #parseTypedConstraints(ruleType, constraintValues) {
|
|
491
|
+
if (ruleType.kind === 'width') {
|
|
492
|
+
return PcbRuleParser.#parseWidthConstraints(constraintValues)
|
|
493
|
+
}
|
|
494
|
+
if (ruleType.kind === 'clearance') {
|
|
495
|
+
return PcbRuleParser.#parseClearanceConstraints(constraintValues)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return {}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Builds semantic aliases for width-rule constraints.
|
|
503
|
+
* @param {Record<string, Record<string, unknown>>} constraintValues
|
|
504
|
+
* @returns {Record<string, Record<string, unknown>>}
|
|
505
|
+
*/
|
|
506
|
+
static #parseWidthConstraints(constraintValues) {
|
|
507
|
+
return PcbRuleParser.#pickTypedConstraints(constraintValues, {
|
|
508
|
+
minWidth: ['MINLIMIT', 'MINWIDTH'],
|
|
509
|
+
preferredWidth: ['PREFEREDWIDTH', 'PREFERREDWIDTH'],
|
|
510
|
+
maxWidth: ['MAXLIMIT', 'MAXWIDTH']
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Builds semantic aliases for clearance-rule constraints.
|
|
516
|
+
* @param {Record<string, Record<string, unknown>>} constraintValues
|
|
517
|
+
* @returns {Record<string, Record<string, unknown>>}
|
|
518
|
+
*/
|
|
519
|
+
static #parseClearanceConstraints(constraintValues) {
|
|
520
|
+
return PcbRuleParser.#pickTypedConstraints(constraintValues, {
|
|
521
|
+
minClearance: ['GAP', 'MINDISTANCE', 'CLEARANCE'],
|
|
522
|
+
genericClearance: ['GENERICCLEARANCE']
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Picks the first available typed constraint value for each semantic alias.
|
|
528
|
+
* @param {Record<string, Record<string, unknown>>} constraintValues
|
|
529
|
+
* @param {Record<string, string[]>} aliases
|
|
530
|
+
* @returns {Record<string, Record<string, unknown>>}
|
|
531
|
+
*/
|
|
532
|
+
static #pickTypedConstraints(constraintValues, aliases) {
|
|
533
|
+
const typed = {}
|
|
534
|
+
|
|
535
|
+
for (const [alias, keys] of Object.entries(aliases)) {
|
|
536
|
+
const key = keys.find((candidate) => constraintValues[candidate])
|
|
537
|
+
|
|
538
|
+
if (key) {
|
|
539
|
+
typed[alias] = {
|
|
540
|
+
key,
|
|
541
|
+
...constraintValues[key]
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return typed
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Converts a raw rule kind to a lower-kebab fallback id.
|
|
551
|
+
* @param {string} rawKind
|
|
552
|
+
* @returns {string}
|
|
553
|
+
*/
|
|
554
|
+
static #toKebabCase(rawKind) {
|
|
555
|
+
return String(rawKind || 'unknown')
|
|
556
|
+
.replace(/([a-z0-9])([A-Z])/gu, '$1-$2')
|
|
557
|
+
.replace(/[^A-Za-z0-9]+/gu, '-')
|
|
558
|
+
.replace(/^-|-$/gu, '')
|
|
559
|
+
.toLowerCase()
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Builds a readable fallback display name from a raw rule kind.
|
|
564
|
+
* @param {string} rawKind
|
|
565
|
+
* @returns {string}
|
|
566
|
+
*/
|
|
567
|
+
static #displayRuleKind(rawKind) {
|
|
568
|
+
const spaced = String(rawKind || 'Unknown')
|
|
569
|
+
.replace(/([a-z0-9])([A-Z])/gu, '$1 $2')
|
|
570
|
+
.replace(/[_-]+/gu, ' ')
|
|
571
|
+
.trim()
|
|
572
|
+
|
|
573
|
+
return spaced || 'Unknown'
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Parses an optional Altium boolean field.
|
|
578
|
+
* @param {Record<string, string | string[]>} fields
|
|
579
|
+
* @param {string} key
|
|
580
|
+
* @returns {boolean | null}
|
|
581
|
+
*/
|
|
582
|
+
static #parseOptionalBoolean(fields, key) {
|
|
583
|
+
const raw = getField(fields, key)
|
|
584
|
+
|
|
585
|
+
return raw ? parseBoolean(raw) : null
|
|
586
|
+
}
|
|
587
|
+
}
|