circuitjson-toolkit 1.0.3 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +5 -3
  2. package/README.md +21 -2
  3. package/docs/api.md +50 -4
  4. package/docs/model-format.md +21 -3
  5. package/package.json +3 -2
  6. package/spec/library-scope.md +4 -1
  7. package/src/core/CircuitJsonBomBuilder.mjs +143 -0
  8. package/src/core/CircuitJsonDocument.mjs +46 -13
  9. package/src/core/CircuitJsonElementValidator.mjs +990 -0
  10. package/src/core/CircuitJsonIndexer.mjs +773 -4
  11. package/src/core/CircuitJsonManufacturingBuilder.mjs +898 -0
  12. package/src/core/CircuitJsonManufacturingDownloadBuilder.mjs +196 -0
  13. package/src/core/CircuitJsonParser.mjs +22 -6
  14. package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +329 -0
  15. package/src/core/CircuitJsonPcbCopperGeometry.mjs +503 -0
  16. package/src/core/CircuitJsonPcbDrawingStyle.mjs +88 -0
  17. package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +172 -0
  18. package/src/core/CircuitJsonPcbNetMetadata.mjs +247 -0
  19. package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +70 -0
  20. package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +992 -0
  21. package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +872 -0
  22. package/src/core/CircuitJsonPcbPrimitiveFields.mjs +233 -0
  23. package/src/core/CircuitJsonPcbPrimitiveGeometry.mjs +142 -0
  24. package/src/core/CircuitJsonPcbPrimitiveGroups.mjs +305 -0
  25. package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +65 -0
  26. package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +895 -0
  27. package/src/core/CircuitJsonPcbTraceLengthModel.mjs +257 -0
  28. package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +683 -0
  29. package/src/core/CircuitJsonSourceMetadata.mjs +233 -0
  30. package/src/core/CircuitJsonSupportMatrixBuilder.mjs +481 -0
  31. package/src/core/CircuitJsonUnits.mjs +133 -8
  32. package/src/core/PcbBoundsSelectionModel.mjs +250 -0
  33. package/src/core/PcbCandidateSelectionModel.mjs +77 -0
  34. package/src/core/PcbDiagnosticFocusModel.mjs +423 -0
  35. package/src/core/PcbInteractionPrimitiveModel.mjs +560 -0
  36. package/src/core/SelectedPartCircuitJsonExportAdapter.mjs +335 -0
  37. package/src/core/spice/SpiceCompatibilityPreprocessor.mjs +139 -0
  38. package/src/core/spice/SpiceDirectiveParser.mjs +231 -0
  39. package/src/core/spice/SpiceFallbackSimulationEngine.mjs +168 -0
  40. package/src/core/spice/SpiceSimulationDiagnostics.mjs +234 -0
  41. package/src/core/spice/SpiceSimulationGraphBuilder.mjs +421 -0
  42. package/src/core/spice/SpiceSimulationGraphSummary.mjs +90 -0
  43. package/src/core/spice/SpiceSimulationService.mjs +92 -0
  44. package/src/core/spice/SpiceTimeSeriesNormalizer.mjs +132 -0
  45. package/src/index.mjs +8 -0
  46. package/src/renderers.mjs +29 -0
  47. package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +128 -0
  48. package/src/ui/CircuitJsonPcbSvgRenderer.mjs +964 -0
  49. package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +168 -0
  50. package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +138 -0
  51. package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +114 -0
  52. package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +130 -0
  53. package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +994 -0
  54. package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +439 -0
