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.
Files changed (54) hide show
  1. package/README.md +24 -6
  2. package/docs/api.md +42 -4
  3. package/docs/model-format.md +95 -5
  4. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
  5. package/docs/testing.md +7 -2
  6. package/package.json +6 -2
  7. package/spec/library-scope.md +7 -1
  8. package/src/core/altium/AltiumParser.mjs +22 -325
  9. package/src/core/altium/NormalizedModelSchema.mjs +28 -0
  10. package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
  11. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
  12. package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
  13. package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
  14. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
  15. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
  16. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
  17. package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
  18. package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
  19. package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
  20. package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
  21. package/src/core/altium/PcbLibModelParser.mjs +202 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
  23. package/src/core/altium/PcbModelParser.mjs +618 -66
  24. package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
  25. package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
  26. package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
  27. package/src/core/altium/PcbPadStackParser.mjs +903 -0
  28. package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
  29. package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
  30. package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
  31. package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
  32. package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
  33. package/src/core/altium/PcbRuleParser.mjs +587 -0
  34. package/src/core/altium/PcbStreamExtractor.mjs +127 -4
  35. package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
  36. package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
  37. package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
  38. package/src/core/altium/PcbViaStackParser.mjs +548 -0
  39. package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
  40. package/src/core/altium/PrjPcbModelParser.mjs +797 -0
  41. package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
  42. package/src/parser.mjs +13 -0
  43. package/src/renderers.mjs +5 -0
  44. package/src/styles/altium-renderers.css +11 -6
  45. package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
  46. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
  47. package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
  48. package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
  49. package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
  50. package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
  51. package/src/ui/PcbSvgRenderer.mjs +101 -109
  52. package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
  53. package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
  54. package/src/ui/SchematicSheetZoneRenderer.mjs +104 -0
