altium-toolkit 1.1.2 → 1.1.22

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 (57) hide show
  1. package/docs/api.md +37 -0
  2. package/docs/model-format.md +18 -0
  3. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +2 -2
  4. package/docs/testing.md +5 -0
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +5 -0
  7. package/src/core/altium/AltiumLayoutParser.mjs +275 -13
  8. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  9. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  10. package/src/core/altium/AltiumParser.mjs +245 -10
  11. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  12. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  13. package/src/core/altium/AsciiRecordParser.mjs +43 -11
  14. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  15. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  16. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  17. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  18. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  19. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  20. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  21. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  22. package/src/core/altium/SchematicImageParser.mjs +291 -6
  23. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  24. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  25. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  26. package/src/core/altium/SchematicPinParser.mjs +262 -24
  27. package/src/core/altium/SchematicPrimitiveParser.mjs +116 -8
  28. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  29. package/src/core/altium/SchematicStreamExtractor.mjs +62 -15
  30. package/src/core/altium/SchematicTextParser.mjs +125 -11
  31. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  32. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  33. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  34. package/src/core/altium/SourceComponentClient.mjs +239 -0
  35. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  36. package/src/parser.mjs +8 -0
  37. package/src/styles/altium-renderers.css +6 -6
  38. package/src/ui/PcbArcUtils.mjs +19 -2
  39. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  40. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  41. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  42. package/src/ui/SchematicColorResolver.mjs +263 -0
  43. package/src/ui/SchematicContentLayout.mjs +58 -1
  44. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  45. package/src/ui/SchematicImageRenderer.mjs +125 -10
  46. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  47. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  48. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  49. package/src/ui/SchematicNoteRenderer.mjs +87 -7
  50. package/src/ui/SchematicOwnerPinLabelLayout.mjs +560 -10
  51. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  52. package/src/ui/SchematicPinSvgRenderer.mjs +397 -48
  53. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  54. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  55. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  56. package/src/ui/SchematicShapeRenderer.mjs +109 -24
  57. package/src/ui/SchematicSvgRenderer.mjs +1210 -71
