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,358 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: 2026 André Fiedler
3
+ *
4
+ * SPDX-License-Identifier: GPL-3.0-or-later
5
+ */
6
+
7
+ .altium-renderer-empty {
8
+ min-height: 18rem;
9
+ display: grid;
10
+ place-items: center;
11
+ text-align: center;
12
+ color: var(--altium-renderer-muted, #405662);
13
+ background: var(--altium-renderer-surface, #fffaf5);
14
+ border: 1px solid var(--altium-renderer-border, rgba(95, 112, 124, 0.14));
15
+ border-radius: 8px;
16
+ padding: 2rem;
17
+ }
18
+
19
+ .svg-panel {
20
+ display: grid;
21
+ gap: 1rem;
22
+ color: var(--altium-renderer-text, #22343b);
23
+ }
24
+
25
+ .svg-panel__header {
26
+ display: flex;
27
+ justify-content: space-between;
28
+ gap: 1rem;
29
+ align-items: baseline;
30
+ flex-wrap: wrap;
31
+ }
32
+
33
+ .svg-panel__header h3,
34
+ .pcb-legend h4,
35
+ .bom-panel__header h3,
36
+ .altium-3d-summary__header h3 {
37
+ margin: 0;
38
+ }
39
+
40
+ .svg-panel__header p,
41
+ .pcb-legend p,
42
+ .bom-panel__header p,
43
+ .altium-3d-summary__header p {
44
+ margin: 0.35rem 0 0;
45
+ color: var(--altium-renderer-muted, #405662);
46
+ }
47
+
48
+ .schematic-svg,
49
+ .pcb-svg {
50
+ width: 100%;
51
+ min-height: 36rem;
52
+ display: block;
53
+ border-radius: 8px;
54
+ background: var(--altium-renderer-canvas, #f7f4ef);
55
+ border: 1px solid var(--altium-renderer-border, rgba(95, 112, 124, 0.14));
56
+ }
57
+
58
+ .schematic-svg {
59
+ --schematic-default-ink-color: #0091ac;
60
+ --schematic-accent-ink-color: #14c5e6;
61
+ --schematic-text-color: #121b22;
62
+ --schematic-sheet-label-color: #405662;
63
+ --schematic-power-color: #a84a12;
64
+ --schematic-port-color: #f28724;
65
+ --schematic-alert-color: #da2f70;
66
+ --schematic-fill-color: #f4dec7;
67
+ --schematic-note-fill-color: #efe4d1;
68
+ --schematic-fill-light-color: #fffaf5;
69
+ --schematic-pin-marker-fill: #edf4f3;
70
+ --schematic-note-border-color: #8a725c;
71
+ --schematic-sheet-backdrop-fill: rgba(253, 251, 248, 0.96);
72
+ --schematic-sheet-backdrop-stroke: rgba(95, 112, 124, 0.12);
73
+ --schematic-sheet-frame-stroke: rgba(95, 112, 124, 0.48);
74
+ --schematic-node-fill: rgba(15, 116, 108, 0.9);
75
+ }
76
+
77
+ .sheet-backdrop {
78
+ fill: var(--schematic-sheet-backdrop-fill);
79
+ stroke: var(--schematic-sheet-backdrop-stroke);
80
+ }
81
+
82
+ .sheet-frame,
83
+ .sheet-zone-separator,
84
+ .sheet-title-block rect,
85
+ .sheet-title-block line {
86
+ fill: none;
87
+ stroke: var(--schematic-sheet-frame-stroke);
88
+ stroke-width: 1;
89
+ }
90
+
91
+ .sheet-title-label,
92
+ .sheet-title-value,
93
+ .sheet-zone-label {
94
+ font-family: Arial, sans-serif;
95
+ }
96
+
97
+ .sheet-title-label {
98
+ font-size: 6px;
99
+ }
100
+
101
+ .sheet-title-value {
102
+ font-size: 9px;
103
+ font-weight: 600;
104
+ }
105
+
106
+ .sheet-zone-label {
107
+ font-size: 8px;
108
+ font-weight: 500;
109
+ }
110
+
111
+ .schematic-pin-line,
112
+ .schematic-port rect,
113
+ .schematic-port polygon,
114
+ .schematic-cross line,
115
+ .schematic-power-port line {
116
+ stroke-width: 1;
117
+ }
118
+
119
+ .schematic-power-port-label {
120
+ font-family: 'Times New Roman', serif;
121
+ }
122
+
123
+ .schematic-node circle,
124
+ .schematic-authored-junction {
125
+ fill: var(--schematic-node-fill);
126
+ }
127
+
128
+ .pcb-svg {
129
+ --pcb-board-fill: #d8e8e4;
130
+ --pcb-board-stroke: #0f746c;
131
+ --pcb-surface-copper-fill: rgba(199, 109, 61, 0.15);
132
+ --pcb-subsurface-copper-fill: rgba(114, 84, 62, 0.06);
133
+ --pcb-surface-fill: rgba(199, 109, 61, 0.34);
134
+ --pcb-subsurface-fill: rgba(114, 84, 62, 0.08);
135
+ --pcb-surface-track-color: rgba(199, 82, 45, 0.88);
136
+ --pcb-subsurface-track-color: rgba(112, 84, 62, 0.2);
137
+ --pcb-copper-fill: rgba(196, 118, 70, 0.24);
138
+ --pcb-copper-solid-fill: rgba(196, 118, 70, 0.78);
139
+ --pcb-track-color: #c7522d;
140
+ --pcb-via-ring-fill: rgba(232, 236, 233, 0.92);
141
+ --pcb-via-hole-fill: #0f746c;
142
+ --pcb-footprint-fill: rgba(247, 230, 117, 0.14);
143
+ --pcb-footprint-track-color: rgba(237, 172, 36, 1);
144
+ --pcb-component-top-fill: rgba(244, 219, 198, 0.92);
145
+ --pcb-component-bottom-fill: rgba(15, 116, 108, 0.84);
146
+ --pcb-component-stroke: rgba(110, 64, 38, 0.28);
147
+ --pcb-component-text: #22343b;
148
+ background:
149
+ radial-gradient(
150
+ circle at top,
151
+ rgba(255, 255, 255, 0.72),
152
+ transparent 44%
153
+ ),
154
+ linear-gradient(180deg, #f7f4ef 0%, #f0ece4 100%);
155
+ }
156
+
157
+ .pcb-layout {
158
+ display: grid;
159
+ grid-template-columns: minmax(180px, 220px) 1fr;
160
+ gap: 1rem;
161
+ align-items: start;
162
+ }
163
+
164
+ .pcb-legend {
165
+ padding: 1rem;
166
+ border-radius: 8px;
167
+ background: var(--altium-renderer-surface, rgba(255, 255, 255, 0.72));
168
+ border: 1px solid var(--altium-renderer-border, rgba(95, 112, 124, 0.14));
169
+ }
170
+
171
+ .pcb-legend ul {
172
+ margin: 0.8rem 0 0;
173
+ padding-left: 1rem;
174
+ color: var(--altium-renderer-muted, #405662);
175
+ }
176
+
177
+ .board-outline {
178
+ fill: var(--pcb-board-fill);
179
+ stroke: var(--pcb-board-stroke);
180
+ stroke-width: 18;
181
+ }
182
+
183
+ .board-outline--stroke {
184
+ fill: none;
185
+ stroke-linejoin: round;
186
+ }
187
+
188
+ .pcb-polygon,
189
+ .pcb-fill {
190
+ stroke: none;
191
+ }
192
+
193
+ .pcb-track,
194
+ .pcb-arc,
195
+ .pcb-footprint-track,
196
+ .pcb-footprint-arc {
197
+ fill: none;
198
+ stroke-linecap: round;
199
+ stroke-linejoin: round;
200
+ }
201
+
202
+ .pcb-copper--surface .pcb-polygon {
203
+ fill: var(--pcb-surface-copper-fill);
204
+ }
205
+
206
+ .pcb-copper--subsurface .pcb-polygon {
207
+ fill: var(--pcb-subsurface-copper-fill);
208
+ }
209
+
210
+ .pcb-copper--surface .pcb-fill {
211
+ fill: var(--pcb-surface-fill);
212
+ }
213
+
214
+ .pcb-copper--subsurface .pcb-fill {
215
+ fill: var(--pcb-subsurface-fill);
216
+ }
217
+
218
+ .pcb-copper--surface .pcb-track,
219
+ .pcb-copper--surface .pcb-arc {
220
+ stroke: var(--pcb-surface-track-color);
221
+ }
222
+
223
+ .pcb-copper--subsurface .pcb-track,
224
+ .pcb-copper--subsurface .pcb-arc {
225
+ stroke: var(--pcb-subsurface-track-color);
226
+ }
227
+
228
+ .pcb-copper--subsurface {
229
+ opacity: 0.45;
230
+ }
231
+
232
+ .pcb-via__pad,
233
+ .pcb-pad__ring {
234
+ fill: var(--pcb-via-ring-fill);
235
+ }
236
+
237
+ .pcb-via__hole,
238
+ .pcb-pad__hole,
239
+ .pcb-pad__hole--slot {
240
+ fill: var(--pcb-via-hole-fill);
241
+ }
242
+
243
+ .pcb-pad--smd .pcb-pad__ring {
244
+ fill: var(--pcb-copper-solid-fill);
245
+ }
246
+
247
+ .pcb-footprint-fill {
248
+ fill: var(--pcb-footprint-fill);
249
+ }
250
+
251
+ .pcb-footprint-track,
252
+ .pcb-footprint-arc {
253
+ stroke: var(--pcb-footprint-track-color);
254
+ }
255
+
256
+ .pcb-component__body {
257
+ fill: var(--pcb-component-top-fill);
258
+ stroke: var(--pcb-component-stroke);
259
+ stroke-width: 3;
260
+ }
261
+
262
+ .pcb-component--bottom .pcb-component__body {
263
+ fill: var(--pcb-component-bottom-fill);
264
+ }
265
+
266
+ .pcb-component text {
267
+ font-size: 29px;
268
+ text-anchor: middle;
269
+ fill: var(--pcb-component-text);
270
+ font-weight: 700;
271
+ }
272
+
273
+ .bom-panel {
274
+ display: grid;
275
+ gap: 1rem;
276
+ color: var(--altium-renderer-text, #22343b);
277
+ }
278
+
279
+ .bom-table {
280
+ width: 100%;
281
+ border-collapse: collapse;
282
+ overflow: hidden;
283
+ border-radius: 8px;
284
+ background: var(--altium-renderer-surface, rgba(255, 255, 255, 0.7));
285
+ }
286
+
287
+ .bom-table th,
288
+ .bom-table td {
289
+ padding: 0.8rem 0.9rem;
290
+ border-bottom: 1px solid
291
+ var(--altium-renderer-border, rgba(95, 112, 124, 0.12));
292
+ text-align: left;
293
+ vertical-align: top;
294
+ }
295
+
296
+ .bom-table th {
297
+ color: var(--altium-renderer-muted, #405662);
298
+ font-size: 0.86rem;
299
+ text-transform: uppercase;
300
+ letter-spacing: 0.05em;
301
+ }
302
+
303
+ .altium-3d-summary {
304
+ display: grid;
305
+ gap: 1rem;
306
+ padding: 1rem;
307
+ border-radius: 8px;
308
+ color: var(--altium-renderer-text, #22343b);
309
+ background: var(--altium-renderer-surface, rgba(255, 255, 255, 0.72));
310
+ border: 1px solid var(--altium-renderer-border, rgba(95, 112, 124, 0.14));
311
+ }
312
+
313
+ .altium-3d-summary--empty {
314
+ color: var(--altium-renderer-muted, #405662);
315
+ }
316
+
317
+ .altium-3d-summary__stats {
318
+ display: grid;
319
+ grid-template-columns: repeat(3, minmax(0, 1fr));
320
+ gap: 0.75rem;
321
+ margin: 0;
322
+ }
323
+
324
+ .altium-3d-summary__stats div {
325
+ padding: 0.85rem;
326
+ border-radius: 8px;
327
+ background: var(--altium-renderer-panel, rgba(255, 255, 255, 0.68));
328
+ border: 1px solid var(--altium-renderer-border, rgba(95, 112, 124, 0.12));
329
+ }
330
+
331
+ .altium-3d-summary__stats dt {
332
+ color: var(--altium-renderer-muted, #405662);
333
+ font-size: 0.78rem;
334
+ text-transform: uppercase;
335
+ letter-spacing: 0.06em;
336
+ }
337
+
338
+ .altium-3d-summary__stats dd {
339
+ margin: 0.35rem 0 0;
340
+ font-weight: 700;
341
+ }
342
+
343
+ @media (max-width: 1024px) {
344
+ .pcb-layout {
345
+ grid-template-columns: 1fr;
346
+ }
347
+ }
348
+
349
+ @media (max-width: 760px) {
350
+ .schematic-svg,
351
+ .pcb-svg {
352
+ min-height: 26rem;
353
+ }
354
+
355
+ .altium-3d-summary__stats {
356
+ grid-template-columns: 1fr;
357
+ }
358
+ }
@@ -0,0 +1,46 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
6
+
7
+ /**
8
+ * Renders grouped BOM rows into HTML tables.
9
+ */
10
+ export class BomTableRenderer {
11
+ /**
12
+ * Renders grouped BOM rows into an HTML table.
13
+ * @param {{ designators: string[], quantity: number, pattern: string, source: string, value: string }[]} rows
14
+ * @returns {string}
15
+ */
16
+ static render(rows) {
17
+ if (!rows.length) {
18
+ return '<section class="altium-renderer-empty">No BOM rows were recovered from this file.</section>'
19
+ }
20
+
21
+ const bodyMarkup = rows
22
+ .map(
23
+ (row) =>
24
+ '<tr><td>' +
25
+ SchematicSvgUtils.escapeHtml(row.designators.join(', ')) +
26
+ '</td><td>' +
27
+ SchematicSvgUtils.escapeHtml(String(row.quantity)) +
28
+ '</td><td>' +
29
+ SchematicSvgUtils.escapeHtml(row.pattern || 'Unknown') +
30
+ '</td><td>' +
31
+ SchematicSvgUtils.escapeHtml(row.value || '') +
32
+ '</td><td>' +
33
+ SchematicSvgUtils.escapeHtml(row.source || '') +
34
+ '</td></tr>'
35
+ )
36
+ .join('')
37
+
38
+ return (
39
+ '<section class="bom-panel"><header class="bom-panel__header"><h3>BOM</h3><p>' +
40
+ rows.length +
41
+ ' grouped rows</p></header><table class="bom-table"><thead><tr><th>Designators</th><th>Qty</th><th>Pattern</th><th>Value</th><th>Source</th></tr></thead><tbody>' +
42
+ bodyMarkup +
43
+ '</tbody></table></section>'
44
+ )
45
+ }
46
+ }
@@ -0,0 +1,189 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
6
+
7
+ /**
8
+ * Builds PCB arc markup and geometry helpers for renderer layout decisions.
9
+ */
10
+ export class PcbArcUtils {
11
+ static #FULL_CIRCLE_EPSILON = 0.001
12
+
13
+ /**
14
+ * Builds one PCB arc or authored circle as SVG markup.
15
+ * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number }} arc
16
+ * @param {string} className
17
+ * @returns {string}
18
+ */
19
+ static buildMarkup(arc, className) {
20
+ const radius = Math.max(Number(arc.radius || 0), 0.8)
21
+ const strokeWidth = Math.max(Number(arc.width || 0), 1)
22
+
23
+ if (PcbArcUtils.#isFullCircle(arc)) {
24
+ return (
25
+ '<circle class="' +
26
+ SchematicSvgUtils.escapeHtml(className) +
27
+ '" cx="' +
28
+ SchematicSvgUtils.formatNumber(Number(arc.x || 0)) +
29
+ '" cy="' +
30
+ SchematicSvgUtils.formatNumber(Number(arc.y || 0)) +
31
+ '" r="' +
32
+ SchematicSvgUtils.formatNumber(radius) +
33
+ '" stroke-width="' +
34
+ SchematicSvgUtils.formatNumber(strokeWidth) +
35
+ '" fill="none" />'
36
+ )
37
+ }
38
+
39
+ return (
40
+ '<path class="' +
41
+ SchematicSvgUtils.escapeHtml(className) +
42
+ '" d="' +
43
+ SchematicSvgUtils.escapeHtml(PcbArcUtils.#buildPath(arc, radius)) +
44
+ '" stroke-width="' +
45
+ SchematicSvgUtils.formatNumber(strokeWidth) +
46
+ '" fill="none" />'
47
+ )
48
+ }
49
+
50
+ /**
51
+ * Pushes one conservative arc bounding box into running axis lists.
52
+ * @param {number[]} xs
53
+ * @param {number[]} ys
54
+ * @param {{ x: number, y: number, radius: number, width?: number }} arc
55
+ * @returns {void}
56
+ */
57
+ static pushExtents(xs, ys, arc) {
58
+ const radius =
59
+ Math.max(Number(arc.radius || 0), 0) +
60
+ Math.max(Number(arc.width || 0), 1) / 2
61
+ const centerX = Number(arc.x || 0)
62
+ const centerY = Number(arc.y || 0)
63
+
64
+ xs.push(centerX - radius, centerX + radius)
65
+ ys.push(centerY - radius, centerY + radius)
66
+ }
67
+
68
+ /**
69
+ * Returns true when one arc overlaps a local component search box.
70
+ * @param {{ x: number, y: number, radius: number, width?: number }} arc
71
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
72
+ * @returns {boolean}
73
+ */
74
+ static intersectsBounds(arc, bounds) {
75
+ const radius =
76
+ Math.max(Number(arc.radius || 0), 0) +
77
+ Math.max(Number(arc.width || 0), 1) / 2
78
+ const centerX = Number(arc.x || 0)
79
+ const centerY = Number(arc.y || 0)
80
+
81
+ return !(
82
+ centerX + radius < bounds.minX ||
83
+ centerX - radius > bounds.maxX ||
84
+ centerY + radius < bounds.minY ||
85
+ centerY - radius > bounds.maxY
86
+ )
87
+ }
88
+
89
+ /**
90
+ * Builds one SVG arc path command from normalized PCB arc geometry.
91
+ * @param {{ x: number, y: number, startAngle: number, endAngle: number }} arc
92
+ * @param {number} radius
93
+ * @returns {string}
94
+ */
95
+ static #buildPath(arc, radius) {
96
+ const start = PcbArcUtils.#projectPoint(arc, arc.startAngle, radius)
97
+ const end = PcbArcUtils.#projectPoint(arc, arc.endAngle, radius)
98
+ const delta = PcbArcUtils.resolveSweepDelta(
99
+ arc.startAngle,
100
+ arc.endAngle
101
+ )
102
+
103
+ return (
104
+ 'M ' +
105
+ SchematicSvgUtils.formatNumber(start.x) +
106
+ ' ' +
107
+ SchematicSvgUtils.formatNumber(start.y) +
108
+ ' A ' +
109
+ SchematicSvgUtils.formatNumber(radius) +
110
+ ' ' +
111
+ SchematicSvgUtils.formatNumber(radius) +
112
+ ' 0 ' +
113
+ (Math.abs(delta) > 180 ? 1 : 0) +
114
+ ' ' +
115
+ (delta >= 0 ? 1 : 0) +
116
+ ' ' +
117
+ SchematicSvgUtils.formatNumber(end.x) +
118
+ ' ' +
119
+ SchematicSvgUtils.formatNumber(end.y)
120
+ )
121
+ }
122
+
123
+ /**
124
+ * Projects one normalized PCB arc point into SVG coordinates.
125
+ * @param {{ x: number, y: number }} arc
126
+ * @param {number} angle
127
+ * @param {number} radius
128
+ * @returns {{ x: number, y: number }}
129
+ */
130
+ static #projectPoint(arc, angle, radius) {
131
+ const radians = (Number(angle || 0) * Math.PI) / 180
132
+
133
+ return {
134
+ x: Number(arc.x || 0) + radius * Math.cos(radians),
135
+ y: Number(arc.y || 0) + radius * Math.sin(radians)
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Returns true when one arc spans a full circle.
141
+ * @param {{ startAngle: number, endAngle: number }} arc
142
+ * @returns {boolean}
143
+ */
144
+ static #isFullCircle(arc) {
145
+ const rawDelta = Number(arc.endAngle || 0) - Number(arc.startAngle || 0)
146
+
147
+ return (
148
+ Math.abs(rawDelta) <= PcbArcUtils.#FULL_CIRCLE_EPSILON ||
149
+ Math.abs(rawDelta) >= 360 - PcbArcUtils.#FULL_CIRCLE_EPSILON
150
+ )
151
+ }
152
+
153
+ /**
154
+ * Normalizes one PCB arc delta to the intended short wrapped sweep.
155
+ * @param {number} startAngle
156
+ * @param {number} endAngle
157
+ * @returns {number}
158
+ */
159
+ static resolveSweepDelta(startAngle, endAngle) {
160
+ const rawDelta = Number(endAngle || 0) - Number(startAngle || 0)
161
+ let normalizedDelta = ((rawDelta + 540) % 360) - 180
162
+
163
+ if (
164
+ Math.abs(normalizedDelta + 180) <=
165
+ PcbArcUtils.#FULL_CIRCLE_EPSILON &&
166
+ rawDelta > 0
167
+ ) {
168
+ normalizedDelta = 180
169
+ }
170
+
171
+ return normalizedDelta
172
+ }
173
+
174
+ /**
175
+ * Resolves the short SVG sweep direction for one arc from its authored
176
+ * center and endpoint geometry.
177
+ * @param {{ x1?: number, y1?: number, x2?: number, y2?: number, cx?: number, cy?: number }} segment
178
+ * @returns {0 | 1}
179
+ */
180
+ static resolveShortSweepFromCenter(segment) {
181
+ const startDeltaX = Number(segment.x1 || 0) - Number(segment.cx || 0)
182
+ const startDeltaY = Number(segment.y1 || 0) - Number(segment.cy || 0)
183
+ const endDeltaX = Number(segment.x2 || 0) - Number(segment.cx || 0)
184
+ const endDeltaY = Number(segment.y2 || 0) - Number(segment.cy || 0)
185
+ const crossProduct = startDeltaX * endDeltaY - startDeltaY * endDeltaX
186
+
187
+ return crossProduct >= 0 ? 1 : 0
188
+ }
189
+ }