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.
- package/AGENTS.md +5 -3
- package/README.md +21 -2
- package/docs/api.md +50 -4
- package/docs/model-format.md +21 -3
- package/package.json +3 -2
- package/spec/library-scope.md +4 -1
- package/src/core/CircuitJsonBomBuilder.mjs +143 -0
- package/src/core/CircuitJsonDocument.mjs +46 -13
- package/src/core/CircuitJsonElementValidator.mjs +990 -0
- package/src/core/CircuitJsonIndexer.mjs +773 -4
- package/src/core/CircuitJsonManufacturingBuilder.mjs +898 -0
- package/src/core/CircuitJsonManufacturingDownloadBuilder.mjs +196 -0
- package/src/core/CircuitJsonParser.mjs +22 -6
- package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +329 -0
- package/src/core/CircuitJsonPcbCopperGeometry.mjs +503 -0
- package/src/core/CircuitJsonPcbDrawingStyle.mjs +88 -0
- package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +172 -0
- package/src/core/CircuitJsonPcbNetMetadata.mjs +247 -0
- package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +70 -0
- package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +992 -0
- package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +872 -0
- package/src/core/CircuitJsonPcbPrimitiveFields.mjs +233 -0
- package/src/core/CircuitJsonPcbPrimitiveGeometry.mjs +142 -0
- package/src/core/CircuitJsonPcbPrimitiveGroups.mjs +305 -0
- package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +65 -0
- package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +895 -0
- package/src/core/CircuitJsonPcbTraceLengthModel.mjs +257 -0
- package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +683 -0
- package/src/core/CircuitJsonSourceMetadata.mjs +233 -0
- package/src/core/CircuitJsonSupportMatrixBuilder.mjs +481 -0
- package/src/core/CircuitJsonUnits.mjs +133 -8
- package/src/core/PcbBoundsSelectionModel.mjs +250 -0
- package/src/core/PcbCandidateSelectionModel.mjs +77 -0
- package/src/core/PcbDiagnosticFocusModel.mjs +423 -0
- package/src/core/PcbInteractionPrimitiveModel.mjs +560 -0
- package/src/core/SelectedPartCircuitJsonExportAdapter.mjs +335 -0
- package/src/core/spice/SpiceCompatibilityPreprocessor.mjs +139 -0
- package/src/core/spice/SpiceDirectiveParser.mjs +231 -0
- package/src/core/spice/SpiceFallbackSimulationEngine.mjs +168 -0
- package/src/core/spice/SpiceSimulationDiagnostics.mjs +234 -0
- package/src/core/spice/SpiceSimulationGraphBuilder.mjs +421 -0
- package/src/core/spice/SpiceSimulationGraphSummary.mjs +90 -0
- package/src/core/spice/SpiceSimulationService.mjs +92 -0
- package/src/core/spice/SpiceTimeSeriesNormalizer.mjs +132 -0
- package/src/index.mjs +8 -0
- package/src/renderers.mjs +29 -0
- package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +128 -0
- package/src/ui/CircuitJsonPcbSvgRenderer.mjs +964 -0
- package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +168 -0
- package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +138 -0
- package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +114 -0
- package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +130 -0
- package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +994 -0
- package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +439 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { CircuitJsonIndexer } from './CircuitJsonIndexer.mjs'
|
|
2
|
+
import { CircuitJsonUnits } from './CircuitJsonUnits.mjs'
|
|
3
|
+
import { CircuitJsonPcbPrimitiveGeometry } from './CircuitJsonPcbPrimitiveGeometry.mjs'
|
|
4
|
+
|
|
5
|
+
const MIN_RING_AREA = 1e-8
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Builds copper area primitives from CircuitJSON PCB area records.
|
|
9
|
+
*/
|
|
10
|
+
export class CircuitJsonPcbZonePrimitiveBuilder {
|
|
11
|
+
/**
|
|
12
|
+
* Builds copper area primitives and geometry diagnostics.
|
|
13
|
+
* @param {{ elementsByType: Map<string, object[]> }} index Element index.
|
|
14
|
+
* @param {Map<string, object>} componentsByPcbId Component lookup.
|
|
15
|
+
* @returns {{ primitives: object[], diagnostics: object[] }}
|
|
16
|
+
*/
|
|
17
|
+
static build(index, componentsByPcbId) {
|
|
18
|
+
const rows = CircuitJsonPcbZonePrimitiveBuilder.#areaElements(
|
|
19
|
+
index
|
|
20
|
+
).map((element) =>
|
|
21
|
+
CircuitJsonPcbZonePrimitiveBuilder.#zonePrimitive(
|
|
22
|
+
element,
|
|
23
|
+
componentsByPcbId
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
primitives: rows.map((row) => row.primitive).filter(Boolean),
|
|
29
|
+
diagnostics: rows.flatMap((row) => row.diagnostics)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Builds one copper area primitive.
|
|
35
|
+
* @param {object} element Source element.
|
|
36
|
+
* @param {Map<string, object>} componentsByPcbId Component lookup.
|
|
37
|
+
* @returns {{ primitive: object | null, diagnostics: object[] }}
|
|
38
|
+
*/
|
|
39
|
+
static #zonePrimitive(element, componentsByPcbId) {
|
|
40
|
+
if (CircuitJsonPcbZonePrimitiveBuilder.#isBRepElement(element)) {
|
|
41
|
+
return CircuitJsonPcbZonePrimitiveBuilder.#brepPrimitive(
|
|
42
|
+
element,
|
|
43
|
+
componentsByPcbId
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return CircuitJsonPcbZonePrimitiveBuilder.#polygonPrimitive(
|
|
47
|
+
element,
|
|
48
|
+
componentsByPcbId
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Builds one B-Rep copper area primitive.
|
|
54
|
+
* @param {object} element Source element.
|
|
55
|
+
* @param {Map<string, object>} componentsByPcbId Component lookup.
|
|
56
|
+
* @returns {{ primitive: object | null, diagnostics: object[] }}
|
|
57
|
+
*/
|
|
58
|
+
static #brepPrimitive(element, componentsByPcbId) {
|
|
59
|
+
const geometry =
|
|
60
|
+
CircuitJsonPcbZonePrimitiveBuilder.#brepGeometry(element)
|
|
61
|
+
const component = CircuitJsonPcbZonePrimitiveBuilder.#component(
|
|
62
|
+
element,
|
|
63
|
+
componentsByPcbId
|
|
64
|
+
)
|
|
65
|
+
const bounds = CircuitJsonPcbZonePrimitiveBuilder.#mergedRingBounds(
|
|
66
|
+
geometry.rings
|
|
67
|
+
)
|
|
68
|
+
const firstOuter = geometry.rings.find((ring) => ring.role === 'outer')
|
|
69
|
+
const primitive = bounds
|
|
70
|
+
? CircuitJsonPcbZonePrimitiveBuilder.#primitive({
|
|
71
|
+
id: CircuitJsonIndexer.getElementId(element),
|
|
72
|
+
kind: 'zone',
|
|
73
|
+
shape: 'brep',
|
|
74
|
+
points: firstOuter?.points || [],
|
|
75
|
+
rings: geometry.rings,
|
|
76
|
+
bounds,
|
|
77
|
+
layer: CircuitJsonPcbZonePrimitiveBuilder.#layer(
|
|
78
|
+
element.layer
|
|
79
|
+
),
|
|
80
|
+
component,
|
|
81
|
+
netName: CircuitJsonPcbZonePrimitiveBuilder.#netName(
|
|
82
|
+
element,
|
|
83
|
+
null
|
|
84
|
+
),
|
|
85
|
+
sourceNetId:
|
|
86
|
+
CircuitJsonPcbZonePrimitiveBuilder.#sourceNetId(element),
|
|
87
|
+
coveredWithSolderMask:
|
|
88
|
+
CircuitJsonPcbZonePrimitiveBuilder.#coveredWithSolderMask(
|
|
89
|
+
element
|
|
90
|
+
),
|
|
91
|
+
anchors: geometry.rings.flatMap((ring) =>
|
|
92
|
+
ring.points.map((point) => ({ point }))
|
|
93
|
+
),
|
|
94
|
+
source: element
|
|
95
|
+
})
|
|
96
|
+
: null
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
primitive,
|
|
100
|
+
diagnostics: geometry.diagnostics.map((diagnostic) =>
|
|
101
|
+
CircuitJsonPcbZonePrimitiveBuilder.#diagnostic(
|
|
102
|
+
diagnostic,
|
|
103
|
+
element,
|
|
104
|
+
primitive
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Builds one polygonal copper area primitive.
|
|
112
|
+
* @param {object} element Source element.
|
|
113
|
+
* @param {Map<string, object>} componentsByPcbId Component lookup.
|
|
114
|
+
* @returns {{ primitive: object | null, diagnostics: object[] }}
|
|
115
|
+
*/
|
|
116
|
+
static #polygonPrimitive(element, componentsByPcbId) {
|
|
117
|
+
const points = CircuitJsonPcbZonePrimitiveBuilder.#points(element)
|
|
118
|
+
if (points.length < 3) return { primitive: null, diagnostics: [] }
|
|
119
|
+
const component = CircuitJsonPcbZonePrimitiveBuilder.#component(
|
|
120
|
+
element,
|
|
121
|
+
componentsByPcbId
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
primitive: CircuitJsonPcbZonePrimitiveBuilder.#primitive({
|
|
126
|
+
id: CircuitJsonIndexer.getElementId(element),
|
|
127
|
+
kind: 'zone',
|
|
128
|
+
shape: String(element.shape || 'polygon'),
|
|
129
|
+
points,
|
|
130
|
+
bounds: CircuitJsonPcbPrimitiveGeometry.pointsBounds(points),
|
|
131
|
+
layer: CircuitJsonPcbZonePrimitiveBuilder.#layer(element.layer),
|
|
132
|
+
component,
|
|
133
|
+
netName: CircuitJsonPcbZonePrimitiveBuilder.#netName(
|
|
134
|
+
element,
|
|
135
|
+
null
|
|
136
|
+
),
|
|
137
|
+
sourceNetId:
|
|
138
|
+
CircuitJsonPcbZonePrimitiveBuilder.#sourceNetId(element),
|
|
139
|
+
coveredWithSolderMask:
|
|
140
|
+
CircuitJsonPcbZonePrimitiveBuilder.#coveredWithSolderMask(
|
|
141
|
+
element
|
|
142
|
+
),
|
|
143
|
+
anchors: points.map((point) => ({ point })),
|
|
144
|
+
source: element
|
|
145
|
+
}),
|
|
146
|
+
diagnostics: []
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Normalizes B-Rep geometry into renderable rings.
|
|
152
|
+
* @param {object} element Source element.
|
|
153
|
+
* @returns {{ rings: object[], diagnostics: object[] }}
|
|
154
|
+
*/
|
|
155
|
+
static #brepGeometry(element) {
|
|
156
|
+
const rings = []
|
|
157
|
+
const diagnostics = []
|
|
158
|
+
const shapes = CircuitJsonPcbZonePrimitiveBuilder.#brepShapes(element)
|
|
159
|
+
|
|
160
|
+
shapes.forEach((shape, shapeIndex) => {
|
|
161
|
+
const outer = CircuitJsonPcbZonePrimitiveBuilder.#normalizeRing(
|
|
162
|
+
CircuitJsonPcbZonePrimitiveBuilder.#outerRing(shape)
|
|
163
|
+
)
|
|
164
|
+
if (!outer.points) {
|
|
165
|
+
diagnostics.push({
|
|
166
|
+
...outer,
|
|
167
|
+
code: 'pcb_zone_brep_island_dropped',
|
|
168
|
+
role: 'outer',
|
|
169
|
+
shapeIndex
|
|
170
|
+
})
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
rings.push({
|
|
175
|
+
role: 'outer',
|
|
176
|
+
shapeIndex,
|
|
177
|
+
points: outer.points,
|
|
178
|
+
bounds: outer.bounds
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
CircuitJsonPcbZonePrimitiveBuilder.#innerRings(shape).forEach(
|
|
182
|
+
(ring, ringIndex) => {
|
|
183
|
+
const hole =
|
|
184
|
+
CircuitJsonPcbZonePrimitiveBuilder.#normalizeRing(ring)
|
|
185
|
+
if (!hole.points) {
|
|
186
|
+
diagnostics.push({
|
|
187
|
+
...hole,
|
|
188
|
+
code: 'pcb_zone_brep_ring_dropped',
|
|
189
|
+
role: 'hole',
|
|
190
|
+
shapeIndex,
|
|
191
|
+
ringIndex
|
|
192
|
+
})
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
rings.push({
|
|
196
|
+
role: 'hole',
|
|
197
|
+
shapeIndex,
|
|
198
|
+
ringIndex,
|
|
199
|
+
points: hole.points,
|
|
200
|
+
bounds: hole.bounds
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
return { rings, diagnostics }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Normalizes one ring into valid points.
|
|
211
|
+
* @param {object | object[]} ring Ring candidate.
|
|
212
|
+
* @returns {{ points?: object[], bounds?: object, reason?: string }}
|
|
213
|
+
*/
|
|
214
|
+
static #normalizeRing(ring) {
|
|
215
|
+
const rawPoints = CircuitJsonPcbZonePrimitiveBuilder.#ringVertices(ring)
|
|
216
|
+
const points = rawPoints
|
|
217
|
+
.map((point) => CircuitJsonPcbZonePrimitiveBuilder.#point(point))
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
.map((point) => ({
|
|
220
|
+
x: CircuitJsonPcbZonePrimitiveBuilder.#round(point.x),
|
|
221
|
+
y: CircuitJsonPcbZonePrimitiveBuilder.#round(point.y)
|
|
222
|
+
}))
|
|
223
|
+
const normalized =
|
|
224
|
+
CircuitJsonPcbZonePrimitiveBuilder.#dropDuplicateClosure(
|
|
225
|
+
CircuitJsonPcbZonePrimitiveBuilder.#dropConsecutiveDuplicates(
|
|
226
|
+
points
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
const bounds =
|
|
230
|
+
normalized.length > 0
|
|
231
|
+
? CircuitJsonPcbPrimitiveGeometry.pointsBounds(normalized)
|
|
232
|
+
: null
|
|
233
|
+
|
|
234
|
+
if (
|
|
235
|
+
CircuitJsonPcbZonePrimitiveBuilder.#uniquePointCount(normalized) < 3
|
|
236
|
+
) {
|
|
237
|
+
return { bounds, reason: 'too-few-points' }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
Math.abs(
|
|
242
|
+
CircuitJsonPcbZonePrimitiveBuilder.#signedArea(normalized)
|
|
243
|
+
) <= MIN_RING_AREA
|
|
244
|
+
) {
|
|
245
|
+
return { bounds, reason: 'tiny-area' }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { points: normalized, bounds }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Builds one geometry diagnostic row.
|
|
253
|
+
* @param {object} diagnostic Normalization diagnostic.
|
|
254
|
+
* @param {object} element Source element.
|
|
255
|
+
* @param {object | null} primitive Built primitive.
|
|
256
|
+
* @returns {object}
|
|
257
|
+
*/
|
|
258
|
+
static #diagnostic(diagnostic, element, primitive) {
|
|
259
|
+
const bounds = diagnostic.bounds || primitive?.bounds || null
|
|
260
|
+
const point = bounds
|
|
261
|
+
? CircuitJsonPcbZonePrimitiveBuilder.#boundsCenter(bounds)
|
|
262
|
+
: { x: 0, y: 0 }
|
|
263
|
+
const sourceId =
|
|
264
|
+
CircuitJsonIndexer.getElementId(element) || 'copper-area'
|
|
265
|
+
const role = diagnostic.role === 'outer' ? 'island' : 'ring'
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
id:
|
|
269
|
+
sourceId +
|
|
270
|
+
':brep:' +
|
|
271
|
+
diagnostic.shapeIndex +
|
|
272
|
+
':' +
|
|
273
|
+
(diagnostic.ringIndex ?? 0) +
|
|
274
|
+
':' +
|
|
275
|
+
diagnostic.code,
|
|
276
|
+
kind: 'warning',
|
|
277
|
+
severity: 'warning',
|
|
278
|
+
category: 'geometry',
|
|
279
|
+
code: diagnostic.code,
|
|
280
|
+
message:
|
|
281
|
+
'Copper area ' +
|
|
282
|
+
role +
|
|
283
|
+
' was ignored because ' +
|
|
284
|
+
CircuitJsonPcbZonePrimitiveBuilder.#reasonMessage(
|
|
285
|
+
diagnostic.reason
|
|
286
|
+
) +
|
|
287
|
+
'.',
|
|
288
|
+
point,
|
|
289
|
+
bounds,
|
|
290
|
+
relatedPrimitiveIds: primitive?.id ? [primitive.id] : [],
|
|
291
|
+
netName: CircuitJsonPcbZonePrimitiveBuilder.#netName(element, null)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Returns all copper area elements.
|
|
297
|
+
* @param {{ elementsByType: Map<string, object[]> }} index Element index.
|
|
298
|
+
* @returns {object[]}
|
|
299
|
+
*/
|
|
300
|
+
static #areaElements(index) {
|
|
301
|
+
return [
|
|
302
|
+
'pcb_copper_pour',
|
|
303
|
+
'pcb_ground_plane',
|
|
304
|
+
'pcb_ground_plane_region'
|
|
305
|
+
].flatMap((type) => index.elementsByType.get(type) || [])
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Returns true when an element carries B-Rep geometry.
|
|
310
|
+
* @param {object} element Source element.
|
|
311
|
+
* @returns {boolean}
|
|
312
|
+
*/
|
|
313
|
+
static #isBRepElement(element) {
|
|
314
|
+
return (
|
|
315
|
+
String(element?.shape || '').toLowerCase() === 'brep' ||
|
|
316
|
+
Boolean(element?.brep_shape) ||
|
|
317
|
+
Boolean(element?.brepShape) ||
|
|
318
|
+
Array.isArray(element?.brep_shapes)
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Resolves B-Rep shape rows from common field aliases.
|
|
324
|
+
* @param {object} element Source element.
|
|
325
|
+
* @returns {object[]}
|
|
326
|
+
*/
|
|
327
|
+
static #brepShapes(element) {
|
|
328
|
+
return [
|
|
329
|
+
...(Array.isArray(element?.brep_shapes) ? element.brep_shapes : []),
|
|
330
|
+
...(Array.isArray(element?.brepShapes) ? element.brepShapes : []),
|
|
331
|
+
element?.brep_shape,
|
|
332
|
+
element?.brepShape
|
|
333
|
+
].filter(Boolean)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resolves the outer ring from a B-Rep shape.
|
|
338
|
+
* @param {object} shape B-Rep shape.
|
|
339
|
+
* @returns {object | object[] | null}
|
|
340
|
+
*/
|
|
341
|
+
static #outerRing(shape) {
|
|
342
|
+
return (
|
|
343
|
+
shape?.outerRing ||
|
|
344
|
+
shape?.outer_ring ||
|
|
345
|
+
shape?.outer ||
|
|
346
|
+
shape?.ring ||
|
|
347
|
+
null
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Resolves inner rings from a B-Rep shape.
|
|
353
|
+
* @param {object} shape B-Rep shape.
|
|
354
|
+
* @returns {object[]}
|
|
355
|
+
*/
|
|
356
|
+
static #innerRings(shape) {
|
|
357
|
+
const rings =
|
|
358
|
+
shape?.innerRings ||
|
|
359
|
+
shape?.inner_rings ||
|
|
360
|
+
shape?.holes ||
|
|
361
|
+
shape?.inner ||
|
|
362
|
+
[]
|
|
363
|
+
return Array.isArray(rings) ? rings : []
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Resolves vertices from a ring candidate.
|
|
368
|
+
* @param {object | object[] | null} ring Ring candidate.
|
|
369
|
+
* @returns {object[]}
|
|
370
|
+
*/
|
|
371
|
+
static #ringVertices(ring) {
|
|
372
|
+
if (Array.isArray(ring)) return ring
|
|
373
|
+
return (
|
|
374
|
+
ring?.cwVertices ||
|
|
375
|
+
ring?.ccwVertices ||
|
|
376
|
+
ring?.vertices ||
|
|
377
|
+
ring?.points ||
|
|
378
|
+
[]
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Resolves polygon points from common fields.
|
|
384
|
+
* @param {object} element Element row.
|
|
385
|
+
* @returns {{ x: number, y: number }[]}
|
|
386
|
+
*/
|
|
387
|
+
static #points(element) {
|
|
388
|
+
const points =
|
|
389
|
+
(Array.isArray(element?.points) && element.points) ||
|
|
390
|
+
(Array.isArray(element?.outline) && element.outline) ||
|
|
391
|
+
(Array.isArray(element?.vertices) && element.vertices) ||
|
|
392
|
+
(Array.isArray(element?.route) && element.route) ||
|
|
393
|
+
(Array.isArray(element?.path) && element.path) ||
|
|
394
|
+
(Array.isArray(element?.shape?.points) && element.shape.points) ||
|
|
395
|
+
[]
|
|
396
|
+
|
|
397
|
+
const normalized = points
|
|
398
|
+
.map((point) => CircuitJsonPcbZonePrimitiveBuilder.#point(point))
|
|
399
|
+
.filter(Boolean)
|
|
400
|
+
if (normalized.length) return normalized
|
|
401
|
+
return CircuitJsonPcbZonePrimitiveBuilder.#rectPoints(element)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Builds rectangle polygon points from center/size metadata.
|
|
406
|
+
* @param {object} element Element row.
|
|
407
|
+
* @returns {{ x: number, y: number }[]}
|
|
408
|
+
*/
|
|
409
|
+
static #rectPoints(element) {
|
|
410
|
+
if (String(element?.shape || '').toLowerCase() !== 'rect') return []
|
|
411
|
+
const center = CircuitJsonPcbZonePrimitiveBuilder.#point(
|
|
412
|
+
element?.center || element
|
|
413
|
+
)
|
|
414
|
+
const width = CircuitJsonUnits.optionalLength(element?.width)
|
|
415
|
+
const height = CircuitJsonUnits.optionalLength(element?.height)
|
|
416
|
+
if (!center || width === null || height === null) return []
|
|
417
|
+
const halfWidth = width / 2
|
|
418
|
+
const halfHeight = height / 2
|
|
419
|
+
return [
|
|
420
|
+
{ x: center.x - halfWidth, y: center.y - halfHeight },
|
|
421
|
+
{ x: center.x + halfWidth, y: center.y - halfHeight },
|
|
422
|
+
{ x: center.x + halfWidth, y: center.y + halfHeight },
|
|
423
|
+
{ x: center.x - halfWidth, y: center.y + halfHeight }
|
|
424
|
+
]
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Resolves a component from the source element.
|
|
429
|
+
* @param {object} element Source element.
|
|
430
|
+
* @param {Map<string, object>} componentsByPcbId Component lookup.
|
|
431
|
+
* @returns {object | undefined}
|
|
432
|
+
*/
|
|
433
|
+
static #component(element, componentsByPcbId) {
|
|
434
|
+
return componentsByPcbId.get(
|
|
435
|
+
String(element.pcb_component_id || '').trim()
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Adds common primitive metadata.
|
|
441
|
+
* @param {object} primitive Primitive data.
|
|
442
|
+
* @returns {object}
|
|
443
|
+
*/
|
|
444
|
+
static #primitive(primitive) {
|
|
445
|
+
const component = primitive.component || {}
|
|
446
|
+
const layer = CircuitJsonPcbZonePrimitiveBuilder.#layer(primitive.layer)
|
|
447
|
+
const source = primitive.source || {}
|
|
448
|
+
return {
|
|
449
|
+
...primitive,
|
|
450
|
+
layer,
|
|
451
|
+
side:
|
|
452
|
+
primitive.side ??
|
|
453
|
+
CircuitJsonPcbZonePrimitiveBuilder.#side(layer),
|
|
454
|
+
componentKey: String(component.componentKey || ''),
|
|
455
|
+
componentId: String(component.pcbComponentId || ''),
|
|
456
|
+
footprintId: component.componentKey
|
|
457
|
+
? 'footprint:' + component.componentKey + ':' + primitive.kind
|
|
458
|
+
: '',
|
|
459
|
+
netName: String(primitive.netName || '').trim(),
|
|
460
|
+
groupIds: CircuitJsonPcbZonePrimitiveBuilder.#uniqueStrings([
|
|
461
|
+
source.pcb_group_id,
|
|
462
|
+
source.source_group_id,
|
|
463
|
+
component.pcb_group_id,
|
|
464
|
+
component.positioned_relative_to_pcb_group_id,
|
|
465
|
+
component.sourceGroupId
|
|
466
|
+
]),
|
|
467
|
+
subcircuitIds: CircuitJsonPcbZonePrimitiveBuilder.#uniqueStrings([
|
|
468
|
+
source.subcircuit_id,
|
|
469
|
+
source.subcircuitId,
|
|
470
|
+
...(component.subcircuitIds || [])
|
|
471
|
+
])
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Merges ring bounds.
|
|
477
|
+
* @param {{ bounds?: object }[]} rings Ring rows.
|
|
478
|
+
* @returns {object | null}
|
|
479
|
+
*/
|
|
480
|
+
static #mergedRingBounds(rings) {
|
|
481
|
+
const boundsRows = rings.map((ring) => ring.bounds).filter(Boolean)
|
|
482
|
+
if (!boundsRows.length) return null
|
|
483
|
+
return boundsRows.reduce((bounds, row) =>
|
|
484
|
+
bounds
|
|
485
|
+
? CircuitJsonPcbPrimitiveGeometry.bounds(
|
|
486
|
+
Math.min(bounds.minX, row.minX),
|
|
487
|
+
Math.min(bounds.minY, row.minY),
|
|
488
|
+
Math.max(bounds.maxX, row.maxX),
|
|
489
|
+
Math.max(bounds.maxY, row.maxY)
|
|
490
|
+
)
|
|
491
|
+
: row
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Drops consecutive duplicate points.
|
|
497
|
+
* @param {{ x: number, y: number }[]} points Ring points.
|
|
498
|
+
* @returns {{ x: number, y: number }[]}
|
|
499
|
+
*/
|
|
500
|
+
static #dropConsecutiveDuplicates(points) {
|
|
501
|
+
return points.filter(
|
|
502
|
+
(point, index) =>
|
|
503
|
+
index === 0 ||
|
|
504
|
+
!CircuitJsonPcbZonePrimitiveBuilder.#samePoint(
|
|
505
|
+
point,
|
|
506
|
+
points[index - 1]
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Drops a duplicate closing point.
|
|
513
|
+
* @param {{ x: number, y: number }[]} points Ring points.
|
|
514
|
+
* @returns {{ x: number, y: number }[]}
|
|
515
|
+
*/
|
|
516
|
+
static #dropDuplicateClosure(points) {
|
|
517
|
+
if (
|
|
518
|
+
points.length > 1 &&
|
|
519
|
+
CircuitJsonPcbZonePrimitiveBuilder.#samePoint(
|
|
520
|
+
points[0],
|
|
521
|
+
points[points.length - 1]
|
|
522
|
+
)
|
|
523
|
+
) {
|
|
524
|
+
return points.slice(0, -1)
|
|
525
|
+
}
|
|
526
|
+
return points
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Counts unique point coordinates.
|
|
531
|
+
* @param {{ x: number, y: number }[]} points Ring points.
|
|
532
|
+
* @returns {number}
|
|
533
|
+
*/
|
|
534
|
+
static #uniquePointCount(points) {
|
|
535
|
+
return new Set(points.map((point) => point.x + ',' + point.y)).size
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Computes signed polygon area.
|
|
540
|
+
* @param {{ x: number, y: number }[]} points Ring points.
|
|
541
|
+
* @returns {number}
|
|
542
|
+
*/
|
|
543
|
+
static #signedArea(points) {
|
|
544
|
+
return (
|
|
545
|
+
points.reduce((sum, point, index) => {
|
|
546
|
+
const next = points[(index + 1) % points.length]
|
|
547
|
+
return sum + point.x * next.y - next.x * point.y
|
|
548
|
+
}, 0) / 2
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Resolves the center of bounds.
|
|
554
|
+
* @param {object} bounds Bounds record.
|
|
555
|
+
* @returns {{ x: number, y: number }}
|
|
556
|
+
*/
|
|
557
|
+
static #boundsCenter(bounds) {
|
|
558
|
+
return {
|
|
559
|
+
x: bounds.minX + bounds.width / 2,
|
|
560
|
+
y: bounds.minY + bounds.height / 2
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Returns true when two points have equal coordinates.
|
|
566
|
+
* @param {{ x: number, y: number }} left First point.
|
|
567
|
+
* @param {{ x: number, y: number }} right Second point.
|
|
568
|
+
* @returns {boolean}
|
|
569
|
+
*/
|
|
570
|
+
static #samePoint(left, right) {
|
|
571
|
+
return left.x === right.x && left.y === right.y
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Resolves a point candidate.
|
|
576
|
+
* @param {object | null | undefined} value Point candidate.
|
|
577
|
+
* @returns {{ x: number, y: number } | null}
|
|
578
|
+
*/
|
|
579
|
+
static #point(value) {
|
|
580
|
+
return CircuitJsonUnits.optionalPoint(value)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Resolves a normalized layer key.
|
|
585
|
+
* @param {unknown} value Layer candidate.
|
|
586
|
+
* @returns {string}
|
|
587
|
+
*/
|
|
588
|
+
static #layer(value) {
|
|
589
|
+
const raw =
|
|
590
|
+
typeof value === 'object' && value !== null ? value.name : value
|
|
591
|
+
const text = String(raw ?? '').trim()
|
|
592
|
+
const lowered = text.toLowerCase()
|
|
593
|
+
if (['top', 'front', 'f.cu', '1'].includes(lowered)) return 'top'
|
|
594
|
+
if (['bottom', 'back', 'b.cu', '32'].includes(lowered)) return 'bottom'
|
|
595
|
+
return text
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Resolves a side from a layer key.
|
|
600
|
+
* @param {string} layer Layer key.
|
|
601
|
+
* @returns {'top' | 'bottom' | ''}
|
|
602
|
+
*/
|
|
603
|
+
static #side(layer) {
|
|
604
|
+
const text = String(layer || '').toLowerCase()
|
|
605
|
+
if (/\b(bottom|back)\b|\bb[._-]/u.test(text)) return 'bottom'
|
|
606
|
+
if (/\b(top|front)\b|\bf[._-]/u.test(text)) return 'top'
|
|
607
|
+
return ''
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Resolves a net name from common fields.
|
|
612
|
+
* @param {object} element Element row.
|
|
613
|
+
* @param {string | null} fallback Fallback net name.
|
|
614
|
+
* @returns {string}
|
|
615
|
+
*/
|
|
616
|
+
static #netName(element, fallback) {
|
|
617
|
+
return String(
|
|
618
|
+
element?.netName ??
|
|
619
|
+
element?.net ??
|
|
620
|
+
element?.net_name ??
|
|
621
|
+
element?.source_net_name ??
|
|
622
|
+
element?.source_net_id ??
|
|
623
|
+
fallback ??
|
|
624
|
+
''
|
|
625
|
+
).trim()
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Resolves a source net id.
|
|
630
|
+
* @param {object} element Element row.
|
|
631
|
+
* @returns {string}
|
|
632
|
+
*/
|
|
633
|
+
static #sourceNetId(element) {
|
|
634
|
+
return String(
|
|
635
|
+
element?.source_net_id || element?.sourceNetId || ''
|
|
636
|
+
).trim()
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Resolves optional mask-coverage metadata.
|
|
641
|
+
* @param {object} element Element row.
|
|
642
|
+
* @returns {boolean | undefined}
|
|
643
|
+
*/
|
|
644
|
+
static #coveredWithSolderMask(element) {
|
|
645
|
+
if (Object.hasOwn(element, 'covered_with_solder_mask')) {
|
|
646
|
+
return Boolean(element.covered_with_solder_mask)
|
|
647
|
+
}
|
|
648
|
+
if (Object.hasOwn(element, 'coveredWithSolderMask')) {
|
|
649
|
+
return Boolean(element.coveredWithSolderMask)
|
|
650
|
+
}
|
|
651
|
+
return element?.type === 'pcb_copper_pour' ? true : undefined
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Explains one dropped-ring reason.
|
|
656
|
+
* @param {string} reason Reason code.
|
|
657
|
+
* @returns {string}
|
|
658
|
+
*/
|
|
659
|
+
static #reasonMessage(reason) {
|
|
660
|
+
if (reason === 'tiny-area') return 'its area is too small'
|
|
661
|
+
return 'it does not contain three usable points'
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Rounds computed geometry values to stable precision.
|
|
666
|
+
* @param {number} value Numeric value.
|
|
667
|
+
* @returns {number}
|
|
668
|
+
*/
|
|
669
|
+
static #round(value) {
|
|
670
|
+
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Resolves unique non-empty string values.
|
|
675
|
+
* @param {unknown[]} values Candidate values.
|
|
676
|
+
* @returns {string[]}
|
|
677
|
+
*/
|
|
678
|
+
static #uniqueStrings(values) {
|
|
679
|
+
return [...new Set(values.map((value) => String(value || '').trim()))]
|
|
680
|
+
.filter(Boolean)
|
|
681
|
+
.sort()
|
|
682
|
+
}
|
|
683
|
+
}
|