@@ -0,0 +1,233 @@
1
+ import { CircuitJsonIndexer } from './CircuitJsonIndexer.mjs'
2
+ import { CircuitJsonUnits } from './CircuitJsonUnits.mjs'
3
+ import { CircuitJsonPcbPrimitiveGeometry } from './CircuitJsonPcbPrimitiveGeometry.mjs'
4
+
5
+ /**
6
+ * Resolves shared CircuitJSON PCB primitive fields from common element shapes.
7
+ */
8
+ export class CircuitJsonPcbPrimitiveFields {
9
+ /**
10
+ * Resolves board bounds from outline or size metadata.
11
+ * @param {object | null} board Board element.
12
+ * @returns {object | null}
13
+ */
14
+ static boardBounds(board) {
15
+ const outline = CircuitJsonPcbPrimitiveFields.points(board)
16
+ if (outline.length >= 3) {
17
+ return CircuitJsonPcbPrimitiveGeometry.pointsBounds(outline)
18
+ }
19
+
20
+ const width = CircuitJsonUnits.optionalLength(board?.width)
21
+ const height = CircuitJsonUnits.optionalLength(board?.height)
22
+ if (width === null || height === null || width <= 0 || height <= 0) {
23
+ return null
24
+ }
25
+
26
+ return CircuitJsonPcbPrimitiveGeometry.centerBounds(
27
+ CircuitJsonPcbPrimitiveFields.center(board) || { x: 0, y: 0 },
28
+ width,
29
+ height
30
+ )
31
+ }
32
+
33
+ /**
34
+ * Resolves merged bounds for all board rows.
35
+ * @param {object[]} boards Board rows.
36
+ * @returns {object | null}
37
+ */
38
+ static mergedBoardBounds(boards) {
39
+ return CircuitJsonPcbPrimitiveGeometry.mergedPrimitiveBounds(
40
+ boards
41
+ .map((board) => ({
42
+ bounds: CircuitJsonPcbPrimitiveFields.boardBounds(board)
43
+ }))
44
+ .filter((row) => row.bounds)
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Builds copper layer rows from board metadata.
50
+ * @param {object | null} board Board element.
51
+ * @returns {object[]}
52
+ */
53
+ static layers(board) {
54
+ const count = Math.max(
55
+ 1,
56
+ Math.round(CircuitJsonUnits.length(board?.num_layers, 2))
57
+ )
58
+ const keys =
59
+ count === 1
60
+ ? ['top']
61
+ : [
62
+ 'top',
63
+ ...Array.from(
64
+ { length: Math.max(count - 2, 0) },
65
+ (_entry, index) => 'inner' + (index + 1)
66
+ ),
67
+ 'bottom'
68
+ ]
69
+
70
+ return keys.map((key, index) => ({
71
+ key,
72
+ id: key,
73
+ layer: key,
74
+ name: key,
75
+ number: index + 1,
76
+ side: key === 'top' ? 'top' : key === 'bottom' ? 'bottom' : 'inner',
77
+ type: 'copper',
78
+ sourceFormat: 'circuitjson'
79
+ }))
80
+ }
81
+
82
+ /**
83
+ * Resolves a center point.
84
+ * @param {object} element Element row.
85
+ * @returns {{ x: number, y: number } | null}
86
+ */
87
+ static center(element) {
88
+ return CircuitJsonPcbPrimitiveFields.point(element?.center || element)
89
+ }
90
+
91
+ /**
92
+ * Resolves one point.
93
+ * @param {object | null | undefined} value Point candidate.
94
+ * @returns {{ x: number, y: number } | null}
95
+ */
96
+ static point(value) {
97
+ return CircuitJsonUnits.optionalPoint(value)
98
+ }
99
+
100
+ /**
101
+ * Resolves polygon points from common fields.
102
+ * @param {object} element Element row.
103
+ * @returns {{ x: number, y: number }[]}
104
+ */
105
+ static points(element) {
106
+ return (
107
+ (Array.isArray(element?.points) && element.points) ||
108
+ (Array.isArray(element?.outline) && element.outline) ||
109
+ (Array.isArray(element?.vertices) && element.vertices) ||
110
+ (Array.isArray(element?.route) && element.route) ||
111
+ (Array.isArray(element?.path) && element.path) ||
112
+ (Array.isArray(element?.shape?.points) && element.shape.points) ||
113
+ []
114
+ )
115
+ .map((point) => CircuitJsonPcbPrimitiveFields.point(point))
116
+ .filter(Boolean)
117
+ }
118
+
119
+ /**
120
+ * Resolves open line/path points from common detail geometry fields.
121
+ * @param {object} element Element row.
122
+ * @returns {{ x: number, y: number }[]}
123
+ */
124
+ static linePoints(element) {
125
+ const points = CircuitJsonPcbPrimitiveFields.points(element)
126
+ if (points.length > 1) return points
127
+
128
+ return [
129
+ CircuitJsonPcbPrimitiveFields.point({
130
+ x: element.x1 ?? element.start?.x,
131
+ y: element.y1 ?? element.start?.y
132
+ }),
133
+ CircuitJsonPcbPrimitiveFields.point({
134
+ x: element.x2 ?? element.end?.x,
135
+ y: element.y2 ?? element.end?.y
136
+ })
137
+ ].filter(Boolean)
138
+ }
139
+
140
+ /**
141
+ * Resolves the center of a point list.
142
+ * @param {{ x: number, y: number }[]} points Points.
143
+ * @returns {{ x: number, y: number } | null}
144
+ */
145
+ static pointsCenter(points) {
146
+ const bounds = CircuitJsonPcbPrimitiveGeometry.pointsBounds(points)
147
+ return bounds
148
+ ? {
149
+ x: bounds.minX + bounds.width / 2,
150
+ y: bounds.minY + bounds.height / 2
151
+ }
152
+ : null
153
+ }
154
+
155
+ /**
156
+ * Resolves a normalized layer key.
157
+ * @param {unknown} value Layer candidate.
158
+ * @returns {string}
159
+ */
160
+ static layer(value) {
161
+ const raw =
162
+ typeof value === 'object' && value !== null ? value.name : value
163
+ const text = String(raw ?? '').trim()
164
+ const lowered = text.toLowerCase()
165
+ if (['top', 'front', 'f.cu', '1'].includes(lowered)) return 'top'
166
+ if (['bottom', 'back', 'b.cu', '32'].includes(lowered)) return 'bottom'
167
+ return text
168
+ }
169
+
170
+ /**
171
+ * Resolves the side from a layer key.
172
+ * @param {string} layer Layer key.
173
+ * @returns {'top' | 'bottom' | ''}
174
+ */
175
+ static side(layer) {
176
+ const text = String(layer || '').toLowerCase()
177
+ if (/\b(bottom|back)\b|\bb[._-]/u.test(text)) return 'bottom'
178
+ if (/\b(top|front)\b|\bf[._-]/u.test(text)) return 'top'
179
+ return ''
180
+ }
181
+
182
+ /**
183
+ * Resolves a net name from common fields.
184
+ * @param {object} element Element row.
185
+ * @param {string | null} fallback Fallback net name.
186
+ * @returns {string}
187
+ */
188
+ static netName(element, fallback = '') {
189
+ return String(
190
+ element?.netName ??
191
+ element?.net ??
192
+ element?.net_name ??
193
+ element?.source_net_id ??
194
+ fallback ??
195
+ ''
196
+ ).trim()
197
+ }
198
+
199
+ /**
200
+ * Resolves first valid length from candidates.
201
+ * @param {unknown[]} values Candidate values.
202
+ * @returns {number | null}
203
+ */
204
+ static optionalLength(values) {
205
+ for (const value of values) {
206
+ const length = CircuitJsonUnits.optionalLength(value)
207
+ if (length !== null) return length
208
+ }
209
+ return null
210
+ }
211
+
212
+ /**
213
+ * Resolves an id for original or derived shape primitives.
214
+ * @param {object} element Element row.
215
+ * @returns {string}
216
+ */
217
+ static derivedId(element) {
218
+ const id = CircuitJsonIndexer.getElementId(element)
219
+ const suffix = String(element.derived_id_suffix || '').trim()
220
+ return id && suffix ? id + ':' + suffix : id
221
+ }
222
+
223
+ /**
224
+ * Resolves unique non-empty string values.
225
+ * @param {unknown[]} values Candidate values.
226
+ * @returns {string[]}
227
+ */
228
+ static uniqueStrings(values) {
229
+ return [...new Set(values.map((value) => String(value || '').trim()))]
230
+ .filter(Boolean)
231
+ .sort()
232
+ }
233
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Provides shared geometry helpers for CircuitJSON PCB primitive builders.
3
+ */
4
+ export class CircuitJsonPcbPrimitiveGeometry {
5
+ /**
6
+ * Builds a normalized bounds record.
7
+ * @param {number} minX Minimum x.
8
+ * @param {number} minY Minimum y.
9
+ * @param {number} maxX Maximum x.
10
+ * @param {number} maxY Maximum y.
11
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number, width: number, height: number }}
12
+ */
13
+ static bounds(minX, minY, maxX, maxY) {
14
+ return {
15
+ minX,
16
+ minY,
17
+ maxX,
18
+ maxY,
19
+ width: maxX - minX,
20
+ height: maxY - minY
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Builds bounds from a center and dimensions.
26
+ * @param {{ x: number, y: number }} center Center point.
27
+ * @param {number} width Width.
28
+ * @param {number} height Height.
29
+ * @returns {object}
30
+ */
31
+ static centerBounds(center, width, height) {
32
+ return CircuitJsonPcbPrimitiveGeometry.bounds(
33
+ center.x - width / 2,
34
+ center.y - height / 2,
35
+ center.x + width / 2,
36
+ center.y + height / 2
37
+ )
38
+ }
39
+
40
+ /**
41
+ * Builds segment bounds.
42
+ * @param {{ x: number, y: number }} start Start point.
43
+ * @param {{ x: number, y: number }} end End point.
44
+ * @param {number} width Segment width.
45
+ * @returns {object}
46
+ */
47
+ static segmentBounds(start, end, width) {
48
+ const margin = Math.max(width / 2, 0)
49
+ return CircuitJsonPcbPrimitiveGeometry.bounds(
50
+ Math.min(start.x, end.x) - margin,
51
+ Math.min(start.y, end.y) - margin,
52
+ Math.max(start.x, end.x) + margin,
53
+ Math.max(start.y, end.y) + margin
54
+ )
55
+ }
56
+
57
+ /**
58
+ * Builds bounds for a point list.
59
+ * @param {{ x: number, y: number }[]} points Points.
60
+ * @returns {object | null}
61
+ */
62
+ static pointsBounds(points) {
63
+ return points.reduce(
64
+ (bounds, point) =>
65
+ bounds
66
+ ? CircuitJsonPcbPrimitiveGeometry.bounds(
67
+ Math.min(bounds.minX, point.x),
68
+ Math.min(bounds.minY, point.y),
69
+ Math.max(bounds.maxX, point.x),
70
+ Math.max(bounds.maxY, point.y)
71
+ )
72
+ : CircuitJsonPcbPrimitiveGeometry.bounds(
73
+ point.x,
74
+ point.y,
75
+ point.x,
76
+ point.y
77
+ ),
78
+ null
79
+ )
80
+ }
81
+
82
+ /**
83
+ * Builds merged primitive bounds.
84
+ * @param {object[]} primitives Primitive rows.
85
+ * @returns {object | null}
86
+ */
87
+ static mergedPrimitiveBounds(primitives) {
88
+ return primitives.reduce((bounds, primitive) => {
89
+ if (!primitive.bounds) return bounds
90
+ if (!bounds) return primitive.bounds
91
+ return CircuitJsonPcbPrimitiveGeometry.bounds(
92
+ Math.min(bounds.minX, primitive.bounds.minX),
93
+ Math.min(bounds.minY, primitive.bounds.minY),
94
+ Math.max(bounds.maxX, primitive.bounds.maxX),
95
+ Math.max(bounds.maxY, primitive.bounds.maxY)
96
+ )
97
+ }, null)
98
+ }
99
+
100
+ /**
101
+ * Builds corner anchors for bounds.
102
+ * @param {object} bounds Bounds record.
103
+ * @returns {object[]}
104
+ */
105
+ static cornerAnchors(bounds) {
106
+ return CircuitJsonPcbPrimitiveGeometry.boundsAnchors(bounds)
107
+ }
108
+
109
+ /**
110
+ * Builds corner, edge-center, and center anchors for bounds.
111
+ * @param {object} bounds Bounds record.
112
+ * @returns {object[]}
113
+ */
114
+ static boundsAnchors(bounds) {
115
+ const centerX = CircuitJsonPcbPrimitiveGeometry.#round(
116
+ bounds.minX + bounds.width / 2
117
+ )
118
+ const centerY = CircuitJsonPcbPrimitiveGeometry.#round(
119
+ bounds.minY + bounds.height / 2
120
+ )
121
+ return [
122
+ { point: { x: bounds.minX, y: bounds.minY } },
123
+ { point: { x: centerX, y: bounds.minY } },
124
+ { point: { x: bounds.maxX, y: bounds.minY } },
125
+ { point: { x: bounds.maxX, y: centerY } },
126
+ { point: { x: bounds.maxX, y: bounds.maxY } },
127
+ { point: { x: centerX, y: bounds.maxY } },
128
+ { point: { x: bounds.minX, y: bounds.maxY } },
129
+ { point: { x: bounds.minX, y: centerY } },
130
+ { point: { x: centerX, y: centerY } }
131
+ ]
132
+ }
133
+
134
+ /**
135
+ * Rounds computed geometry values to stable precision.
136
+ * @param {number} value Numeric value.
137
+ * @returns {number}
138
+ */
139
+ static #round(value) {
140
+ return Math.round(Number(value || 0) * 1_000_000) / 1_000_000
141
+ }
142
+ }
@@ -0,0 +1,305 @@
1
+ import { CircuitJsonUnits } from './CircuitJsonUnits.mjs'
2
+ import { CircuitJsonPcbPrimitiveGeometry } from './CircuitJsonPcbPrimitiveGeometry.mjs'
3
+
4
+ /**
5
+ * Builds PCB group rows and group-anchor overlays from element arrays.
6
+ */
7
+ export class CircuitJsonPcbPrimitiveGroups {
8
+ /**
9
+ * Builds group metadata and decorated primitive rows.
10
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
11
+ * @param {object[]} primitives Primitive rows.
12
+ * @param {object[]} components Component rows.
13
+ * @returns {{ primitives: object[], groups: object[], anchorOffsets: object[] }}
14
+ */
15
+ static build(index, primitives, components) {
16
+ const groups = CircuitJsonPcbPrimitiveGroups.#groups(
17
+ index,
18
+ primitives,
19
+ components
20
+ )
21
+ const componentGroups =
22
+ CircuitJsonPcbPrimitiveGroups.#componentGroupIds(components)
23
+ return {
24
+ primitives: primitives.map((primitive) =>
25
+ CircuitJsonPcbPrimitiveGroups.#withGroupIds(
26
+ primitive,
27
+ index,
28
+ componentGroups
29
+ )
30
+ ),
31
+ groups,
32
+ anchorOffsets: CircuitJsonPcbPrimitiveGroups.#anchorOffsets(
33
+ groups,
34
+ components
35
+ )
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Builds PCB group rows.
41
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
42
+ * @param {object[]} primitives Primitive rows.
43
+ * @param {object[]} components Component rows.
44
+ * @returns {object[]}
45
+ */
46
+ static #groups(index, primitives, components) {
47
+ return CircuitJsonPcbPrimitiveGroups.#all(index, 'pcb_group')
48
+ .map((group) =>
49
+ CircuitJsonPcbPrimitiveGroups.#groupRow(
50
+ group,
51
+ primitives,
52
+ components
53
+ )
54
+ )
55
+ .filter(Boolean)
56
+ }
57
+
58
+ /**
59
+ * Builds one group row.
60
+ * @param {object} group Source group element.
61
+ * @param {object[]} primitives Primitive rows.
62
+ * @param {object[]} components Component rows.
63
+ * @returns {object | null}
64
+ */
65
+ static #groupRow(group, primitives, components) {
66
+ const id = String(group.pcb_group_id || '').trim()
67
+ if (!id) return null
68
+ const componentIds = components
69
+ .filter(
70
+ (component) =>
71
+ String(component.pcb_group_id || '').trim() === id
72
+ )
73
+ .map((component) => component.pcbComponentId)
74
+ .filter(Boolean)
75
+ return {
76
+ id,
77
+ type: 'pcb_group',
78
+ name: String(group.name || id),
79
+ bounds: CircuitJsonPcbPrimitiveGroups.#groupBounds(
80
+ group,
81
+ primitives
82
+ ),
83
+ sourceGroupId: String(group.source_group_id || '').trim(),
84
+ subcircuitId: String(group.subcircuit_id || '').trim(),
85
+ componentIds,
86
+ memberIds: CircuitJsonPcbPrimitiveGroups.#memberIds(
87
+ id,
88
+ primitives,
89
+ componentIds
90
+ ),
91
+ anchor: CircuitJsonUnits.optionalPoint(group.anchor_position),
92
+ depth: Number.isFinite(Number(group.depth))
93
+ ? Number(group.depth)
94
+ : 0,
95
+ ...CircuitJsonPcbPrimitiveGroups.#optionalGroupMetadata(group)
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Builds optional group metadata for inspection.
101
+ * @param {object} group Source group element.
102
+ * @returns {object}
103
+ */
104
+ static #optionalGroupMetadata(group) {
105
+ return {
106
+ ...CircuitJsonPcbPrimitiveGroups.#optionalStringField(
107
+ 'anchorAlignment',
108
+ group.anchor_alignment
109
+ ),
110
+ ...CircuitJsonPcbPrimitiveGroups.#optionalStringField(
111
+ 'positionMode',
112
+ group.position_mode
113
+ ),
114
+ ...CircuitJsonPcbPrimitiveGroups.#optionalStringField(
115
+ 'childLayoutMode',
116
+ group.child_layout_mode
117
+ ),
118
+ ...CircuitJsonPcbPrimitiveGroups.#optionalStringField(
119
+ 'layoutMode',
120
+ group.layout_mode
121
+ ),
122
+ ...CircuitJsonPcbPrimitiveGroups.#optionalLengthField(
123
+ 'autorouterTraceClearance',
124
+ group.autorouter_configuration?.trace_clearance
125
+ )
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Builds an optional string field.
131
+ * @param {string} key Output key.
132
+ * @param {unknown} value Candidate value.
133
+ * @returns {object}
134
+ */
135
+ static #optionalStringField(key, value) {
136
+ const text = String(value || '').trim()
137
+ return text ? { [key]: text } : {}
138
+ }
139
+
140
+ /**
141
+ * Builds an optional length field.
142
+ * @param {string} key Output key.
143
+ * @param {unknown} value Candidate value.
144
+ * @returns {object}
145
+ */
146
+ static #optionalLengthField(key, value) {
147
+ const number = CircuitJsonUnits.optionalLength(value)
148
+ return number === null ? {} : { [key]: number }
149
+ }
150
+
151
+ /**
152
+ * Resolves group bounds from explicit size or grouped primitives.
153
+ * @param {object} group Group element.
154
+ * @param {object[]} primitives Primitive rows.
155
+ * @returns {object | null}
156
+ */
157
+ static #groupBounds(group, primitives) {
158
+ const center = CircuitJsonUnits.optionalPoint(group.center || group)
159
+ const size = CircuitJsonUnits.optionalSize(group.size || group)
160
+ if (center && size) {
161
+ return CircuitJsonPcbPrimitiveGeometry.centerBounds(
162
+ center,
163
+ size.width,
164
+ size.height
165
+ )
166
+ }
167
+
168
+ return CircuitJsonPcbPrimitiveGeometry.mergedPrimitiveBounds(
169
+ primitives.filter(
170
+ (primitive) =>
171
+ String(primitive.source?.pcb_group_id || '').trim() ===
172
+ String(group.pcb_group_id || '').trim()
173
+ )
174
+ )
175
+ }
176
+
177
+ /**
178
+ * Builds member ids from components and primitives.
179
+ * @param {string} groupId Group id.
180
+ * @param {object[]} primitives Primitive rows.
181
+ * @param {string[]} componentIds Component ids.
182
+ * @returns {string[]}
183
+ */
184
+ static #memberIds(groupId, primitives, componentIds) {
185
+ const ids = [...componentIds]
186
+ for (const primitive of primitives) {
187
+ if (
188
+ String(primitive.source?.pcb_group_id || '').trim() !== groupId
189
+ ) {
190
+ continue
191
+ }
192
+ if (primitive.id && !ids.includes(primitive.id)) {
193
+ ids.push(primitive.id)
194
+ }
195
+ }
196
+ return ids
197
+ }
198
+
199
+ /**
200
+ * Builds group ids for each component id.
201
+ * @param {object[]} components Component rows.
202
+ * @returns {Map<string, string[]>}
203
+ */
204
+ static #componentGroupIds(components) {
205
+ return new Map(
206
+ components.map((component) => [
207
+ String(component.pcbComponentId || ''),
208
+ CircuitJsonPcbPrimitiveGroups.#unique(component.groupIds || [])
209
+ ])
210
+ )
211
+ }
212
+
213
+ /**
214
+ * Decorates one primitive with group and subcircuit ids.
215
+ * @param {object} primitive Primitive row.
216
+ * @param {{ sourceComponentById?: Map<string, object> }} index Element index.
217
+ * @param {Map<string, string[]>} componentGroups Component group lookup.
218
+ * @returns {object}
219
+ */
220
+ static #withGroupIds(primitive, index, componentGroups) {
221
+ const sourceComponent = index.sourceComponentById?.get(
222
+ String(primitive.component?.source_component_id || '').trim()
223
+ )
224
+ const groupIds = CircuitJsonPcbPrimitiveGroups.#unique([
225
+ ...(primitive.groupIds || []),
226
+ primitive.source?.pcb_group_id,
227
+ primitive.source?.source_group_id,
228
+ primitive.component?.pcb_group_id,
229
+ primitive.component?.positioned_relative_to_pcb_group_id,
230
+ primitive.component?.source_group_id,
231
+ primitive.component?.sourceGroupId,
232
+ sourceComponent?.source_group_id,
233
+ ...(componentGroups.get(String(primitive.componentId || '')) || [])
234
+ ])
235
+ const subcircuitIds = CircuitJsonPcbPrimitiveGroups.#unique([
236
+ ...(primitive.subcircuitIds || []),
237
+ primitive.source?.subcircuit_id,
238
+ primitive.source?.subcircuitId,
239
+ primitive.component?.subcircuit_id,
240
+ primitive.component?.subcircuitId,
241
+ ...(primitive.component?.subcircuitIds || []),
242
+ sourceComponent?.subcircuit_id
243
+ ])
244
+ return {
245
+ ...primitive,
246
+ groupIds,
247
+ subcircuitIds
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Builds visual anchor offsets for grouped component placements.
253
+ * @param {object[]} groups Group rows.
254
+ * @param {object[]} components Component rows.
255
+ * @returns {object[]}
256
+ */
257
+ static #anchorOffsets(groups, components) {
258
+ return groups.flatMap((group) => {
259
+ if (!group.anchor) return []
260
+ return group.componentIds.flatMap((componentId) => {
261
+ const component = components.find(
262
+ (row) => row.pcbComponentId === componentId
263
+ )
264
+ if (!component) return []
265
+ return [
266
+ {
267
+ id: 'anchor-offset:' + group.id + ':' + componentId,
268
+ kind: 'group-anchor-offset',
269
+ sourceId: group.id,
270
+ targetId: componentId,
271
+ targetType: 'component',
272
+ start: group.anchor,
273
+ end: { x: component.x, y: component.y },
274
+ label: component.componentKey
275
+ }
276
+ ]
277
+ })
278
+ })
279
+ }
280
+
281
+ /**
282
+ * Returns indexed elements by type.
283
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
284
+ * @param {string} type Element type.
285
+ * @returns {object[]}
286
+ */
287
+ static #all(index, type) {
288
+ return index.elementsByType.get(type) || []
289
+ }
290
+
291
+ /**
292
+ * Resolves unique non-empty strings.
293
+ * @param {unknown[]} values Candidate values.
294
+ * @returns {string[]}
295
+ */
296
+ static #unique(values) {
297
+ return [...new Set(values.map((value) => String(value || '').trim()))]
298
+ .filter(Boolean)
299
+ .sort((left, right) => {
300
+ const leftPcb = left.startsWith('pcb_') ? 0 : 1
301
+ const rightPcb = right.startsWith('pcb_') ? 0 : 1
302
+ return leftPcb - rightPcb || left.localeCompare(right)
303
+ })
304
+ }
305
+ }