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,560 @@
|
|
|
1
|
+
import { CircuitJsonDocument } from './CircuitJsonDocument.mjs'
|
|
2
|
+
import { CircuitJsonPcbPrimitiveBuilder } from './CircuitJsonPcbPrimitiveBuilder.mjs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Builds renderer-neutral PCB primitives for element-array board documents.
|
|
6
|
+
*/
|
|
7
|
+
export class PcbInteractionPrimitiveModel {
|
|
8
|
+
/**
|
|
9
|
+
* Builds a normalized primitive model from a parsed PCB document.
|
|
10
|
+
* @param {object | object[]} documentModel Parsed document model.
|
|
11
|
+
* @returns {{ bounds: object, layers: object[], virtualLayers: object[], components: object[], nets: object[], primitives: object[], anchors: object[], diagnostics: object[], airwires: object[], traceLengths: object[], groups: object[], anchorOffsets: object[] }}
|
|
12
|
+
*/
|
|
13
|
+
static build(documentModel) {
|
|
14
|
+
if (
|
|
15
|
+
!PcbInteractionPrimitiveModel.#isElementArrayDocument(documentModel)
|
|
16
|
+
) {
|
|
17
|
+
return PcbInteractionPrimitiveModel.#emptyModel()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return CircuitJsonPcbPrimitiveBuilder.build(documentModel)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolves physical and virtual PCB interaction layers.
|
|
25
|
+
* @param {object | object[]} documentModel Parsed document model.
|
|
26
|
+
* @returns {{ physicalLayers: object[], virtualLayers: object[] }}
|
|
27
|
+
*/
|
|
28
|
+
static resolveLayerGroups(documentModel) {
|
|
29
|
+
if (
|
|
30
|
+
!PcbInteractionPrimitiveModel.#isElementArrayDocument(documentModel)
|
|
31
|
+
) {
|
|
32
|
+
return { physicalLayers: [], virtualLayers: [] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const model = CircuitJsonPcbPrimitiveBuilder.build(documentModel)
|
|
36
|
+
return {
|
|
37
|
+
physicalLayers: model.layers,
|
|
38
|
+
virtualLayers: model.virtualLayers || []
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Snaps a board point to the nearest primitive anchor within tolerance.
|
|
44
|
+
* @param {object | object[]} documentModel Parsed document model.
|
|
45
|
+
* @param {{ x?: unknown, y?: unknown }} point Board-space point.
|
|
46
|
+
* @param {{ tolerance?: number }} [options] Snap options.
|
|
47
|
+
* @returns {{ snapped: boolean, point: { x: number, y: number } }}
|
|
48
|
+
*/
|
|
49
|
+
static resolveSnapPoint(documentModel, point, options = {}) {
|
|
50
|
+
const normalizedPoint = PcbInteractionPrimitiveModel.#point(point)
|
|
51
|
+
if (!normalizedPoint) return { snapped: false, point: { x: 0, y: 0 } }
|
|
52
|
+
|
|
53
|
+
const tolerance = Math.max(
|
|
54
|
+
PcbInteractionPrimitiveModel.#number(options.tolerance, 0),
|
|
55
|
+
0
|
|
56
|
+
)
|
|
57
|
+
let bestPoint = null
|
|
58
|
+
let bestDistanceSq = Infinity
|
|
59
|
+
|
|
60
|
+
for (const anchor of PcbInteractionPrimitiveModel.build(documentModel)
|
|
61
|
+
.anchors) {
|
|
62
|
+
const distanceSq = PcbInteractionPrimitiveModel.#distanceSquared(
|
|
63
|
+
normalizedPoint,
|
|
64
|
+
anchor.point
|
|
65
|
+
)
|
|
66
|
+
if (distanceSq < bestDistanceSq) {
|
|
67
|
+
bestPoint = anchor.point
|
|
68
|
+
bestDistanceSq = distanceSq
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!bestPoint || bestDistanceSq > tolerance * tolerance) {
|
|
73
|
+
return { snapped: false, point: normalizedPoint }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { snapped: true, point: { x: bestPoint.x, y: bestPoint.y } }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns prioritized primitive hit candidates for one board point.
|
|
81
|
+
* @param {object | object[]} documentModel Parsed document model.
|
|
82
|
+
* @param {{ x?: unknown, y?: unknown }} point Board-space point.
|
|
83
|
+
* @param {{ side?: 'top' | 'bottom', hiddenLayers?: string[], hiddenObjects?: string[], tolerance?: number }} [options] Hit-test options.
|
|
84
|
+
* @returns {object[]}
|
|
85
|
+
*/
|
|
86
|
+
static hitTest(documentModel, point, options = {}) {
|
|
87
|
+
const normalizedPoint = PcbInteractionPrimitiveModel.#point(point)
|
|
88
|
+
if (!normalizedPoint) return []
|
|
89
|
+
|
|
90
|
+
const tolerance = PcbInteractionPrimitiveModel.#number(
|
|
91
|
+
options.tolerance,
|
|
92
|
+
0.2
|
|
93
|
+
)
|
|
94
|
+
const model = PcbInteractionPrimitiveModel.build(documentModel)
|
|
95
|
+
const groupsById = new Map(
|
|
96
|
+
(model.groups || []).map((group) => [String(group.id || ''), group])
|
|
97
|
+
)
|
|
98
|
+
const hits = []
|
|
99
|
+
|
|
100
|
+
for (const primitive of model.primitives) {
|
|
101
|
+
if (!PcbInteractionPrimitiveModel.#isVisible(primitive, options)) {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
const distance = PcbInteractionPrimitiveModel.#hitDistance(
|
|
105
|
+
primitive,
|
|
106
|
+
normalizedPoint,
|
|
107
|
+
tolerance
|
|
108
|
+
)
|
|
109
|
+
if (distance === null) continue
|
|
110
|
+
|
|
111
|
+
hits.push({
|
|
112
|
+
...PcbInteractionPrimitiveModel.#candidate(
|
|
113
|
+
primitive,
|
|
114
|
+
groupsById
|
|
115
|
+
),
|
|
116
|
+
distance
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return hits.sort(
|
|
121
|
+
(left, right) =>
|
|
122
|
+
PcbInteractionPrimitiveModel.#priority(left.kind) -
|
|
123
|
+
PcbInteractionPrimitiveModel.#priority(right.kind) ||
|
|
124
|
+
left.distance - right.distance
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Returns true when the document uses the element-array source format.
|
|
130
|
+
* @param {object | object[]} documentModel Parsed document model.
|
|
131
|
+
* @returns {boolean}
|
|
132
|
+
*/
|
|
133
|
+
static #isElementArrayDocument(documentModel) {
|
|
134
|
+
return CircuitJsonDocument.isModel(
|
|
135
|
+
CircuitJsonPcbPrimitiveBuilder.elements(documentModel)
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Builds an empty primitive model.
|
|
141
|
+
* @returns {{ bounds: object, layers: object[], virtualLayers: object[], components: object[], nets: object[], primitives: object[], anchors: object[], diagnostics: object[], airwires: object[], traceLengths: object[], groups: object[], anchorOffsets: object[] }}
|
|
142
|
+
*/
|
|
143
|
+
static #emptyModel() {
|
|
144
|
+
return {
|
|
145
|
+
bounds: PcbInteractionPrimitiveModel.#bounds(0, 0, 1, 1),
|
|
146
|
+
layers: [],
|
|
147
|
+
virtualLayers: [],
|
|
148
|
+
components: [],
|
|
149
|
+
nets: [],
|
|
150
|
+
primitives: [],
|
|
151
|
+
anchors: [],
|
|
152
|
+
diagnostics: [],
|
|
153
|
+
airwires: [],
|
|
154
|
+
traceLengths: [],
|
|
155
|
+
groups: [],
|
|
156
|
+
anchorOffsets: []
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Returns true when a primitive should participate in hit testing.
|
|
162
|
+
* @param {object} primitive Primitive row.
|
|
163
|
+
* @param {object} options Hit-test options.
|
|
164
|
+
* @returns {boolean}
|
|
165
|
+
*/
|
|
166
|
+
static #isVisible(primitive, options) {
|
|
167
|
+
const hiddenLayers = new Set(
|
|
168
|
+
(Array.isArray(options.hiddenLayers) ? options.hiddenLayers : [])
|
|
169
|
+
.map(String)
|
|
170
|
+
.filter(Boolean)
|
|
171
|
+
)
|
|
172
|
+
if (primitive.layer && hiddenLayers.has(String(primitive.layer))) {
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const hiddenObjects = new Set(
|
|
177
|
+
(Array.isArray(options.hiddenObjects) ? options.hiddenObjects : [])
|
|
178
|
+
.map(String)
|
|
179
|
+
.filter(Boolean)
|
|
180
|
+
)
|
|
181
|
+
if (
|
|
182
|
+
hiddenObjects.has(
|
|
183
|
+
PcbInteractionPrimitiveModel.#componentSideObjectKey(primitive)
|
|
184
|
+
)
|
|
185
|
+
) {
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
if (
|
|
189
|
+
hiddenObjects.has(
|
|
190
|
+
PcbInteractionPrimitiveModel.#objectKey(primitive)
|
|
191
|
+
)
|
|
192
|
+
) {
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const side = String(options.side || '')
|
|
197
|
+
return !side || !primitive.side || primitive.side === side
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resolves component-side object keys for component-backed primitives.
|
|
202
|
+
* @param {object} primitive Primitive row.
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
static #componentSideObjectKey(primitive) {
|
|
206
|
+
if (!primitive.componentKey && !primitive.componentId) return ''
|
|
207
|
+
if (primitive.side === 'top') return 'components-top'
|
|
208
|
+
if (primitive.side === 'bottom') return 'components-bottom'
|
|
209
|
+
return ''
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Resolves the sidebar object key for a primitive.
|
|
214
|
+
* @param {object} primitive Primitive row.
|
|
215
|
+
* @returns {string}
|
|
216
|
+
*/
|
|
217
|
+
static #objectKey(primitive) {
|
|
218
|
+
return (
|
|
219
|
+
{
|
|
220
|
+
board: 'page',
|
|
221
|
+
cutout: 'page',
|
|
222
|
+
pad: 'pads',
|
|
223
|
+
track: 'tracks',
|
|
224
|
+
via: 'vias',
|
|
225
|
+
zone: 'zones',
|
|
226
|
+
'copper-text': 'footprint-text',
|
|
227
|
+
copper_text: 'footprint-text',
|
|
228
|
+
silkscreen: 'silkscreen',
|
|
229
|
+
silkscreen_text: 'silkscreen',
|
|
230
|
+
silkscreen_line: 'silkscreen',
|
|
231
|
+
fabrication: 'fabrication',
|
|
232
|
+
note: 'fabrication',
|
|
233
|
+
dimension: 'fabrication',
|
|
234
|
+
courtyard: 'courtyards',
|
|
235
|
+
'solder-mask': 'solder-mask',
|
|
236
|
+
'solder-paste': 'solder-paste',
|
|
237
|
+
ratsnest: 'rats-nest'
|
|
238
|
+
}[primitive.kind] || primitive.kind
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Resolves one primitive hit distance.
|
|
244
|
+
* @param {object} primitive Primitive row.
|
|
245
|
+
* @param {{ x: number, y: number }} point Board point.
|
|
246
|
+
* @param {number} tolerance Hit tolerance.
|
|
247
|
+
* @returns {number | null}
|
|
248
|
+
*/
|
|
249
|
+
static #hitDistance(primitive, point, tolerance) {
|
|
250
|
+
if (primitive.kind === 'track') {
|
|
251
|
+
const distance = PcbInteractionPrimitiveModel.#distanceToSegment(
|
|
252
|
+
point,
|
|
253
|
+
{ x: primitive.x1, y: primitive.y1 },
|
|
254
|
+
{ x: primitive.x2, y: primitive.y2 }
|
|
255
|
+
)
|
|
256
|
+
return distance <= primitive.width / 2 + tolerance ? distance : null
|
|
257
|
+
}
|
|
258
|
+
if (PcbInteractionPrimitiveModel.#isLineLikePrimitive(primitive)) {
|
|
259
|
+
const distance = PcbInteractionPrimitiveModel.#distanceToSegment(
|
|
260
|
+
point,
|
|
261
|
+
{ x: primitive.x1, y: primitive.y1 },
|
|
262
|
+
{ x: primitive.x2, y: primitive.y2 }
|
|
263
|
+
)
|
|
264
|
+
const width = PcbInteractionPrimitiveModel.#number(
|
|
265
|
+
primitive.width,
|
|
266
|
+
0
|
|
267
|
+
)
|
|
268
|
+
return distance <= width / 2 + tolerance ? distance : null
|
|
269
|
+
}
|
|
270
|
+
if (primitive.kind === 'via') {
|
|
271
|
+
if (String(primitive.shape || 'circle') !== 'circle') {
|
|
272
|
+
return PcbInteractionPrimitiveModel.#inside(
|
|
273
|
+
point,
|
|
274
|
+
primitive.bounds,
|
|
275
|
+
tolerance
|
|
276
|
+
)
|
|
277
|
+
? 0
|
|
278
|
+
: null
|
|
279
|
+
}
|
|
280
|
+
const distance = Math.sqrt(
|
|
281
|
+
PcbInteractionPrimitiveModel.#distanceSquared(point, primitive)
|
|
282
|
+
)
|
|
283
|
+
return distance <= primitive.diameter / 2 + tolerance
|
|
284
|
+
? distance
|
|
285
|
+
: null
|
|
286
|
+
}
|
|
287
|
+
if (
|
|
288
|
+
['pad', 'zone', 'keepout', 'cutout', 'courtyard'].includes(
|
|
289
|
+
primitive.kind
|
|
290
|
+
)
|
|
291
|
+
) {
|
|
292
|
+
return PcbInteractionPrimitiveModel.#inside(
|
|
293
|
+
point,
|
|
294
|
+
primitive.bounds,
|
|
295
|
+
tolerance
|
|
296
|
+
)
|
|
297
|
+
? 0
|
|
298
|
+
: null
|
|
299
|
+
}
|
|
300
|
+
if (primitive.kind === 'board') {
|
|
301
|
+
return PcbInteractionPrimitiveModel.#inside(
|
|
302
|
+
point,
|
|
303
|
+
primitive.bounds,
|
|
304
|
+
0
|
|
305
|
+
)
|
|
306
|
+
? Number.MAX_SAFE_INTEGER
|
|
307
|
+
: null
|
|
308
|
+
}
|
|
309
|
+
return null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Returns true when a primitive uses explicit segment geometry.
|
|
314
|
+
* @param {object} primitive Primitive row.
|
|
315
|
+
* @returns {boolean}
|
|
316
|
+
*/
|
|
317
|
+
static #isLineLikePrimitive(primitive) {
|
|
318
|
+
if (
|
|
319
|
+
![
|
|
320
|
+
'silkscreen',
|
|
321
|
+
'fabrication',
|
|
322
|
+
'note',
|
|
323
|
+
'dimension',
|
|
324
|
+
'thermal-spoke',
|
|
325
|
+
'route-hint',
|
|
326
|
+
'courtyard'
|
|
327
|
+
].includes(primitive.kind)
|
|
328
|
+
) {
|
|
329
|
+
return false
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return [primitive.x1, primitive.y1, primitive.x2, primitive.y2].every(
|
|
333
|
+
(value) => PcbInteractionPrimitiveModel.#finite(value) !== null
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Builds a hit-test candidate from a primitive.
|
|
339
|
+
* @param {object} primitive Primitive row.
|
|
340
|
+
* @param {Map<string, object>} groupsById Group lookup.
|
|
341
|
+
* @returns {object}
|
|
342
|
+
*/
|
|
343
|
+
static #candidate(primitive, groupsById) {
|
|
344
|
+
return {
|
|
345
|
+
id: String(primitive.id || ''),
|
|
346
|
+
role: primitive.kind,
|
|
347
|
+
kind: primitive.kind,
|
|
348
|
+
componentKey: String(primitive.componentKey || ''),
|
|
349
|
+
componentId: String(
|
|
350
|
+
primitive.componentId || primitive.componentKey || ''
|
|
351
|
+
),
|
|
352
|
+
netName: String(primitive.netName || ''),
|
|
353
|
+
net: String(primitive.netName || ''),
|
|
354
|
+
layer: String(primitive.layer || ''),
|
|
355
|
+
layerKey: String(primitive.layer || ''),
|
|
356
|
+
groupIds: Array.isArray(primitive.groupIds)
|
|
357
|
+
? primitive.groupIds
|
|
358
|
+
: [],
|
|
359
|
+
groups: PcbInteractionPrimitiveModel.#candidateGroups(
|
|
360
|
+
primitive,
|
|
361
|
+
groupsById
|
|
362
|
+
),
|
|
363
|
+
source: primitive.source || primitive
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Builds group summaries for one candidate.
|
|
369
|
+
* @param {object} primitive Primitive row.
|
|
370
|
+
* @param {Map<string, object>} groupsById Group lookup.
|
|
371
|
+
* @returns {object[]}
|
|
372
|
+
*/
|
|
373
|
+
static #candidateGroups(primitive, groupsById) {
|
|
374
|
+
return (Array.isArray(primitive.groupIds) ? primitive.groupIds : [])
|
|
375
|
+
.map((id) => groupsById.get(String(id || '')))
|
|
376
|
+
.filter(Boolean)
|
|
377
|
+
.map((group) => PcbInteractionPrimitiveModel.#groupSummary(group))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Builds a compact group summary for hit-test output.
|
|
382
|
+
* @param {object} group Group row.
|
|
383
|
+
* @returns {object}
|
|
384
|
+
*/
|
|
385
|
+
static #groupSummary(group) {
|
|
386
|
+
return {
|
|
387
|
+
id: String(group.id || ''),
|
|
388
|
+
name: String(group.name || group.id || ''),
|
|
389
|
+
sourceGroupId: String(group.sourceGroupId || ''),
|
|
390
|
+
componentCount: Array.isArray(group.componentIds)
|
|
391
|
+
? group.componentIds.length
|
|
392
|
+
: 0,
|
|
393
|
+
memberCount: Array.isArray(group.memberIds)
|
|
394
|
+
? group.memberIds.length
|
|
395
|
+
: 0,
|
|
396
|
+
anchor: group.anchor || null,
|
|
397
|
+
...PcbInteractionPrimitiveModel.#optionalCandidateField(
|
|
398
|
+
'anchorAlignment',
|
|
399
|
+
group.anchorAlignment
|
|
400
|
+
),
|
|
401
|
+
...PcbInteractionPrimitiveModel.#optionalCandidateField(
|
|
402
|
+
'positionMode',
|
|
403
|
+
group.positionMode
|
|
404
|
+
),
|
|
405
|
+
...PcbInteractionPrimitiveModel.#optionalCandidateField(
|
|
406
|
+
'childLayoutMode',
|
|
407
|
+
group.childLayoutMode
|
|
408
|
+
),
|
|
409
|
+
...PcbInteractionPrimitiveModel.#optionalCandidateField(
|
|
410
|
+
'layoutMode',
|
|
411
|
+
group.layoutMode
|
|
412
|
+
),
|
|
413
|
+
...PcbInteractionPrimitiveModel.#optionalCandidateNumber(
|
|
414
|
+
'autorouterTraceClearance',
|
|
415
|
+
group.autorouterTraceClearance
|
|
416
|
+
),
|
|
417
|
+
bounds: group.bounds || null
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Builds an optional string field for a candidate.
|
|
423
|
+
* @param {string} key Output key.
|
|
424
|
+
* @param {unknown} value Candidate value.
|
|
425
|
+
* @returns {object}
|
|
426
|
+
*/
|
|
427
|
+
static #optionalCandidateField(key, value) {
|
|
428
|
+
const text = String(value || '').trim()
|
|
429
|
+
return text ? { [key]: text } : {}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Builds an optional number field for a candidate.
|
|
434
|
+
* @param {string} key Output key.
|
|
435
|
+
* @param {unknown} value Candidate value.
|
|
436
|
+
* @returns {object}
|
|
437
|
+
*/
|
|
438
|
+
static #optionalCandidateNumber(key, value) {
|
|
439
|
+
const number = Number(value)
|
|
440
|
+
return Number.isFinite(number) ? { [key]: number } : {}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Returns sorting priority for one hit kind.
|
|
445
|
+
* @param {string} kind Primitive kind.
|
|
446
|
+
* @returns {number}
|
|
447
|
+
*/
|
|
448
|
+
static #priority(kind) {
|
|
449
|
+
return { pad: 10, via: 20, track: 30, zone: 40, board: 100 }[kind] || 90
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Returns true when a point is inside bounds.
|
|
454
|
+
* @param {{ x: number, y: number }} point Point.
|
|
455
|
+
* @param {object} bounds Bounds.
|
|
456
|
+
* @param {number} padding Padding.
|
|
457
|
+
* @returns {boolean}
|
|
458
|
+
*/
|
|
459
|
+
static #inside(point, bounds, padding) {
|
|
460
|
+
return (
|
|
461
|
+
point.x >= bounds.minX - padding &&
|
|
462
|
+
point.x <= bounds.maxX + padding &&
|
|
463
|
+
point.y >= bounds.minY - padding &&
|
|
464
|
+
point.y <= bounds.maxY + padding
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Resolves a point from common x/y fields.
|
|
470
|
+
* @param {object | null | undefined} value Point candidate.
|
|
471
|
+
* @returns {{ x: number, y: number } | null}
|
|
472
|
+
*/
|
|
473
|
+
static #point(value) {
|
|
474
|
+
const x = PcbInteractionPrimitiveModel.#finite(value?.x)
|
|
475
|
+
const y = PcbInteractionPrimitiveModel.#finite(value?.y)
|
|
476
|
+
if (x === null || y === null) return null
|
|
477
|
+
return { x, y }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Builds a normalized bounds record.
|
|
482
|
+
* @param {number} minX Minimum x.
|
|
483
|
+
* @param {number} minY Minimum y.
|
|
484
|
+
* @param {number} maxX Maximum x.
|
|
485
|
+
* @param {number} maxY Maximum y.
|
|
486
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number, width: number, height: number }}
|
|
487
|
+
*/
|
|
488
|
+
static #bounds(minX, minY, maxX, maxY) {
|
|
489
|
+
return {
|
|
490
|
+
minX,
|
|
491
|
+
minY,
|
|
492
|
+
maxX,
|
|
493
|
+
maxY,
|
|
494
|
+
width: maxX - minX,
|
|
495
|
+
height: maxY - minY
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Resolves squared point distance.
|
|
501
|
+
* @param {{ x: number, y: number }} left First point.
|
|
502
|
+
* @param {{ x: number, y: number }} right Second point.
|
|
503
|
+
* @returns {number}
|
|
504
|
+
*/
|
|
505
|
+
static #distanceSquared(left, right) {
|
|
506
|
+
return (left.x - right.x) ** 2 + (left.y - right.y) ** 2
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Resolves point-to-segment distance.
|
|
511
|
+
* @param {{ x: number, y: number }} point Point.
|
|
512
|
+
* @param {{ x: number, y: number }} start Segment start.
|
|
513
|
+
* @param {{ x: number, y: number }} end Segment end.
|
|
514
|
+
* @returns {number}
|
|
515
|
+
*/
|
|
516
|
+
static #distanceToSegment(point, start, end) {
|
|
517
|
+
const dx = end.x - start.x
|
|
518
|
+
const dy = end.y - start.y
|
|
519
|
+
const lengthSq = dx * dx + dy * dy
|
|
520
|
+
if (!lengthSq) {
|
|
521
|
+
return Math.sqrt(
|
|
522
|
+
PcbInteractionPrimitiveModel.#distanceSquared(point, start)
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
const t = Math.max(
|
|
526
|
+
0,
|
|
527
|
+
Math.min(
|
|
528
|
+
1,
|
|
529
|
+
((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSq
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
return Math.sqrt(
|
|
533
|
+
PcbInteractionPrimitiveModel.#distanceSquared(point, {
|
|
534
|
+
x: start.x + t * dx,
|
|
535
|
+
y: start.y + t * dy
|
|
536
|
+
})
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Converts a value to a finite number or null.
|
|
542
|
+
* @param {unknown} value Numeric candidate.
|
|
543
|
+
* @returns {number | null}
|
|
544
|
+
*/
|
|
545
|
+
static #finite(value) {
|
|
546
|
+
if (value === undefined || value === null || value === '') return null
|
|
547
|
+
const number = Number(value)
|
|
548
|
+
return Number.isFinite(number) ? number : null
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Converts a value to a finite number with fallback.
|
|
553
|
+
* @param {unknown} value Numeric candidate.
|
|
554
|
+
* @param {number} fallback Fallback number.
|
|
555
|
+
* @returns {number}
|
|
556
|
+
*/
|
|
557
|
+
static #number(value, fallback) {
|
|
558
|
+
return PcbInteractionPrimitiveModel.#finite(value) ?? fallback
|
|
559
|
+
}
|
|
560
|
+
}
|