@@ -0,0 +1,449 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { OleConstants } from './OleConstants.mjs'
6
+
7
+ const sectorSize = 512
8
+ const directoryEntrySize = 128
9
+ const regularEntriesPerFatSector = sectorSize / 4
10
+ const rootEntryType = 5
11
+ const storageEntryType = 1
12
+ const streamEntryType = 2
13
+ const emptyEntryType = 0
14
+
15
+ /**
16
+ * Writes compact OLE Compound Document files for generated Altium libraries.
17
+ */
18
+ export class OleCompoundDocumentWriter {
19
+ /**
20
+ * Writes one compound document from a map of stream paths.
21
+ * @param {{ streams?: Map<string, Uint8Array> | [string, Uint8Array][] }} options Writer options.
22
+ * @returns {Uint8Array}
23
+ */
24
+ static write(options = {}) {
25
+ const streams = OleCompoundDocumentWriter.#normalizeStreams(
26
+ options.streams
27
+ )
28
+ const entries =
29
+ OleCompoundDocumentWriter.#buildDirectoryEntries(streams)
30
+ const directorySectorCount = Math.ceil(
31
+ (OleCompoundDocumentWriter.#paddedDirectoryEntries(entries).length *
32
+ directoryEntrySize) /
33
+ sectorSize
34
+ )
35
+ const streamSectorCounts = streams.map((stream) =>
36
+ Math.ceil(stream.bytes.byteLength / sectorSize)
37
+ )
38
+ const dataSectorCount =
39
+ directorySectorCount +
40
+ streamSectorCounts.reduce((sum, count) => sum + count, 0)
41
+ const fatSectorCount =
42
+ OleCompoundDocumentWriter.#resolveFatSectorCount(dataSectorCount)
43
+ const totalSectorCount = dataSectorCount + fatSectorCount
44
+ const bytes = new Uint8Array(
45
+ OleConstants.HEADER_BYTE_LENGTH + totalSectorCount * sectorSize
46
+ )
47
+ const dataView = new DataView(bytes.buffer)
48
+ const fatEntries = new Array(totalSectorCount).fill(
49
+ OleConstants.FREE_SECTOR
50
+ )
51
+ const fatSectorStart = dataSectorCount
52
+
53
+ let nextSector = directorySectorCount
54
+ streams.forEach((stream, index) => {
55
+ const sectorCount = streamSectorCounts[index]
56
+ stream.startSector = sectorCount
57
+ ? nextSector
58
+ : OleConstants.END_OF_CHAIN
59
+ nextSector += sectorCount
60
+ })
61
+
62
+ OleCompoundDocumentWriter.#writeHeader(
63
+ dataView,
64
+ fatSectorStart,
65
+ fatSectorCount
66
+ )
67
+ OleCompoundDocumentWriter.#writeDirectory(
68
+ bytes,
69
+ fatEntries,
70
+ entries,
71
+ directorySectorCount
72
+ )
73
+ OleCompoundDocumentWriter.#writeStreams(
74
+ bytes,
75
+ fatEntries,
76
+ streams,
77
+ streamSectorCounts
78
+ )
79
+ OleCompoundDocumentWriter.#writeFat(
80
+ bytes,
81
+ dataView,
82
+ fatEntries,
83
+ fatSectorStart,
84
+ fatSectorCount
85
+ )
86
+
87
+ return bytes
88
+ }
89
+
90
+ /**
91
+ * Normalizes caller-provided streams into sorted byte entries.
92
+ * @param {Map<string, Uint8Array> | [string, Uint8Array][] | undefined} streams Stream map.
93
+ * @returns {{ path: string, bytes: Uint8Array, startSector: number }[]}
94
+ */
95
+ static #normalizeStreams(streams) {
96
+ return [...new Map(streams || []).entries()]
97
+ .map(([path, bytes]) => ({
98
+ path: OleCompoundDocumentWriter.#normalizePath(path),
99
+ bytes: bytes instanceof Uint8Array ? bytes : new Uint8Array(0),
100
+ startSector: OleConstants.END_OF_CHAIN
101
+ }))
102
+ .filter((entry) => entry.path)
103
+ .sort((left, right) => left.path.localeCompare(right.path))
104
+ }
105
+
106
+ /**
107
+ * Normalizes one stream path.
108
+ * @param {any} path Stream path.
109
+ * @returns {string}
110
+ */
111
+ static #normalizePath(path) {
112
+ return String(path || '')
113
+ .split('/')
114
+ .map((part) => part.trim())
115
+ .filter(Boolean)
116
+ .join('/')
117
+ }
118
+
119
+ /**
120
+ * Builds directory entries and a simple right-sibling tree.
121
+ * @param {{ path: string, bytes: Uint8Array }[]} streams Stream entries.
122
+ * @returns {object[]}
123
+ */
124
+ static #buildDirectoryEntries(streams) {
125
+ const entries = [
126
+ OleCompoundDocumentWriter.#createDirectoryEntry('Root Entry', {
127
+ type: rootEntryType
128
+ })
129
+ ]
130
+ const childrenByParent = new Map([[0, []]])
131
+ const storageIdsByPath = new Map([['', 0]])
132
+
133
+ for (const stream of streams) {
134
+ const parts = stream.path.split('/')
135
+ let parentPath = ''
136
+ let parentId = 0
137
+
138
+ for (const part of parts.slice(0, -1)) {
139
+ const storagePath = parentPath ? parentPath + '/' + part : part
140
+ if (!storageIdsByPath.has(storagePath)) {
141
+ const storageId = entries.length
142
+ storageIdsByPath.set(storagePath, storageId)
143
+ entries.push(
144
+ OleCompoundDocumentWriter.#createDirectoryEntry(part, {
145
+ type: storageEntryType
146
+ })
147
+ )
148
+ OleCompoundDocumentWriter.#appendChild(
149
+ childrenByParent,
150
+ parentId,
151
+ storageId
152
+ )
153
+ }
154
+ parentPath = storagePath
155
+ parentId = storageIdsByPath.get(storagePath)
156
+ }
157
+
158
+ const streamId = entries.length
159
+ entries.push(
160
+ OleCompoundDocumentWriter.#createDirectoryEntry(parts.at(-1), {
161
+ type: streamEntryType,
162
+ stream
163
+ })
164
+ )
165
+ OleCompoundDocumentWriter.#appendChild(
166
+ childrenByParent,
167
+ parentId,
168
+ streamId
169
+ )
170
+ }
171
+
172
+ OleCompoundDocumentWriter.#linkSiblingTrees(entries, childrenByParent)
173
+
174
+ return entries
175
+ }
176
+
177
+ /**
178
+ * Creates one mutable directory entry.
179
+ * @param {string} name Entry name.
180
+ * @param {{ type: number, stream?: object }} options Entry options.
181
+ * @returns {object}
182
+ */
183
+ static #createDirectoryEntry(name, options) {
184
+ return {
185
+ name,
186
+ type: options.type,
187
+ leftSiblingId: OleConstants.NO_STREAM,
188
+ rightSiblingId: OleConstants.NO_STREAM,
189
+ childId: OleConstants.NO_STREAM,
190
+ stream: options.stream || null
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Appends a child entry id to the parent lookup table.
196
+ * @param {Map<number, number[]>} childrenByParent Child lookup.
197
+ * @param {number} parentId Parent entry id.
198
+ * @param {number} childId Child entry id.
199
+ * @returns {void}
200
+ */
201
+ static #appendChild(childrenByParent, parentId, childId) {
202
+ if (!childrenByParent.has(parentId)) {
203
+ childrenByParent.set(parentId, [])
204
+ }
205
+ childrenByParent.get(parentId).push(childId)
206
+ childrenByParent.set(childId, [])
207
+ }
208
+
209
+ /**
210
+ * Links child lists into deterministic right-sibling chains.
211
+ * @param {object[]} entries Directory entries.
212
+ * @param {Map<number, number[]>} childrenByParent Child lookup.
213
+ * @returns {void}
214
+ */
215
+ static #linkSiblingTrees(entries, childrenByParent) {
216
+ for (const [parentId, childIds] of childrenByParent.entries()) {
217
+ const sortedChildIds = [...childIds].sort((left, right) =>
218
+ entries[left].name.localeCompare(entries[right].name)
219
+ )
220
+ if (!sortedChildIds.length) {
221
+ continue
222
+ }
223
+ entries[parentId].childId = sortedChildIds[0]
224
+ sortedChildIds.slice(0, -1).forEach((childId, index) => {
225
+ entries[childId].rightSiblingId = sortedChildIds[index + 1]
226
+ })
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Pads directory entries to a full sector boundary.
232
+ * @param {object[]} entries Directory entries.
233
+ * @returns {object[]}
234
+ */
235
+ static #paddedDirectoryEntries(entries) {
236
+ const padded = [...entries]
237
+ while ((padded.length * directoryEntrySize) % sectorSize !== 0) {
238
+ padded.push(
239
+ OleCompoundDocumentWriter.#createDirectoryEntry('', {
240
+ type: emptyEntryType
241
+ })
242
+ )
243
+ }
244
+ return padded
245
+ }
246
+
247
+ /**
248
+ * Resolves the number of FAT sectors required by this file.
249
+ * @param {number} dataSectorCount Non-FAT sector count.
250
+ * @returns {number}
251
+ */
252
+ static #resolveFatSectorCount(dataSectorCount) {
253
+ let fatSectorCount = 1
254
+ while (
255
+ dataSectorCount + fatSectorCount >
256
+ fatSectorCount * regularEntriesPerFatSector
257
+ ) {
258
+ fatSectorCount += 1
259
+ }
260
+ return fatSectorCount
261
+ }
262
+
263
+ /**
264
+ * Writes the OLE header.
265
+ * @param {DataView} dataView Output view.
266
+ * @param {number} fatSectorStart First FAT sector id.
267
+ * @param {number} fatSectorCount FAT sector count.
268
+ * @returns {void}
269
+ */
270
+ static #writeHeader(dataView, fatSectorStart, fatSectorCount) {
271
+ OleConstants.HEADER_SIGNATURE.forEach((value, index) => {
272
+ dataView.setUint8(index, value)
273
+ })
274
+ dataView.setUint16(24, 0x003e, true)
275
+ dataView.setUint16(26, 0x0003, true)
276
+ dataView.setUint16(28, 0xfffe, true)
277
+ dataView.setUint16(30, 9, true)
278
+ dataView.setUint16(32, 6, true)
279
+ dataView.setUint32(40, 0, true)
280
+ dataView.setUint32(44, fatSectorCount, true)
281
+ dataView.setInt32(48, 0, true)
282
+ dataView.setUint32(56, 0, true)
283
+ dataView.setInt32(60, OleConstants.END_OF_CHAIN, true)
284
+ dataView.setUint32(64, 0, true)
285
+ dataView.setInt32(68, OleConstants.END_OF_CHAIN, true)
286
+ dataView.setUint32(72, 0, true)
287
+ for (let index = 0; index < 109; index += 1) {
288
+ dataView.setInt32(76 + index * 4, OleConstants.FREE_SECTOR, true)
289
+ }
290
+ for (let index = 0; index < fatSectorCount; index += 1) {
291
+ dataView.setInt32(76 + index * 4, fatSectorStart + index, true)
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Writes the directory stream.
297
+ * @param {Uint8Array} bytes Output bytes.
298
+ * @param {number[]} fatEntries FAT entries.
299
+ * @param {object[]} entries Directory entries.
300
+ * @param {number} directorySectorCount Directory sector count.
301
+ * @returns {void}
302
+ */
303
+ static #writeDirectory(bytes, fatEntries, entries, directorySectorCount) {
304
+ OleCompoundDocumentWriter.#linkFatChain(
305
+ fatEntries,
306
+ 0,
307
+ directorySectorCount
308
+ )
309
+ OleCompoundDocumentWriter.#paddedDirectoryEntries(entries).forEach(
310
+ (entry, index) => {
311
+ OleCompoundDocumentWriter.#writeDirectoryEntry(
312
+ bytes,
313
+ index,
314
+ entry
315
+ )
316
+ }
317
+ )
318
+ }
319
+
320
+ /**
321
+ * Writes all regular stream payloads.
322
+ * @param {Uint8Array} bytes Output bytes.
323
+ * @param {number[]} fatEntries FAT entries.
324
+ * @param {object[]} streams Stream entries.
325
+ * @param {number[]} streamSectorCounts Sector counts.
326
+ * @returns {void}
327
+ */
328
+ static #writeStreams(bytes, fatEntries, streams, streamSectorCounts) {
329
+ streams.forEach((stream, index) => {
330
+ const sectorCount = streamSectorCounts[index]
331
+ if (!sectorCount) {
332
+ return
333
+ }
334
+ OleCompoundDocumentWriter.#linkFatChain(
335
+ fatEntries,
336
+ stream.startSector,
337
+ sectorCount
338
+ )
339
+ bytes.set(
340
+ stream.bytes,
341
+ OleConstants.HEADER_BYTE_LENGTH +
342
+ stream.startSector * sectorSize
343
+ )
344
+ })
345
+ }
346
+
347
+ /**
348
+ * Links a FAT sector chain.
349
+ * @param {number[]} fatEntries FAT entries.
350
+ * @param {number} startSector Start sector.
351
+ * @param {number} sectorCount Sector count.
352
+ * @returns {void}
353
+ */
354
+ static #linkFatChain(fatEntries, startSector, sectorCount) {
355
+ for (let offset = 0; offset < sectorCount; offset += 1) {
356
+ fatEntries[startSector + offset] =
357
+ offset === sectorCount - 1
358
+ ? OleConstants.END_OF_CHAIN
359
+ : startSector + offset + 1
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Writes FAT sectors.
365
+ * @param {Uint8Array} bytes Output bytes.
366
+ * @param {DataView} dataView Output view.
367
+ * @param {number[]} fatEntries FAT entries.
368
+ * @param {number} fatSectorStart First FAT sector id.
369
+ * @param {number} fatSectorCount FAT sector count.
370
+ * @returns {void}
371
+ */
372
+ static #writeFat(
373
+ bytes,
374
+ dataView,
375
+ fatEntries,
376
+ fatSectorStart,
377
+ fatSectorCount
378
+ ) {
379
+ for (let index = 0; index < fatSectorCount; index += 1) {
380
+ fatEntries[fatSectorStart + index] = OleConstants.FAT_SECTOR
381
+ }
382
+ for (
383
+ let index = 0;
384
+ index < fatSectorCount * regularEntriesPerFatSector;
385
+ index += 1
386
+ ) {
387
+ dataView.setInt32(
388
+ OleConstants.HEADER_BYTE_LENGTH +
389
+ fatSectorStart * sectorSize +
390
+ index * 4,
391
+ fatEntries[index] ?? OleConstants.FREE_SECTOR,
392
+ true
393
+ )
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Writes one directory entry.
399
+ * @param {Uint8Array} bytes Output bytes.
400
+ * @param {number} index Directory entry index.
401
+ * @param {object} entry Directory entry.
402
+ * @returns {void}
403
+ */
404
+ static #writeDirectoryEntry(bytes, index, entry) {
405
+ const offset =
406
+ OleConstants.HEADER_BYTE_LENGTH + index * directoryEntrySize
407
+ const dataView = new DataView(bytes.buffer)
408
+ const encodedName = OleCompoundDocumentWriter.#encodeDirectoryName(
409
+ entry.name
410
+ )
411
+ const nameByteLength = Math.min(encodedName.byteLength, 64)
412
+
413
+ bytes.set(encodedName.slice(0, nameByteLength), offset)
414
+ dataView.setUint16(offset + 64, nameByteLength, true)
415
+ dataView.setUint8(offset + 66, entry.type)
416
+ dataView.setUint8(offset + 67, 1)
417
+ dataView.setInt32(offset + 68, entry.leftSiblingId, true)
418
+ dataView.setInt32(offset + 72, entry.rightSiblingId, true)
419
+ dataView.setInt32(offset + 76, entry.childId, true)
420
+ dataView.setInt32(
421
+ offset + 116,
422
+ entry.stream?.startSector ?? OleConstants.END_OF_CHAIN,
423
+ true
424
+ )
425
+ dataView.setUint32(
426
+ offset + 120,
427
+ entry.stream?.bytes?.byteLength || 0,
428
+ true
429
+ )
430
+ dataView.setUint32(offset + 124, 0, true)
431
+ }
432
+
433
+ /**
434
+ * Encodes a directory name as UTF-16LE with a null terminator.
435
+ * @param {string} name Directory entry name.
436
+ * @returns {Uint8Array}
437
+ */
438
+ static #encodeDirectoryName(name) {
439
+ const characters = Array.from(String(name || '')).slice(0, 31)
440
+ const bytes = new Uint8Array((characters.length + 1) * 2)
441
+ const dataView = new DataView(bytes.buffer)
442
+
443
+ characters.concat('\u0000').forEach((character, index) => {
444
+ dataView.setUint16(index * 2, character.charCodeAt(0), true)
445
+ })
446
+
447
+ return bytes
448
+ }
449
+ }
package/src/parser.mjs CHANGED
@@ -6,6 +6,14 @@ export { BinaryReader } from './core/BinaryReader.mjs'
6
6
  export { OleCompoundDocument } from './core/ole/OleCompoundDocument.mjs'