@@ -0,0 +1,336 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Prepares normalized Altium PCB models for side-specific top-oriented renderers.
7
+ */
8
+ export class PcbSideResolvedRenderModel {
9
+ /**
10
+ * Resolves a normalized PCB model for the requested board side.
11
+ * @param {object | null} board
12
+ * @param {'front' | 'back' | { side?: 'front' | 'back' }} [options]
13
+ * @returns {object | null}
14
+ */
15
+ static resolve(board, options = {}) {
16
+ if (!board?.pcb) return board || null
17
+
18
+ const side = PcbSideResolvedRenderModel.#normalizeSide(options)
19
+ const pcb = board.pcb
20
+ return {
21
+ ...board,
22
+ pcb: {
23
+ ...pcb,
24
+ components: (pcb.components || []).filter((component) =>
25
+ PcbSideResolvedRenderModel.#isComponentVisibleOnSide(
26
+ component,
27
+ side
28
+ )
29
+ ),
30
+ primitiveLayers:
31
+ PcbSideResolvedRenderModel.#preparePrimitiveLayersForSide(
32
+ pcb.primitiveLayers || [],
33
+ side
34
+ ),
35
+ polygons: PcbSideResolvedRenderModel.#preparePolygonsForSide(
36
+ pcb.polygons || [],
37
+ side
38
+ ),
39
+ fills: PcbSideResolvedRenderModel.#preparePrimitivesForSide(
40
+ pcb.fills || [],
41
+ side
42
+ ),
43
+ tracks: PcbSideResolvedRenderModel.#preparePrimitivesForSide(
44
+ pcb.tracks || [],
45
+ side
46
+ ),
47
+ arcs: PcbSideResolvedRenderModel.#preparePrimitivesForSide(
48
+ pcb.arcs || [],
49
+ side
50
+ ),
51
+ regions: PcbSideResolvedRenderModel.#preparePrimitivesForSide(
52
+ pcb.regions || [],
53
+ side
54
+ ),
55
+ shapeBasedRegions:
56
+ PcbSideResolvedRenderModel.#preparePrimitivesForSide(
57
+ pcb.shapeBasedRegions || [],
58
+ side
59
+ ),
60
+ boardRegions:
61
+ PcbSideResolvedRenderModel.#preparePrimitivesForSide(
62
+ pcb.boardRegions || [],
63
+ side
64
+ ),
65
+ vias: PcbSideResolvedRenderModel.#prepareViasForSide(
66
+ pcb.vias || [],
67
+ side
68
+ ),
69
+ pads: PcbSideResolvedRenderModel.#preparePadsForSide(
70
+ pcb.pads || [],
71
+ side
72
+ )
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Checks whether a primitive belongs to an Altium copper signal layer.
79
+ * @param {object | null} primitive
80
+ * @returns {boolean}
81
+ */
82
+ static isCopperPrimitive(primitive) {
83
+ const layerId = Number(primitive?.layerId)
84
+ return Number.isInteger(layerId) && layerId >= 1 && layerId <= 32
85
+ }
86
+
87
+ /**
88
+ * Normalizes the caller side option.
89
+ * @param {'front' | 'back' | { side?: 'front' | 'back' }} options
90
+ * @returns {'front' | 'back'}
91
+ */
92
+ static #normalizeSide(options) {
93
+ if (options === 'back') return 'back'
94
+ if (options && typeof options === 'object' && options.side === 'back') {
95
+ return 'back'
96
+ }
97
+ return 'front'
98
+ }
99
+
100
+ /**
101
+ * Checks whether a component should be visible for a requested side.
102
+ * @param {object | null} component
103
+ * @param {'front' | 'back'} side
104
+ * @returns {boolean}
105
+ */
106
+ static #isComponentVisibleOnSide(component, side) {
107
+ const layer = String(component?.layer || '')
108
+ .trim()
109
+ .toLowerCase()
110
+ const isBottom = layer.includes('bottom') || layer === 'bot'
111
+ return side === 'back' ? isBottom : !isBottom
112
+ }
113
+
114
+ /**
115
+ * Projects primitive layer names for a top-oriented renderer.
116
+ * @param {readonly object[]} primitiveLayers
117
+ * @param {'front' | 'back'} side
118
+ * @returns {object[]}
119
+ */
120
+ static #preparePrimitiveLayersForSide(primitiveLayers, side) {
121
+ if (side !== 'back') return Array.from(primitiveLayers)
122
+
123
+ return primitiveLayers.map((layer) => ({
124
+ ...layer,
125
+ name: PcbSideResolvedRenderModel.#backLayerNameForTopRenderer(
126
+ layer.name
127
+ )
128
+ }))
129
+ }
130
+
131
+ /**
132
+ * Converts top/bottom layer labels for back-side top-oriented rendering.
133
+ * @param {unknown} name
134
+ * @returns {string}
135
+ */
136
+ static #backLayerNameForTopRenderer(name) {
137
+ const value = String(name || '')
138
+ if (/\bbottom\b/iu.test(value)) {
139
+ return value.replace(/\bbottom\b/giu, 'Top')
140
+ }
141
+ if (/\btop\b/iu.test(value)) {
142
+ return value.replace(/\btop\b/giu, 'Hidden')
143
+ }
144
+ return value
145
+ }
146
+
147
+ /**
148
+ * Projects polygon side labels for a top-oriented renderer.
149
+ * @param {readonly object[]} polygons
150
+ * @param {'front' | 'back'} side
151
+ * @returns {object[]}
152
+ */
153
+ static #preparePolygonsForSide(polygons, side) {
154
+ if (side !== 'back') return Array.from(polygons)
155
+
156
+ return polygons.map((polygon) => {
157
+ const layer = String(polygon?.layer || '')
158
+ .trim()
159
+ .toUpperCase()
160
+ if (layer === 'BOTTOM' || layer === 'BOT') {
161
+ return { ...polygon, layer: 'TOP' }
162
+ }
163
+ if (layer === 'TOP') {
164
+ return { ...polygon, layer: 'HIDDEN' }
165
+ }
166
+ return { ...polygon }
167
+ })
168
+ }
169
+
170
+ /**
171
+ * Projects primitive layer codes for a top-oriented renderer.
172
+ * @param {readonly object[]} primitives
173
+ * @param {'front' | 'back'} side
174
+ * @returns {object[]}
175
+ */
176
+ static #preparePrimitivesForSide(primitives, side) {
177
+ if (side !== 'back') return Array.from(primitives)
178
+
179
+ return primitives.map((primitive) => {
180
+ if (!PcbSideResolvedRenderModel.isCopperPrimitive(primitive)) {
181
+ return { ...primitive }
182
+ }
183
+
184
+ const layerCode = Number(primitive.layerCode)
185
+ const fallbackCode = Number(primitive.layerId)
186
+ return {
187
+ ...primitive,
188
+ layerCode: -(Number.isFinite(layerCode)
189
+ ? layerCode
190
+ : fallbackCode || 0)
191
+ }
192
+ })
193
+ }
194
+
195
+ /**
196
+ * Filters vias to those visible from the requested side.
197
+ * @param {readonly object[]} vias
198
+ * @param {'front' | 'back'} side
199
+ * @returns {object[]}
200
+ */
201
+ static #prepareViasForSide(vias, side) {
202
+ return vias
203
+ .filter((via) =>
204
+ PcbSideResolvedRenderModel.#isViaVisibleOnSide(via, side)
205
+ )
206
+ .map((via) => ({ ...via }))
207
+ }
208
+
209
+ /**
210
+ * Checks whether a via spans the requested surface layer.
211
+ * @param {object | null} via
212
+ * @param {'front' | 'back'} side
213
+ * @returns {boolean}
214
+ */
215
+ static #isViaVisibleOnSide(via, side) {
216
+ const start = Number(via?.layerStartId)
217
+ const end = Number(via?.layerEndId)
218
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return true
219
+
220
+ const surfaceLayerId = side === 'back' ? 32 : 1
221
+ return (
222
+ surfaceLayerId >= Math.min(start, end) &&
223
+ surfaceLayerId <= Math.max(start, end)
224
+ )
225
+ }
226
+
227
+ /**
228
+ * Filters and projects pads to the requested side.
229
+ * @param {readonly object[]} pads
230
+ * @param {'front' | 'back'} side
231
+ * @returns {object[]}
232
+ */
233
+ static #preparePadsForSide(pads, side) {
234
+ const visiblePads = pads.filter((pad) =>
235
+ PcbSideResolvedRenderModel.#isPadVisibleOnSide(pad, side)
236
+ )
237
+ if (side !== 'back') return visiblePads.map((pad) => ({ ...pad }))
238
+
239
+ return visiblePads.map((pad) => {
240
+ const layerId = PcbSideResolvedRenderModel.#effectivePadLayerId(pad)
241
+ return {
242
+ ...pad,
243
+ sizeTopX: PcbSideResolvedRenderModel.#firstFiniteValue(
244
+ pad.sizeBottomX,
245
+ pad.sizeMidX,
246
+ pad.sizeTopX
247
+ ),
248
+ sizeTopY: PcbSideResolvedRenderModel.#firstFiniteValue(
249
+ pad.sizeBottomY,
250
+ pad.sizeMidY,
251
+ pad.sizeTopY
252
+ ),
253
+ shapeTop: PcbSideResolvedRenderModel.#firstFiniteValue(
254
+ pad.shapeBottom,
255
+ pad.shapeMid,
256
+ pad.shapeTop
257
+ ),
258
+ roundedRectShapeTop:
259
+ PcbSideResolvedRenderModel.#firstFiniteValue(
260
+ pad.roundedRectShapeBottom,
261
+ pad.roundedRectShapeMid,
262
+ pad.roundedRectShapeTop
263
+ ),
264
+ cornerRadiusTop: PcbSideResolvedRenderModel.#firstFiniteValue(
265
+ pad.cornerRadiusBottom,
266
+ pad.cornerRadiusMid,
267
+ pad.cornerRadiusTop
268
+ ),
269
+ layerCode: layerId === 32 ? 1 : pad.layerCode,
270
+ layerId: layerId === 32 ? 1 : pad.layerId
271
+ }
272
+ })
273
+ }
274
+
275
+ /**
276
+ * Checks whether a pad should be visible on the requested side.
277
+ * @param {object | null} pad
278
+ * @param {'front' | 'back'} side
279
+ * @returns {boolean}
280
+ */
281
+ static #isPadVisibleOnSide(pad, side) {
282
+ const layerId = PcbSideResolvedRenderModel.#effectivePadLayerId(pad)
283
+ if (layerId === 1) return side === 'front'
284
+ if (layerId === 32) return side === 'back'
285
+ return true
286
+ }
287
+
288
+ /**
289
+ * Resolves the authored Altium layer id for a pad.
290
+ * @param {object | null} pad
291
+ * @returns {number | null}
292
+ */
293
+ static #effectivePadLayerId(pad) {
294
+ const layerId = Number(pad?.layerId)
295
+ if (Number.isInteger(layerId) && layerId > 0) return layerId
296
+
297
+ const legacyLayerId = Number(pad?.legacyLayerId)
298
+ if (Number.isInteger(legacyLayerId) && legacyLayerId > 0) {
299
+ return legacyLayerId
300
+ }
301
+
302
+ return null
303
+ }
304
+
305
+ /**
306
+ * Returns the first finite numeric value.
307
+ * @param {...unknown} values
308
+ * @returns {number | undefined}
309
+ */
310
+ static #firstFiniteValue(...values) {
311
+ for (const value of values) {
312
+ const number = Number(value)
313
+ if (Number.isFinite(number)) return number
314
+ }
315
+ return undefined
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Resolves a normalized PCB model for the requested board side.
321
+ * @param {object | null} board
322
+ * @param {'front' | 'back' | { side?: 'front' | 'back' }} [options]
323
+ * @returns {object | null}
324
+ */
325
+ export function preparePcbSideResolvedRenderModel(board, options = {}) {
326
+ return PcbSideResolvedRenderModel.resolve(board, options)
327
+ }
328
+
329
+ /**
330
+ * Checks whether a primitive belongs to an Altium copper signal layer.
331
+ * @param {object | null} primitive
332
+ * @returns {boolean}
333
+ */
334
+ export function isCopperPrimitive(primitive) {
335
+ return PcbSideResolvedRenderModel.isCopperPrimitive(primitive)
336
+ }
@@ -4,7 +4,11 @@
4
4
 
