altium-toolkit 0.1.0

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 (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,957 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbOutlineRasterizer } from './PcbOutlineRasterizer.mjs'
6
+
7
+ /**
8
+ * Recovers a board-facing PCB outline from mechanical boundary tracks and
9
+ * normalizes PCB coordinates into SVG top-view space.
10
+ */
11
+ export class PcbOutlineRecovery {
12
+ static #BOARD_ROUTE_CLOSURE_MIL = 40
13
+
14
+ static #MAX_BOARD_ROUTE_AREA_INCREASE_RATIO = 1.12
15
+
16
+ static #MIN_BOARD_ROUTE_COMPLEXITY_SEGMENTS = 8
17
+
18
+ static #MIN_BOARD_ROUTE_SIGNIFICANT_GAIN_RATIO = 1.002
19
+
20
+ static #MIN_COMPONENT_MARGIN_MIL = 120
21
+
22
+ static #MAX_DIRECT_RENDER_BOARD_ROUTE_SEGMENTS = 12
23
+
24
+ static #MAX_DIRECT_RENDER_ARC_SWEEP_DEGREES = 120
25
+
26
+ /**
27
+ * Selects a recoverable board outline from mechanical track layers.
28
+ * @param {{ fallbackOutline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }, components: { x: number, y: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerId?: number }[] }} options
29
+ * @returns {{ source: 'board-route' | 'fallback' | 'mechanical-track-layer', layerId: number | null, outline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> } }}
30
+ */
31
+ static recoverOutline(options) {
32
+ const fallbackOutline = options?.fallbackOutline || {
33
+ minX: 0,
34
+ minY: 0,
35
+ widthMil: 0,
36
+ heightMil: 0,
37
+ segments: []
38
+ }
39
+ const componentBounds = PcbOutlineRecovery.#buildComponentBounds(
40
+ options?.components || []
41
+ )
42
+
43
+ if (!componentBounds) {
44
+ return {
45
+ source: 'fallback',
46
+ layerId: null,
47
+ outline: fallbackOutline
48
+ }
49
+ }
50
+
51
+ const boardRouteOutline = PcbOutlineRecovery.#recoverBoardRouteOutline(
52
+ fallbackOutline,
53
+ options?.components || [],
54
+ componentBounds
55
+ )
56
+
57
+ if (boardRouteOutline) {
58
+ return {
59
+ source: 'board-route',
60
+ layerId: null,
61
+ outline: boardRouteOutline
62
+ }
63
+ }
64
+
65
+ const boundaryLayer = PcbOutlineRecovery.#selectBoundaryLayer(
66
+ options?.tracks || [],
67
+ componentBounds
68
+ )
69
+
70
+ if (!boundaryLayer) {
71
+ return {
72
+ source: 'fallback',
73
+ layerId: null,
74
+ outline: fallbackOutline
75
+ }
76
+ }
77
+
78
+ const recoveredOutline =
79
+ PcbOutlineRecovery.#traceTrackOutline(
80
+ boundaryLayer.tracks,
81
+ options?.components || [],
82
+ componentBounds
83
+ ) || PcbOutlineRecovery.#buildRectOutline(boundaryLayer.bounds)
84
+
85
+ return {
86
+ source: 'mechanical-track-layer',
87
+ layerId: boundaryLayer.layerId,
88
+ outline: recoveredOutline
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Mirrors one normalized PCB model vertically so the SVG matches the
94
+ * authored top-view orientation.
95
+ * @param {{ boardOutline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | 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, rotation?: number, holeRotation?: number | null }[], components?: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] }} pcb
96
+ * @returns {{ boardOutline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | 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, rotation?: number, holeRotation?: number | null }[], components: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] }}
97
+ */
98
+ static flipGeometryVertically(pcb) {
99
+ const outline = pcb?.boardOutline
100
+ const maxY =
101
+ Number(outline?.minY || 0) + Number(outline?.heightMil || 0)
102
+ const mirrorY = (value) =>
103
+ Number(outline?.minY || 0) + maxY - Number(value || 0)
104
+
105
+ return {
106
+ ...pcb,
107
+ boardOutline: {
108
+ ...outline,
109
+ segments: (outline?.segments || []).map((segment) =>
110
+ PcbOutlineRecovery.#flipSegment(segment, mirrorY)
111
+ )
112
+ },
113
+ polygons: (pcb?.polygons || []).map((polygon) => ({
114
+ ...polygon,
115
+ segments: (polygon.segments || []).map((segment) =>
116
+ PcbOutlineRecovery.#flipSegment(segment, mirrorY)
117
+ )
118
+ })),
119
+ fills: (pcb?.fills || []).map((fill) => {
120
+ const y1 = mirrorY(fill.y1)
121
+ const y2 = mirrorY(fill.y2)
122
+
123
+ return {
124
+ ...fill,
125
+ y1: Math.min(y1, y2),
126
+ y2: Math.max(y1, y2)
127
+ }
128
+ }),
129
+ tracks: (pcb?.tracks || []).map((track) => ({
130
+ ...track,
131
+ y1: mirrorY(track.y1),
132
+ y2: mirrorY(track.y2)
133
+ })),
134
+ arcs: (pcb?.arcs || []).map((arc) => ({
135
+ ...arc,
136
+ y: mirrorY(arc.y),
137
+ startAngle: PcbOutlineRecovery.#normalizeAngle(
138
+ 360 - Number(arc.startAngle || 0)
139
+ ),
140
+ endAngle: PcbOutlineRecovery.#normalizeAngle(
141
+ 360 - Number(arc.endAngle || 0)
142
+ )
143
+ })),
144
+ vias: (pcb?.vias || []).map((via) => ({
145
+ ...via,
146
+ y: mirrorY(via.y)
147
+ })),
148
+ pads: (pcb?.pads || []).map((pad) => ({
149
+ ...pad,
150
+ y: mirrorY(pad.y),
151
+ rotation: PcbOutlineRecovery.#normalizeAngle(
152
+ 360 - Number(pad.rotation || 0)
153
+ ),
154
+ holeRotation:
155
+ pad?.holeRotation === null ||
156
+ pad?.holeRotation === undefined
157
+ ? (pad?.holeRotation ?? null)
158
+ : PcbOutlineRecovery.#normalizeAngle(
159
+ 360 - Number(pad.holeRotation || 0)
160
+ )
161
+ })),
162
+ components: (pcb?.components || []).map((component) => ({
163
+ ...component,
164
+ y: mirrorY(component.y),
165
+ rotation: PcbOutlineRecovery.#normalizeAngle(
166
+ 360 - Number(component.rotation || 0)
167
+ )
168
+ }))
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Chooses the smallest mechanical track layer that still encloses all
174
+ * placements with a practical board-edge margin.
175
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width: number, layerId?: number }[]} tracks
176
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} componentBounds
177
+ * @returns {{ layerId: number, bounds: { minX: number, minY: number, maxX: number, maxY: number, widthMil: number, heightMil: number }, tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerId?: number }[] } | null}
178
+ */
179
+ static #selectBoundaryLayer(tracks, componentBounds) {
180
+ const groupedTracks = new Map()
181
+
182
+ for (const track of tracks) {
183
+ const layerId = Number(track.layerId)
184
+
185
+ if (!Number.isInteger(layerId) || layerId < 57) {
186
+ continue
187
+ }
188
+
189
+ if (!groupedTracks.has(layerId)) {
190
+ groupedTracks.set(layerId, [])
191
+ }
192
+
193
+ groupedTracks.get(layerId).push(track)
194
+ }
195
+
196
+ const candidates = []
197
+
198
+ for (const [layerId, layerTracks] of groupedTracks.entries()) {
199
+ if (layerTracks.length < 4) {
200
+ continue
201
+ }
202
+
203
+ const bounds = PcbOutlineRecovery.#buildTrackBounds(layerTracks)
204
+ if (!bounds) {
205
+ continue
206
+ }
207
+
208
+ const margins = {
209
+ left: componentBounds.minX - bounds.minX,
210
+ right: bounds.maxX - componentBounds.maxX,
211
+ top: componentBounds.minY - bounds.minY,
212
+ bottom: bounds.maxY - componentBounds.maxY
213
+ }
214
+ const minMargin = Math.min(
215
+ margins.left,
216
+ margins.right,
217
+ margins.top,
218
+ margins.bottom
219
+ )
220
+
221
+ if (minMargin < PcbOutlineRecovery.#MIN_COMPONENT_MARGIN_MIL) {
222
+ continue
223
+ }
224
+
225
+ candidates.push({
226
+ layerId,
227
+ bounds,
228
+ tracks: layerTracks,
229
+ area: bounds.widthMil * bounds.heightMil
230
+ })
231
+ }
232
+
233
+ candidates.sort((left, right) => left.area - right.area)
234
+
235
+ return candidates[0] || null
236
+ }
237
+
238
+ /**
239
+ * Builds one track-bounds envelope.
240
+ * @param {{ x1: number, y1: number, x2: number, y2: number }[]} tracks
241
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number, widthMil: number, heightMil: number } | null}
242
+ */
243
+ static #buildTrackBounds(tracks) {
244
+ if (!tracks.length) {
245
+ return null
246
+ }
247
+
248
+ let minX = Number.POSITIVE_INFINITY
249
+ let minY = Number.POSITIVE_INFINITY
250
+ let maxX = Number.NEGATIVE_INFINITY
251
+ let maxY = Number.NEGATIVE_INFINITY
252
+
253
+ for (const track of tracks) {
254
+ minX = Math.min(minX, track.x1, track.x2)
255
+ minY = Math.min(minY, track.y1, track.y2)
256
+ maxX = Math.max(maxX, track.x1, track.x2)
257
+ maxY = Math.max(maxY, track.y1, track.y2)
258
+ }
259
+
260
+ return {
261
+ minX,
262
+ minY,
263
+ maxX,
264
+ maxY,
265
+ widthMil: maxX - minX,
266
+ heightMil: maxY - minY
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Builds one placement bounds envelope.
272
+ * @param {{ x: number, y: number }[]} components
273
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number, centerX: number, centerY: number } | null}
274
+ */
275
+ static #buildComponentBounds(components) {
276
+ if (!components.length) {
277
+ return null
278
+ }
279
+
280
+ let minX = Number.POSITIVE_INFINITY
281
+ let minY = Number.POSITIVE_INFINITY
282
+ let maxX = Number.NEGATIVE_INFINITY
283
+ let maxY = Number.NEGATIVE_INFINITY
284
+
285
+ for (const component of components) {
286
+ minX = Math.min(minX, component.x)
287
+ minY = Math.min(minY, component.y)
288
+ maxX = Math.max(maxX, component.x)
289
+ maxY = Math.max(maxY, component.y)
290
+ }
291
+
292
+ return {
293
+ minX,
294
+ minY,
295
+ maxX,
296
+ maxY,
297
+ centerX: (minX + maxX) / 2,
298
+ centerY: (minY + maxY) / 2
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Returns true when one fallback route contour is complex enough to merit
304
+ * board-silhouette recovery instead of using its raw routed path directly.
305
+ * @param {{ segments?: Array<Record<string, number | string>> } | undefined} outline
306
+ * @returns {boolean}
307
+ */
308
+ static #hasRecoverableBoardRouteComplexity(outline) {
309
+ const segments = outline?.segments || []
310
+
311
+ return (
312
+ segments.length >=
313
+ PcbOutlineRecovery.#MIN_BOARD_ROUTE_COMPLEXITY_SEGMENTS ||
314
+ segments.some((segment) => segment.type === 'arc')
315
+ )
316
+ }
317
+
318
+ /**
319
+ * Converts one authored route contour into a board-body silhouette by
320
+ * filling the enclosed region and closing small scallops caused by routed
321
+ * hole bites. When the closure gain is negligible the authored contour is
322
+ * preserved as-is.
323
+ * @param {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }} fallbackOutline
324
+ * @param {{ x: number, y: number }[]} components
325
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} componentBounds
326
+ * @returns {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> } | null}
327
+ */
328
+ static #recoverBoardRouteOutline(
329
+ fallbackOutline,
330
+ components,
331
+ componentBounds
332
+ ) {
333
+ if (
334
+ !componentBounds ||
335
+ !PcbOutlineRecovery.#hasRecoverableBoardRouteComplexity(
336
+ fallbackOutline
337
+ )
338
+ ) {
339
+ return null
340
+ }
341
+
342
+ const bounds = {
343
+ minX: Number(fallbackOutline.minX || 0),
344
+ minY: Number(fallbackOutline.minY || 0),
345
+ maxX:
346
+ Number(fallbackOutline.minX || 0) +
347
+ Number(fallbackOutline.widthMil || 0),
348
+ maxY:
349
+ Number(fallbackOutline.minY || 0) +
350
+ Number(fallbackOutline.heightMil || 0),
351
+ widthMil: Number(fallbackOutline.widthMil || 0),
352
+ heightMil: Number(fallbackOutline.heightMil || 0)
353
+ }
354
+
355
+ if (!bounds.widthMil || !bounds.heightMil) {
356
+ return null
357
+ }
358
+
359
+ if (
360
+ PcbOutlineRecovery.#isDirectlyRenderableBoardRoute(fallbackOutline)
361
+ ) {
362
+ return fallbackOutline
363
+ }
364
+
365
+ const resolutionMil =
366
+ PcbOutlineRasterizer.resolveSilhouetteResolution(bounds)
367
+ const closingPasses = Math.max(
368
+ Math.ceil(
369
+ PcbOutlineRecovery.#BOARD_ROUTE_CLOSURE_MIL / resolutionMil
370
+ ),
371
+ 4
372
+ )
373
+ const paddingCells = closingPasses + 8
374
+ const rasterWidth = Math.max(
375
+ Math.ceil(bounds.widthMil / resolutionMil) + paddingCells * 2 + 1,
376
+ 32
377
+ )
378
+ const rasterHeight = Math.max(
379
+ Math.ceil(bounds.heightMil / resolutionMil) + paddingCells * 2 + 1,
380
+ 32
381
+ )
382
+ const originX = bounds.minX - paddingCells * resolutionMil
383
+ const originY = bounds.minY - paddingCells * resolutionMil
384
+ const boundaryMask = PcbOutlineRasterizer.drawOutlineMask(
385
+ fallbackOutline.segments || [],
386
+ rasterWidth,
387
+ rasterHeight,
388
+ resolutionMil,
389
+ originX,
390
+ originY
391
+ )
392
+ const exteriorMask = PcbOutlineRasterizer.floodExterior(
393
+ boundaryMask,
394
+ rasterWidth,
395
+ rasterHeight
396
+ )
397
+ const solidMask = PcbOutlineRasterizer.buildSolidMask(
398
+ boundaryMask,
399
+ exteriorMask
400
+ )
401
+ const closedMask = PcbOutlineRasterizer.closeSolidMask(
402
+ solidMask,
403
+ rasterWidth,
404
+ rasterHeight,
405
+ closingPasses
406
+ )
407
+
408
+ if (
409
+ !PcbOutlineRasterizer.maskContainsAllComponents(
410
+ closedMask,
411
+ rasterWidth,
412
+ rasterHeight,
413
+ components,
414
+ resolutionMil,
415
+ originX,
416
+ originY
417
+ )
418
+ ) {
419
+ return null
420
+ }
421
+
422
+ const contourLoops = PcbOutlineRasterizer.traceInteriorLoops(
423
+ closedMask,
424
+ rasterWidth,
425
+ rasterHeight,
426
+ resolutionMil,
427
+ originX,
428
+ originY
429
+ )
430
+
431
+ if (!contourLoops.length) {
432
+ return null
433
+ }
434
+
435
+ const points = contourLoops.sort(
436
+ (left, right) =>
437
+ Math.abs(PcbOutlineRecovery.#computeLoopArea(right)) -
438
+ Math.abs(PcbOutlineRecovery.#computeLoopArea(left))
439
+ )[0]
440
+ const simplifiedPoints = PcbOutlineRecovery.#simplifyLoopPoints(points)
441
+
442
+ if (simplifiedPoints.length < 4) {
443
+ return null
444
+ }
445
+
446
+ const recoveredOutline =
447
+ PcbOutlineRecovery.#buildOutlineFromPoints(simplifiedPoints)
448
+ const rawArea = PcbOutlineRecovery.#computeOutlineArea(fallbackOutline)
449
+ const recoveredArea = Math.abs(
450
+ PcbOutlineRecovery.#computeLoopArea(simplifiedPoints)
451
+ )
452
+
453
+ if (!rawArea || recoveredArea < rawArea) {
454
+ return null
455
+ }
456
+
457
+ const areaIncreaseRatio = recoveredArea / rawArea
458
+
459
+ if (
460
+ areaIncreaseRatio >
461
+ PcbOutlineRecovery.#MAX_BOARD_ROUTE_AREA_INCREASE_RATIO
462
+ ) {
463
+ return null
464
+ }
465
+
466
+ if (
467
+ areaIncreaseRatio <
468
+ PcbOutlineRecovery.#MIN_BOARD_ROUTE_SIGNIFICANT_GAIN_RATIO
469
+ ) {
470
+ return fallbackOutline
471
+ }
472
+
473
+ return recoveredOutline
474
+ }
475
+
476
+ /**
477
+ * Rasterizes one mechanical boundary layer and traces the filled region
478
+ * that encloses the placement centroid.
479
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width: number }[]} tracks
480
+ * @param {{ x: number, y: number }[]} components
481
+ * @param {{ centerX: number, centerY: number }} componentBounds
482
+ * @returns {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> } | null}
483
+ */
484
+ static #traceTrackOutline(tracks, components, componentBounds) {
485
+ const bounds = PcbOutlineRecovery.#buildTrackBounds(tracks)
486
+
487
+ if (!bounds) {
488
+ return null
489
+ }
490
+
491
+ const resolutionMil =
492
+ PcbOutlineRasterizer.resolveRasterResolution(bounds)
493
+ const paddingCells = 6
494
+ const rasterWidth = Math.max(
495
+ Math.ceil(bounds.widthMil / resolutionMil) + paddingCells * 2 + 1,
496
+ 16
497
+ )
498
+ const rasterHeight = Math.max(
499
+ Math.ceil(bounds.heightMil / resolutionMil) + paddingCells * 2 + 1,
500
+ 16
501
+ )
502
+ const originX = bounds.minX - paddingCells * resolutionMil
503
+ const originY = bounds.minY - paddingCells * resolutionMil
504
+
505
+ const componentCells = components
506
+ .map((component) =>
507
+ PcbOutlineRasterizer.coordinateToRasterCell(
508
+ component.x,
509
+ component.y,
510
+ resolutionMil,
511
+ originX,
512
+ originY,
513
+ rasterWidth,
514
+ rasterHeight
515
+ )
516
+ )
517
+ .filter(Boolean)
518
+
519
+ for (let dilationPasses = 0; dilationPasses <= 2; dilationPasses += 1) {
520
+ let boundaryMask = PcbOutlineRasterizer.drawBoundaryMask(
521
+ tracks,
522
+ rasterWidth,
523
+ rasterHeight,
524
+ resolutionMil,
525
+ originX,
526
+ originY
527
+ )
528
+
529
+ for (let pass = 0; pass < dilationPasses; pass += 1) {
530
+ boundaryMask = PcbOutlineRasterizer.dilateMask(
531
+ boundaryMask,
532
+ rasterWidth,
533
+ rasterHeight
534
+ )
535
+ }
536
+
537
+ const exteriorMask = PcbOutlineRasterizer.floodExterior(
538
+ boundaryMask,
539
+ rasterWidth,
540
+ rasterHeight
541
+ )
542
+ const interiorMask = PcbOutlineRasterizer.recoverPlacementInterior(
543
+ boundaryMask,
544
+ exteriorMask,
545
+ rasterWidth,
546
+ rasterHeight,
547
+ componentCells,
548
+ componentBounds,
549
+ resolutionMil,
550
+ originX,
551
+ originY
552
+ )
553
+
554
+ if (!interiorMask) {
555
+ continue
556
+ }
557
+ const contourLoops = PcbOutlineRasterizer.traceInteriorLoops(
558
+ interiorMask,
559
+ rasterWidth,
560
+ rasterHeight,
561
+ resolutionMil,
562
+ originX,
563
+ originY
564
+ )
565
+
566
+ if (!contourLoops.length) {
567
+ continue
568
+ }
569
+
570
+ const points = contourLoops.sort(
571
+ (left, right) =>
572
+ Math.abs(PcbOutlineRecovery.#computeLoopArea(right)) -
573
+ Math.abs(PcbOutlineRecovery.#computeLoopArea(left))
574
+ )[0]
575
+ const simplifiedPoints =
576
+ PcbOutlineRecovery.#simplifyLoopPoints(points)
577
+
578
+ if (simplifiedPoints.length < 4) {
579
+ continue
580
+ }
581
+
582
+ return PcbOutlineRecovery.#buildOutlineFromPoints(simplifiedPoints)
583
+ }
584
+
585
+ return null
586
+ }
587
+
588
+ /**
589
+ * Returns true when one authored board-route contour is already simple
590
+ * enough to render directly without silhouette recovery.
591
+ * @param {{ segments?: Array<Record<string, number | string>> } | undefined} outline
592
+ * @returns {boolean}
593
+ */
594
+ static #isDirectlyRenderableBoardRoute(outline) {
595
+ const segments = outline?.segments || []
596
+
597
+ if (
598
+ !segments.length ||
599
+ segments.length >
600
+ PcbOutlineRecovery.#MAX_DIRECT_RENDER_BOARD_ROUTE_SEGMENTS
601
+ ) {
602
+ return false
603
+ }
604
+
605
+ const arcSegments = segments.filter((segment) => segment.type === 'arc')
606
+
607
+ if (!arcSegments.length) {
608
+ return false
609
+ }
610
+
611
+ if (
612
+ arcSegments.some(
613
+ (segment) =>
614
+ PcbOutlineRecovery.#computeArcSweep(segment) >
615
+ PcbOutlineRecovery.#MAX_DIRECT_RENDER_ARC_SWEEP_DEGREES
616
+ )
617
+ ) {
618
+ return false
619
+ }
620
+
621
+ return PcbOutlineRecovery.#isClosedOutlinePath(segments)
622
+ }
623
+
624
+ /**
625
+ * Returns true when consecutive outline segments connect closely enough to
626
+ * form one closed authored contour.
627
+ * @param {Array<Record<string, number | string>>} segments
628
+ * @returns {boolean}
629
+ */
630
+ static #isClosedOutlinePath(segments) {
631
+ if (!segments.length) {
632
+ return false
633
+ }
634
+
635
+ for (let index = 0; index < segments.length; index += 1) {
636
+ const current = segments[index]
637
+ const next = segments[(index + 1) % segments.length]
638
+ const deltaX = Number(current.x2 || 0) - Number(next.x1 || 0)
639
+ const deltaY = Number(current.y2 || 0) - Number(next.y1 || 0)
640
+
641
+ if (
642
+ Math.hypot(deltaX, deltaY) >
643
+ PcbOutlineRecovery.#BOARD_ROUTE_CLOSURE_MIL
644
+ ) {
645
+ return false
646
+ }
647
+ }
648
+
649
+ return true
650
+ }
651
+
652
+ /**
653
+ * Builds one rectangular fallback outline from bounds.
654
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number, widthMil: number, heightMil: number }} bounds
655
+ * @returns {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }}
656
+ */
657
+ static #buildRectOutline(bounds) {
658
+ return {
659
+ minX: bounds.minX,
660
+ minY: bounds.minY,
661
+ widthMil: bounds.widthMil,
662
+ heightMil: bounds.heightMil,
663
+ segments: [
664
+ {
665
+ type: 'line',
666
+ x1: bounds.minX,
667
+ y1: bounds.minY,
668
+ x2: bounds.maxX,
669
+ y2: bounds.minY
670
+ },
671
+ {
672
+ type: 'line',
673
+ x1: bounds.maxX,
674
+ y1: bounds.minY,
675
+ x2: bounds.maxX,
676
+ y2: bounds.maxY
677
+ },
678
+ {
679
+ type: 'line',
680
+ x1: bounds.maxX,
681
+ y1: bounds.maxY,
682
+ x2: bounds.minX,
683
+ y2: bounds.maxY
684
+ },
685
+ {
686
+ type: 'line',
687
+ x1: bounds.minX,
688
+ y1: bounds.maxY,
689
+ x2: bounds.minX,
690
+ y2: bounds.minY
691
+ }
692
+ ]
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Builds one segment outline from traced loop points.
698
+ * @param {{ x: number, y: number }[]} points
699
+ * @returns {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }}
700
+ */
701
+ static #buildOutlineFromPoints(points) {
702
+ const segments = []
703
+ let minX = Number.POSITIVE_INFINITY
704
+ let minY = Number.POSITIVE_INFINITY
705
+ let maxX = Number.NEGATIVE_INFINITY
706
+ let maxY = Number.NEGATIVE_INFINITY
707
+
708
+ for (const point of points) {
709
+ minX = Math.min(minX, point.x)
710
+ minY = Math.min(minY, point.y)
711
+ maxX = Math.max(maxX, point.x)
712
+ maxY = Math.max(maxY, point.y)
713
+ }
714
+
715
+ for (let index = 0; index < points.length; index += 1) {
716
+ const current = points[index]
717
+ const next = points[(index + 1) % points.length]
718
+
719
+ segments.push({
720
+ type: 'line',
721
+ x1: current.x,
722
+ y1: current.y,
723
+ x2: next.x,
724
+ y2: next.y
725
+ })
726
+ }
727
+
728
+ return {
729
+ minX,
730
+ minY,
731
+ widthMil: maxX - minX,
732
+ heightMil: maxY - minY,
733
+ segments
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Removes duplicate closure points and intermediate collinear corners.
739
+ * @param {{ x: number, y: number }[]} points
740
+ * @returns {{ x: number, y: number }[]}
741
+ */
742
+ static #simplifyLoopPoints(points) {
743
+ const normalizedPoints = points.slice()
744
+
745
+ if (normalizedPoints.length > 1) {
746
+ const first = normalizedPoints[0]
747
+ const last = normalizedPoints[normalizedPoints.length - 1]
748
+
749
+ if (
750
+ Math.abs(first.x - last.x) < 1e-6 &&
751
+ Math.abs(first.y - last.y) < 1e-6
752
+ ) {
753
+ normalizedPoints.pop()
754
+ }
755
+ }
756
+
757
+ let changed = true
758
+
759
+ while (changed && normalizedPoints.length > 3) {
760
+ changed = false
761
+
762
+ for (
763
+ let index = 0;
764
+ index < normalizedPoints.length && normalizedPoints.length > 3;
765
+ index += 1
766
+ ) {
767
+ const previous =
768
+ normalizedPoints[
769
+ (index - 1 + normalizedPoints.length) %
770
+ normalizedPoints.length
771
+ ]
772
+ const current = normalizedPoints[index]
773
+ const next =
774
+ normalizedPoints[(index + 1) % normalizedPoints.length]
775
+
776
+ if (
777
+ (Math.abs(previous.x - current.x) < 1e-6 &&
778
+ Math.abs(current.x - next.x) < 1e-6) ||
779
+ (Math.abs(previous.y - current.y) < 1e-6 &&
780
+ Math.abs(current.y - next.y) < 1e-6)
781
+ ) {
782
+ normalizedPoints.splice(index, 1)
783
+ changed = true
784
+ break
785
+ }
786
+ }
787
+ }
788
+
789
+ return normalizedPoints
790
+ }
791
+
792
+ /**
793
+ * Computes the signed polygon area of one traced loop.
794
+ * @param {{ x: number, y: number }[]} points
795
+ * @returns {number}
796
+ */
797
+ static #computeLoopArea(points) {
798
+ let area = 0
799
+
800
+ for (let index = 0; index < points.length; index += 1) {
801
+ const current = points[index]
802
+ const next = points[(index + 1) % points.length]
803
+
804
+ area += current.x * next.y - next.x * current.y
805
+ }
806
+
807
+ return area / 2
808
+ }
809
+
810
+ /**
811
+ * Computes one approximate routed outline area by sampling arc segments
812
+ * densely enough for board-route closure decisions.
813
+ * @param {{ segments?: Array<Record<string, number | string>> } | undefined} outline
814
+ * @returns {number}
815
+ */
816
+ static #computeOutlineArea(outline) {
817
+ const segments = outline?.segments || []
818
+
819
+ if (!segments.length) {
820
+ return 0
821
+ }
822
+
823
+ const points = []
824
+
825
+ for (const segment of segments) {
826
+ const sampledPoints =
827
+ PcbOutlineRecovery.#sampleSegmentPoints(segment)
828
+
829
+ if (!sampledPoints.length) {
830
+ continue
831
+ }
832
+
833
+ if (!points.length) {
834
+ points.push(sampledPoints[0])
835
+ }
836
+
837
+ points.push(...sampledPoints.slice(1))
838
+ }
839
+
840
+ if (points.length < 3) {
841
+ return 0
842
+ }
843
+
844
+ return Math.abs(PcbOutlineRecovery.#computeLoopArea(points))
845
+ }
846
+
847
+ /**
848
+ * Samples one line or arc segment into polygon points for approximate area
849
+ * calculations.
850
+ * @param {Record<string, number | string>} segment
851
+ * @returns {{ x: number, y: number }[]}
852
+ */
853
+ static #sampleSegmentPoints(segment) {
854
+ if (segment.type !== 'arc') {
855
+ return [
856
+ {
857
+ x: Number(segment.x1 || 0),
858
+ y: Number(segment.y1 || 0)
859
+ },
860
+ {
861
+ x: Number(segment.x2 || 0),
862
+ y: Number(segment.y2 || 0)
863
+ }
864
+ ]
865
+ }
866
+
867
+ const startAngle = Number(segment.startAngle || 0)
868
+ const endAngle = Number(segment.endAngle || 0)
869
+ let delta = endAngle - startAngle
870
+
871
+ if (Math.abs(delta) < 1e-6) {
872
+ delta = 360
873
+ }
874
+
875
+ if (delta < 0) {
876
+ delta += 360
877
+ }
878
+
879
+ const steps = Math.max(Math.ceil(Math.abs(delta) / 10), 8)
880
+ const radius = Number(segment.radius) || 0
881
+ const centerX = Number(segment.cx || 0)
882
+ const centerY = Number(segment.cy || 0)
883
+ const points = []
884
+
885
+ for (let step = 0; step <= steps; step += 1) {
886
+ const angle =
887
+ ((startAngle + delta * (step / steps)) * Math.PI) / 180
888
+
889
+ points.push({
890
+ x: centerX + radius * Math.cos(angle),
891
+ y: centerY + radius * Math.sin(angle)
892
+ })
893
+ }
894
+
895
+ return points
896
+ }
897
+
898
+ /**
899
+ * Computes one normalized positive arc sweep in degrees.
900
+ * @param {Record<string, number | string>} segment
901
+ * @returns {number}
902
+ */
903
+ static #computeArcSweep(segment) {
904
+ const startAngle = Number(segment.startAngle || 0)
905
+ const endAngle = Number(segment.endAngle || 0)
906
+ let delta = endAngle - startAngle
907
+
908
+ if (Math.abs(delta) < 1e-6) {
909
+ delta = 360
910
+ }
911
+
912
+ if (delta < 0) {
913
+ delta += 360
914
+ }
915
+
916
+ return delta
917
+ }
918
+
919
+ /**
920
+ * Mirrors one outline or polygon segment across the board Y axis.
921
+ * @param {Record<string, number | string>} segment
922
+ * @param {(value: number) => number} mirrorY
923
+ * @returns {Record<string, number | string>}
924
+ */
925
+ static #flipSegment(segment, mirrorY) {
926
+ if (segment.type !== 'arc') {
927
+ return {
928
+ ...segment,
929
+ y1: mirrorY(Number(segment.y1 || 0)),
930
+ y2: mirrorY(Number(segment.y2 || 0))
931
+ }
932
+ }
933
+
934
+ const startAngle = Number(segment.startAngle || 0)
935
+ const endAngle = Number(segment.endAngle || 0)
936
+
937
+ return {
938
+ ...segment,
939
+ y1: mirrorY(Number(segment.y1 || 0)),
940
+ y2: mirrorY(Number(segment.y2 || 0)),
941
+ cy: mirrorY(Number(segment.cy || 0)),
942
+ startAngle: PcbOutlineRecovery.#normalizeAngle(360 - startAngle),
943
+ endAngle: PcbOutlineRecovery.#normalizeAngle(360 - endAngle)
944
+ }
945
+ }
946
+
947
+ /**
948
+ * Normalizes one circular angle into the [0, 360) range.
949
+ * @param {number} angle
950
+ * @returns {number}
951
+ */
952
+ static #normalizeAngle(angle) {
953
+ const normalized = Number(angle || 0) % 360
954
+
955
+ return normalized < 0 ? normalized + 360 : normalized
956
+ }
957
+ }