7
7
  export { OleConstants } from './core/ole/OleConstants.mjs'
8
8
  export { OleDirectoryEntry } from './core/ole/OleDirectoryEntry.mjs'
9
+ export { OleCompoundDocumentWriter } from './core/ole/OleCompoundDocumentWriter.mjs'
10
+ export { AltiumLibraryBatchExporter } from './core/altium/AltiumLibraryBatchExporter.mjs'
11
+ export { AltiumLibraryRecordBuilder } from './core/altium/AltiumLibraryRecordBuilder.mjs'
12
+ export { AltiumPcbLibExporter } from './core/altium/AltiumPcbLibExporter.mjs'
13
+ export { AltiumSchLibExporter } from './core/altium/AltiumSchLibExporter.mjs'
14
+ export { SourceBundleExporter } from './core/altium/SourceBundleExporter.mjs'
15
+ export { SourceComponentBundleNormalizer } from './core/altium/SourceComponentBundleNormalizer.mjs'
16
+ export { SourceComponentClient } from './core/altium/SourceComponentClient.mjs'
9
17
  export { AltiumParser } from './core/altium/AltiumParser.mjs'
10
18
  export { AltiumLayoutParser } from './core/altium/AltiumLayoutParser.mjs'
11
19
  export { AsciiRecordParser } from './core/altium/AsciiRecordParser.mjs'