5
5
  import { PcbArcUtils } from './PcbArcUtils.mjs'
6
6
  import { PcbEdgeFacingGlyphNormalizer } from './PcbEdgeFacingGlyphNormalizer.mjs'
7
+ import { PcbEmbeddedFontFaceRenderer } from './PcbEmbeddedFontFaceRenderer.mjs'
7
8
  import { PcbFootprintPrimitiveSelector } from './PcbFootprintPrimitiveSelector.mjs'
9
+ import { PcbCopperPrimitiveSplitter } from './PcbCopperPrimitiveSplitter.mjs'
10
+ import { PcbRegionPrimitiveRenderer } from './PcbRegionPrimitiveRenderer.mjs'
11
+ import { PcbTextPrimitiveRenderer } from './PcbTextPrimitiveRenderer.mjs'
8
12
  import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
9
13
  /**
10
14
  * Renders normalized PCB models into HTML and SVG markup.
@@ -15,7 +19,7 @@ export class PcbSvgRenderer {
15
19
  static #GENERIC_DETAIL_SEARCH_HALF_EXTENT = 240
16
20
  /**
17
21
  * Renders a normalized PCB model into HTML and SVG markup.
18
- * @param {{ summary: { title?: string }, pcb?: { boardOutline: { segments: Array<Record<string, number | string>>, minX: number, minY: number, widthMil: number, heightMil: number }, layers: { name: string }[], primitiveLayers?: { layerId: number, name: string }[], polygons?: { layer?: string, segments: Array<Record<string, number | string>> }[], fills?: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks?: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs?: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], vias?: { x: number, y: number, diameter: number, holeDiameter: number }[], pads?: { x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number, shapeTop?: number, shapeMid?: number, shapeBottom?: number, rotation?: number, isPlated?: boolean }[], components: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] } }} documentModel
22
+ * @param {{ summary: { title?: string }, pcb?: { boardOutline: { segments: Array<Record<string, number | string>>, minX: number, minY: number, widthMil: number, heightMil: number }, layers: { name: string }[], primitiveLayers?: { layerId: number, name: string }[], polygons?: { layer?: string, segments: Array<Record<string, number | string>> }[], fills?: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks?: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs?: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], vias?: { x: number, y: number, diameter: number, holeDiameter: number }[], pads?: { x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number, shapeTop?: number, shapeMid?: number, shapeBottom?: number, rotation?: number, isPlated?: boolean }[], texts?: { text: string, x: number, y: number, height?: number, rotation?: number, layerId?: number, visible?: boolean }[], components: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] } }} documentModel
19
23
  * @returns {string}
20
24
  */
21
25
  static render(documentModel) {
@@ -28,14 +32,25 @@ export class PcbSvgRenderer {
28
32
  const fills = pcb.fills || []
29
33
  const tracks = pcb.tracks || []
30
34
  const arcs = pcb.arcs || []
35
+ const regions = pcb.regions || []
36
+ const shapeBasedRegions = pcb.shapeBasedRegions || []
37
+ const renderedRegions = shapeBasedRegions.length
38
+ ? shapeBasedRegions
39
+ : regions
31
40
  const vias = pcb.vias || []
32
41
  const pads = pcb.pads || []
33
42
  const components = pcb.components.slice(0, 260)
34
- const copperGroups = PcbSvgRenderer.#splitCopperPrimitives(
43
+ const texts = PcbTextPrimitiveRenderer.select(
44
+ pcb.primitiveLayers || [],
45
+ pcb.texts || [],
46
+ 'top'
47
+ )
48
+ const copperGroups = PcbCopperPrimitiveSplitter.split(
35
49
  polygons,
36
50
  fills,
37
51
  tracks,
38
- arcs
52
+ arcs,
53
+ renderedRegions
39
54
  )
40
55
  const footprintPrimitives = PcbEdgeFacingGlyphNormalizer.normalize(
41
56
  PcbFootprintPrimitiveSelector.select(
@@ -43,6 +58,7 @@ export class PcbSvgRenderer {
43
58
  fills,
44
59
  tracks,
45
60
  arcs,
61
+ renderedRegions,
46
62
  'top'
47
63
  ),
48
64
  outline
@@ -71,6 +87,11 @@ export class PcbSvgRenderer {
71
87
  ...copperGroups.subsurface.arcs,
72
88
  ...footprintPrimitives.arcs
73
89
  ],
90
+ [
91
+ ...copperGroups.surface.regions,
92
+ ...copperGroups.subsurface.regions,
93
+ ...footprintPrimitives.regions
94
+ ],
74
95
  vias,
75
96
  pads
76
97
  )
@@ -151,6 +172,11 @@ export class PcbSvgRenderer {
151
172
  )
152
173
  )
153
174
  .join('')
175
+ const regionMarkup = (regionList, visibilityClass) =>
176
+ PcbRegionPrimitiveRenderer.buildMarkup(
177
+ regionList,
178
+ 'pcb-region pcb-region--' + visibilityClass
179
+ )
154
180
  const viaMarkup = vias
155
181
  .map((via) => {
156
182
  const ringRadius = Math.max((via.diameter || 0) / 2, 1)
@@ -222,6 +248,14 @@ export class PcbSvgRenderer {
222
248
  const footprintArcMarkup = footprintPrimitives.arcs
223
249
  .map((arc) => PcbArcUtils.buildMarkup(arc, 'pcb-footprint-arc'))
224
250
  .join('')
251
+ const footprintRegionMarkup = PcbRegionPrimitiveRenderer.buildMarkup(
252
+ footprintPrimitives.regions,
253
+ 'pcb-footprint-region'
254
+ )
255
+ const textMarkup = PcbTextPrimitiveRenderer.render(texts)
256
+ const fontFaceMarkup = PcbEmbeddedFontFaceRenderer.buildMarkup(
257
+ pcb.embeddedFonts || []
258
+ )
225
259
 
226
260
  const componentMarkup = components
227
261
  .map((component) => {
@@ -260,13 +294,7 @@ export class PcbSvgRenderer {
260
294
  SchematicSvgUtils.formatNumber(component.rotation) +
261
295
  ')">' +
262
296
  bodyMarkup +
263
- '<text x="0" y="' +
264
- SchematicSvgUtils.formatNumber(
265
- bodyGeometry.height * -0.75
266
- ) +
267
- '">' +
268
- SchematicSvgUtils.escapeHtml(component.designator) +
269
- '</text></g>'
297
+ '</g>'
270
298
  )
271
299
  })
272
300
  .join('')
@@ -288,7 +316,9 @@ export class PcbSvgRenderer {
288
316
  '<svg class="pcb-svg" viewBox="' +
289
317
  SchematicSvgUtils.escapeHtml(viewBox) +
290
318
  '" preserveAspectRatio="xMidYMid meet" aria-label="PCB view">' +
291
- '<defs><clipPath id="' +
319
+ '<defs>' +
320
+ fontFaceMarkup +
321
+ '<clipPath id="' +
292
322
  clipPathId +
293
323
  '"><path d="' +
294
324
  SchematicSvgUtils.escapeHtml(path) +
@@ -302,12 +332,14 @@ export class PcbSvgRenderer {
302
332
  '<g class="pcb-copper pcb-copper--subsurface">' +
303
333
  polygonMarkup(copperGroups.subsurface.polygons, 'subsurface') +
304
334
  fillMarkup(copperGroups.subsurface.fills, 'subsurface') +
335
+ regionMarkup(copperGroups.subsurface.regions, 'subsurface') +
305
336
  trackMarkup(copperGroups.subsurface.tracks, 'subsurface') +
306
337
  arcMarkup(copperGroups.subsurface.arcs, 'subsurface') +
307
338
  '</g>' +
308
339
  '<g class="pcb-copper pcb-copper--surface">' +
309
340
  polygonMarkup(copperGroups.surface.polygons, 'surface') +
310
341
  fillMarkup(copperGroups.surface.fills, 'surface') +
342
+ regionMarkup(copperGroups.surface.regions, 'surface') +
311
343
  trackMarkup(copperGroups.surface.tracks, 'surface') +
312
344
  arcMarkup(copperGroups.surface.arcs, 'surface') +
313
345
  padMarkup +
@@ -318,6 +350,12 @@ export class PcbSvgRenderer {
318
350
  footprintFillMarkup +
319
351
  footprintTrackMarkup +
320
352
  footprintArcMarkup +
353
+ footprintRegionMarkup +
354
+ '</g>' +
355
+ '<g class="pcb-texts" clip-path="url(#' +
356
+ clipPathId +
357
+ ')">' +
358
+ textMarkup +
321
359
  '</g>' +
322
360
  '<path class="board-outline board-outline--stroke" d="' +
323
361
  SchematicSvgUtils.escapeHtml(path) +
@@ -382,6 +420,7 @@ export class PcbSvgRenderer {
382
420
  * @param {{ x1: number, y1: number, x2: number, y2: number }[]} fills
383
421
  * @param {{ x1: number, y1: number, x2: number, y2: number }[]} tracks
384
422
  * @param {{ x: number, y: number, radius: number, width?: number }[]} arcs
423
+ * @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][] }[]} regions
385
424
  * @param {{ x: number, y: number, diameter: number }[]} vias
386
425
  * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number }[]} pads
387
426
  * @returns {string}
@@ -393,6 +432,7 @@ export class PcbSvgRenderer {
393
432
  fills,
394
433
  tracks,
395
434
  arcs,
435
+ regions,
396
436
  vias,
397
437
  pads
398
438
  ) {
@@ -424,6 +464,10 @@ export class PcbSvgRenderer {
424
464
  PcbArcUtils.pushExtents(xs, ys, arc)
425
465
  }
426
466
 
467
+ for (const region of regions) {
468
+ PcbRegionPrimitiveRenderer.pushExtents(xs, ys, region)
469
+ }
470
+
427
471
  for (const via of vias) {
428
472
  const radius = (via.diameter || 0) / 2
429
473
  xs.push(via.x - radius, via.x + radius)
@@ -664,14 +708,24 @@ export class PcbSvgRenderer {
664
708
  }
665
709
 
666
710
  /**
667
- * Returns true when one component already has authored local geometry from
668
- * selected top-side documentation layers.
669
- * @param {{ x: number, y: number, pattern: string }} component
670
- * @param {{ fills: { x1: number, y1: number, x2: number, y2: number }[], tracks: { x1: number, y1: number, x2: number, y2: number }[], arcs: { x: number, y: number, radius: number, width?: number }[] }} footprintPrimitives
671
- * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, rotation?: number, offsetTopX?: number, offsetTopY?: number, holeDiameter?: number }[]} pads
711
+ * Returns true when one component already has authored geometry, either by
712
+ * an explicit component ownership link or by nearby recovered primitives.
713
+ * @param {{ componentIndex?: number | null, x: number, y: number, pattern: string }} component
714
+ * @param {{ fills: { componentIndex?: number | null, x1: number, y1: number, x2: number, y2: number }[], tracks: { componentIndex?: number | null, x1: number, y1: number, x2: number, y2: number }[], arcs: { componentIndex?: number | null, x: number, y: number, radius: number, width?: number }[], regions?: { componentIndex?: number | null, points?: { x: number, y: number }[], holes?: { x: number, y: number }[][] }[] }} footprintPrimitives
715
+ * @param {{ componentIndex?: number | null, x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, rotation?: number, offsetTopX?: number, offsetTopY?: number, holeDiameter?: number }[]} pads
672
716
  * @returns {boolean}
673
717
  */
674
718
  static #hasAuthoredFootprintDetail(component, footprintPrimitives, pads) {
719
+ if (
720
+ PcbSvgRenderer.#hasComponentOwnedFootprintDetail(
721
+ component,
722
+ footprintPrimitives,
723
+ pads
724
+ )
725
+ ) {
726
+ return true
727
+ }
728
+
675
729
  const bounds = PcbSvgRenderer.#footprintDetailBounds(component)
676
730
 
677
731
  return (
@@ -684,12 +738,43 @@ export class PcbSvgRenderer {
684
738
  (footprintPrimitives.arcs || []).some((arc) =>
685
739
  PcbArcUtils.intersectsBounds(arc, bounds)
686
740
  ) ||
741
+ (footprintPrimitives.regions || []).some((region) =>
742
+ PcbRegionPrimitiveRenderer.intersectsBounds(region, bounds)
743
+ ) ||
687
744
  (pads || []).some((pad) =>
688
745
  PcbSvgRenderer.#padIntersectsBounds(pad, bounds)
689
746
  )
690
747
  )
691
748
  }
692
749
 
750
+ /**
751
+ * Returns true when recovered primitives directly reference the component.
752
+ * @param {{ componentIndex?: number | null }} component
753
+ * @param {{ fills?: { componentIndex?: number | null }[], tracks?: { componentIndex?: number | null }[], arcs?: { componentIndex?: number | null }[], regions?: { componentIndex?: number | null }[] }} footprintPrimitives
754
+ * @param {{ componentIndex?: number | null }[]} pads
755
+ * @returns {boolean}
756
+ */
757
+ static #hasComponentOwnedFootprintDetail(
758
+ component,
759
+ footprintPrimitives,
760
+ pads
761
+ ) {
762
+ const componentIndex = Number(component?.componentIndex)
763
+ if (!Number.isInteger(componentIndex)) {
764
+ return false
765
+ }
766
+
767
+ return [
768
+ ...(footprintPrimitives.tracks || []),
769
+ ...(footprintPrimitives.fills || []),
770
+ ...(footprintPrimitives.arcs || []),
771
+ ...(footprintPrimitives.regions || []),
772
+ ...(pads || [])
773
+ ].some(
774
+ (primitive) => Number(primitive?.componentIndex) === componentIndex
775
+ )
776
+ }
777
+
693
778
  /**
694
779
  * Returns true when one recovered pad surface overlaps a component-local
695
780
  * search box, which means the footprint already has concrete 2D items.
@@ -771,89 +856,6 @@ export class PcbSvgRenderer {
771
856
  )
772
857
  }
773
858
 
774
- /**
775
- * Splits recovered copper primitives into the default top-facing surface
776
- * view and de-emphasized buried layers.
777
- * @param {{ layer?: string, segments: Array<Record<string, number | string>> }[]} polygons
778
- * @param {{ x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[]} fills
779
- * @param {{ x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[]} tracks
780
- * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[]} arcs
781
- * @returns {{ surface: { polygons: { layer?: string, segments: Array<Record<string, number | string>> }[], fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }, subsurface: { polygons: { layer?: string, segments: Array<Record<string, number | string>> }[], fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] } }}
782
- */
783
- static #splitCopperPrimitives(polygons, fills, tracks, arcs) {
784
- const copperFills = fills.filter((fill) =>
785
- PcbSvgRenderer.#isCopperLayerId(fill.layerId)
786
- )
787
- const copperTracks = tracks.filter((track) =>
788
- PcbSvgRenderer.#isCopperLayerId(track.layerId)
789
- )
790
- const copperArcs = arcs.filter((arc) =>
791
- PcbSvgRenderer.#isCopperLayerId(arc.layerId)
792
- )
793
- const surfaceTrackLayerCode =
794
- PcbSvgRenderer.#resolveSurfaceLayerCode(copperTracks)
795
- const surfaceFillLayerCode =
796
- PcbSvgRenderer.#resolveSurfaceLayerCode(copperFills)
797
- const surfaceArcLayerCode =
798
- PcbSvgRenderer.#resolveSurfaceLayerCode(copperArcs)
799
-
800
- return {
801
- surface: {
802
- polygons: polygons.filter((polygon) =>
803
- PcbSvgRenderer.#isSurfacePolygon(polygon)
804
- ),
805
- fills: copperFills.filter(
806
- (fill) => fill.layerCode === surfaceFillLayerCode
807
- ),
808
- tracks: copperTracks.filter(
809
- (track) => track.layerCode === surfaceTrackLayerCode
810
- ),
811
- arcs: copperArcs.filter(
812
- (arc) => arc.layerCode === surfaceArcLayerCode
813
- )
814
- },
815
- subsurface: {
816
- polygons: polygons.filter(
817
- (polygon) => !PcbSvgRenderer.#isSurfacePolygon(polygon)
818
- ),
819
- fills: copperFills.filter(
820
- (fill) => fill.layerCode !== surfaceFillLayerCode
821
- ),
822
- tracks: copperTracks.filter(
823
- (track) => track.layerCode !== surfaceTrackLayerCode
824
- ),
825
- arcs: copperArcs.filter(
826
- (arc) => arc.layerCode !== surfaceArcLayerCode
827
- )
828
- }
829
- }
830
- }
831
-
832
- /**
833
- * Returns the default visible layer code from one primitive family.
834
- * @param {{ layerCode?: number }[]} primitives
835
- * @returns {number | null}
836
- */
837
- static #resolveSurfaceLayerCode(primitives) {
838
- const layerCodes = primitives
839
- .map((primitive) => primitive.layerCode)
840
- .filter((layerCode) => Number.isFinite(layerCode))
841
- return layerCodes.length ? Math.min(...layerCodes) : null
842
- }
843
-
844
- /**
845
- * Returns true when one polygon belongs to the top-facing copper view.
846
- * @param {{ layer?: string }} polygon
847
- * @returns {boolean}
848
- */
849
- static #isSurfacePolygon(polygon) {
850
- return (
851
- String(polygon.layer || '')
852
- .trim()
853
- .toUpperCase() === 'TOP'
854
- )
855
- }
856
-
857
859
  /**
858
860
  * Returns true when one track intersects a component-local search box.
859
861
  * @param {{ x1: number, y1: number, x2: number, y2: number }} track
@@ -893,14 +895,4 @@ export class PcbSvgRenderer {
893
895
  minY > bounds.maxY
894
896
  )
895
897
  }
896
-
897
- /**
898
- * Returns true when one decoded primitive layer belongs to the copper
899
- * stack instead of a mechanical or annotation layer.
900
- * @param {number | undefined} layerId
901
- * @returns {boolean}
902
- */
903
- static #isCopperLayerId(layerId) {
904
- return Number.isInteger(layerId) && layerId >= 1 && layerId <= 32
905
- }
906
898
  }