@@ -56,14 +56,14 @@
56
56
  }
57
57
 
58
58
  .schematic-svg {
59
- --schematic-default-ink-color: #0091ac;
60
- --schematic-accent-ink-color: #14c5e6;
59
+ --schematic-default-ink-color: #008aa3;
60
+ --schematic-accent-ink-color: #009bb2;
61
61
  --schematic-text-color: #121b22;
62
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;
63
+ --schematic-power-color: #a44a1b;
64
+ --schematic-port-color: #a44a1b;
65
+ --schematic-alert-color: #c43a68;
66
+ --schematic-fill-color: #f1d8bd;
67
67
  --schematic-note-fill-color: #efe4d1;
68
68
  --schematic-fill-light-color: #fffaf5;
69
69
  --schematic-pin-marker-fill: #edf4f3;
@@ -10,6 +10,8 @@ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
10
10
  export class PcbArcUtils {
11
11
  static #FULL_CIRCLE_EPSILON = 0.001
12
12
 
13
+ static #CROSS_PRODUCT_EPSILON = 1e-9
14
+
13
15
  /**
14
16
  * Builds one PCB arc or authored circle as SVG markup.
15
17
  * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number }} arc
@@ -174,7 +176,7 @@ export class PcbArcUtils {
174
176
  /**
175
177
  * Resolves the short SVG sweep direction for one arc from its authored
176
178
  * center and endpoint geometry.
177
- * @param {{ x1?: number, y1?: number, x2?: number, y2?: number, cx?: number, cy?: number }} segment
179
+ * @param {{ x1?: number, y1?: number, x2?: number, y2?: number, cx?: number, cy?: number, startAngle?: number, endAngle?: number }} segment
178
180
  * @returns {0 | 1}
179
181
  */
180
182
  static resolveShortSweepFromCenter(segment) {
@@ -184,6 +186,21 @@ export class PcbArcUtils {
184
186
  const endDeltaY = Number(segment.y2 || 0) - Number(segment.cy || 0)
185
187
  const crossProduct = startDeltaX * endDeltaY - startDeltaY * endDeltaX
186
188
 
187
- return crossProduct >= 0 ? 1 : 0
189
+ if (Math.abs(crossProduct) > PcbArcUtils.#CROSS_PRODUCT_EPSILON) {
190
+ return crossProduct > 0 ? 1 : 0
191
+ }
192
+
193
+ const startAngle = Number(segment.startAngle)
194
+ const endAngle = Number(segment.endAngle)
195
+ if (Number.isFinite(startAngle) && Number.isFinite(endAngle)) {
196
+ // Board-outline segment angles come from board-space arc records;
197
+ // after the endpoints are in SVG space, exact half-circles need
198
+ // the opposite SVG sweep to select the authored side.
199
+ return PcbArcUtils.resolveSweepDelta(startAngle, endAngle) >= 0
200
+ ? 0
201
+ : 1
202
+ }
203
+
204
+ return 1
188
205
  }
189
206
  }