altium-toolkit 1.1.3 → 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 (39) 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/AltiumLibraryBatchExporter.mjs +206 -0
  8. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  9. package/src/core/altium/AltiumParser.mjs +5 -2
  10. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  11. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  12. package/src/core/altium/AsciiRecordParser.mjs +43 -11
  13. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  14. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  15. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  16. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  17. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  18. package/src/core/altium/SchematicPinParser.mjs +87 -20
  19. package/src/core/altium/SchematicPrimitiveParser.mjs +116 -8
  20. package/src/core/altium/SchematicStreamExtractor.mjs +62 -15
  21. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  22. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  23. package/src/core/altium/SourceComponentClient.mjs +239 -0
  24. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  25. package/src/parser.mjs +8 -0
  26. package/src/styles/altium-renderers.css +6 -6
  27. package/src/ui/PcbArcUtils.mjs +19 -2
  28. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  29. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  30. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  31. package/src/ui/SchematicColorResolver.mjs +185 -0
  32. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  33. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  34. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  35. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  36. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  37. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  38. package/src/ui/SchematicShapeRenderer.mjs +37 -11
  39. package/src/ui/SchematicSvgRenderer.mjs +944 -51
@@ -31,6 +31,7 @@ export class SchematicColorResolver {
31
31
  * Resolves one SVG color value to a schematic theme variable.
32
32
  * @param {string | undefined} color
33
33
  * @param {string} fallbackVariable
34
+ * @param {boolean} [preserveUnknown]
34
35
  * @returns {string}
35
36
  */
36
37
  static resolveColor(color, fallbackVariable, preserveUnknown = false) {
@@ -88,6 +89,7 @@ export class SchematicColorResolver {
88
89
  * Resolves one SVG fill value to a schematic theme variable.
89
90
  * @param {string | undefined} fill
90
91
  * @param {string} fallbackVariable
92
+ * @param {boolean} [preserveUnknown]
91
93
  * @returns {string}
92
94
  */
93
95
  static resolveFill(fill, fallbackVariable, preserveUnknown = false) {
@@ -153,6 +155,32 @@ export class SchematicColorResolver {
153
155
  )
154
156
  }
155
157
 
158
+ /**
159
+ * Resolves an authored source stroke through a muted source palette.
160
+ * @param {string | undefined} color
161
+ * @param {string} fallbackVariable
162
+ * @returns {string}
163
+ */
164
+ static resolveMutedSourceColor(color, fallbackVariable) {
165
+ return SchematicColorResolver.#resolveMutedSourcePaint(
166
+ color,
167
+ fallbackVariable
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Resolves an authored source fill through a muted source palette.
173
+ * @param {string | undefined} fill
174
+ * @param {string} fallbackVariable
175
+ * @returns {string}
176
+ */
177
+ static resolveMutedSourceFill(fill, fallbackVariable) {
178
+ return SchematicColorResolver.#resolveMutedSourcePaint(
179
+ fill,
180
+ fallbackVariable
181
+ )
182
+ }
183
+
156
184
  /**
157
185
  * Resolves one literal source paint value while preserving SVG control
158
186
  * values and falling back only when no source paint exists.
@@ -178,6 +206,42 @@ export class SchematicColorResolver {
178
206
  return normalized
179
207
  }
180
208
 
209
+ /**
210
+ * Resolves one literal source paint while reducing vivid palette colors.
211
+ * @param {string | undefined} paint
212
+ * @param {string} fallbackVariable
213
+ * @returns {string}
214
+ */
215
+ static #resolveMutedSourcePaint(paint, fallbackVariable) {
216
+ const normalized = SchematicColorResolver.#normalizeColor(paint)
217
+
218
+ if (!normalized) {
219
+ return SchematicColorResolver.#toVariable(fallbackVariable)
220
+ }
221
+
222
+ if (
223
+ normalized === 'none' ||
224
+ normalized === 'transparent' ||
225
+ normalized.startsWith('var(')
226
+ ) {
227
+ return normalized
228
+ }
229
+
230
+ const rgb = SchematicColorResolver.#parseHexColor(normalized)
231
+ if (!rgb) {
232
+ return normalized
233
+ }
234
+
235
+ const hsl = SchematicColorResolver.#rgbToHsl(rgb)
236
+ const mutedRgb = SchematicColorResolver.#hslToRgb({
237
+ h: hsl.h,
238
+ s: Math.min(hsl.s, 45),
239
+ l: Math.min(Math.max(hsl.l, 30), 68)
240
+ })
241
+
242
+ return SchematicColorResolver.#formatHexColor(mutedRgb)
243
+ }
244
+
181
245
  /**
182
246
  * Normalizes one raw color string for token lookup.
183
247
  * @param {string | undefined} color
@@ -189,6 +253,127 @@ export class SchematicColorResolver {
189
253
  .toLowerCase()
190
254
  }
191
255
 
256
+ /**
257
+ * Parses a six-digit hex color.
258
+ * @param {string} color
259
+ * @returns {{ r: number, g: number, b: number } | null}
260
+ */
261
+ static #parseHexColor(color) {
262
+ const match = /^#([0-9a-f]{6})$/u.exec(color)
263
+ if (!match) {
264
+ return null
265
+ }
266
+
267
+ const value = match[1]
268
+ return {
269
+ r: Number.parseInt(value.slice(0, 2), 16),
270
+ g: Number.parseInt(value.slice(2, 4), 16),
271
+ b: Number.parseInt(value.slice(4, 6), 16)
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Converts RGB channels to HSL.
277
+ * @param {{ r: number, g: number, b: number }} color
278
+ * @returns {{ h: number, s: number, l: number }}
279
+ */
280
+ static #rgbToHsl(color) {
281
+ const r = color.r / 255
282
+ const g = color.g / 255
283
+ const b = color.b / 255
284
+ const max = Math.max(r, g, b)
285
+ const min = Math.min(r, g, b)
286
+ let h = 0
287
+ let s = 0
288
+ const l = ((max + min) / 2) * 100
289
+
290
+ if (max !== min) {
291
+ const delta = max - min
292
+ s =
293
+ ((max + min) / 2 > 0.5
294
+ ? delta / (2 - max - min)
295
+ : delta / (max + min)) * 100
296
+
297
+ switch (max) {
298
+ case r:
299
+ h = (g - b) / delta + (g < b ? 6 : 0)
300
+ break
301
+ case g:
302
+ h = (b - r) / delta + 2
303
+ break
304
+ default:
305
+ h = (r - g) / delta + 4
306
+ break
307
+ }
308
+
309
+ h /= 6
310
+ }
311
+
312
+ return { h, s, l }
313
+ }
314
+
315
+ /**
316
+ * Converts HSL channels to RGB.
317
+ * @param {{ h: number, s: number, l: number }} color
318
+ * @returns {{ r: number, g: number, b: number }}
319
+ */
320
+ static #hslToRgb(color) {
321
+ const s = color.s / 100
322
+ const l = color.l / 100
323
+
324
+ if (s === 0) {
325
+ const value = Math.round(l * 255)
326
+ return { r: value, g: value, b: value }
327
+ }
328
+
329
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s
330
+ const p = 2 * l - q
331
+ const r = SchematicColorResolver.#hueToRgb(p, q, color.h + 1 / 3)
332
+ const g = SchematicColorResolver.#hueToRgb(p, q, color.h)
333
+ const b = SchematicColorResolver.#hueToRgb(p, q, color.h - 1 / 3)
334
+
335
+ return {
336
+ r: Math.round(r * 255),
337
+ g: Math.round(g * 255),
338
+ b: Math.round(b * 255)
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Converts one hue channel to RGB.
344
+ * @param {number} p
345
+ * @param {number} q
346
+ * @param {number} t
347
+ * @returns {number}
348
+ */
349
+ static #hueToRgb(p, q, t) {
350
+ let value = t
351
+ if (value < 0) value += 1
352
+ if (value > 1) value -= 1
353
+ if (value < 1 / 6) return p + (q - p) * 6 * value
354
+ if (value < 1 / 2) return q
355
+ if (value < 2 / 3) return p + (q - p) * (2 / 3 - value) * 6
356
+ return p
357
+ }
358
+
359
+ /**
360
+ * Formats RGB channels as a hex color.
361
+ * @param {{ r: number, g: number, b: number }} color
362
+ * @returns {string}
363
+ */
364
+ static #formatHexColor(color) {
365
+ return (
366
+ '#' +
367
+ [color.r, color.g, color.b]
368
+ .map((channel) =>
369
+ Math.max(0, Math.min(255, channel))
370
+ .toString(16)
371
+ .padStart(2, '0')
372
+ )
373
+ .join('')
374
+ )
375
+ }
376
+
192
377
  /**
193
378
  * Wraps one CSS custom property name in `var(...)` markup.
194
379
  * @param {string} variableName
@@ -50,32 +50,45 @@ export class SchematicDirectiveRenderer {
50
50
  return SchematicDirectiveRenderer.#buildRouteMarkup(
51
51
  directive,
52
52
  sheetHeight,
53
- sheet
53
+ sheet,
54
+ 'route'
54
55
  )
55
56
  default:
56
- return ''
57
+ return directive?.name
58
+ ? SchematicDirectiveRenderer.#buildRouteMarkup(
59
+ directive,
60
+ sheetHeight,
61
+ sheet,
62
+ 'parameter-set'
63
+ )
64
+ : ''
57
65
  }
58
66
  }
59
67
 
60
68
  /**
61
- * Builds the labeled route-callout marker for one differential-pair
62
- * routing directive.
63
- * @param {{ x: number, y: number, color: string, orientation?: number }} directive
69
+ * Builds the labeled info-callout marker for one parameter-set directive.
70
+ * @param {{ x: number, y: number, color: string, name: string, orientation?: number }} directive
64
71
  * @param {number} sheetHeight
65
72
  * @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
73
+ * @param {string} classModifier
66
74
  * @returns {string}
67
75
  */
68
- static #buildRouteMarkup(directive, sheetHeight, sheet) {
76
+ static #buildRouteMarkup(directive, sheetHeight, sheet, classModifier) {
69
77
  const color = SchematicColorResolver.resolveColor(
70
78
  directive.color,
71
79
  '--schematic-alert-color'
72
80
  )
73
81
  const projectedY = projectSchematicY(sheetHeight, directive.y)
74
- const verticalDirection =
75
- Number(directive.orientation || 0) === 3 ? 1 : -1
82
+ const direction = SchematicDirectiveRenderer.#resolveCalloutDirection(
83
+ directive.orientation
84
+ )
76
85
  const circleRadius = 7
77
- const circleCenterY = projectedY + verticalDirection * 18
78
- const leaderEndY = circleCenterY - verticalDirection * circleRadius
86
+ const calloutDistance =
87
+ SchematicDirectiveRenderer.#resolveCalloutDistance(direction)
88
+ const circleCenterX = directive.x + direction.x * calloutDistance
89
+ const circleCenterY = projectedY + direction.y * calloutDistance
90
+ const leaderEndX = circleCenterX - direction.x * circleRadius
91
+ const leaderEndY = circleCenterY - direction.y * circleRadius
79
92
  const labelOptions =
80
93
  SchematicTypography.buildViewerSchematicFontOptions(sheet)
81
94
  const infoOptions = {
@@ -83,26 +96,32 @@ export class SchematicDirectiveRenderer {
83
96
  fontSize: Math.max(Number(labelOptions.fontSize || 9) - 1, 6),
84
97
  fontWeight: 700
85
98
  }
86
- const labelY =
87
- circleCenterY +
88
- verticalDirection *
89
- (circleRadius + Number(labelOptions.fontSize || 9))
99
+ const labelPlacement =
100
+ SchematicDirectiveRenderer.#resolveLabelPlacement(
101
+ circleCenterX,
102
+ circleCenterY,
103
+ direction,
104
+ circleRadius,
105
+ Number(labelOptions.fontSize || 9)
106
+ )
90
107
 
91
108
  return (
92
- '<g class="schematic-directive schematic-directive--route">' +
109
+ '<g class="schematic-directive schematic-directive--' +
110
+ escapeHtml(classModifier) +
111
+ '">' +
93
112
  '<line x1="' +
94
113
  formatNumber(directive.x) +
95
114
  '" y1="' +
96
115
  formatNumber(projectedY) +
97
116
  '" x2="' +
98
- formatNumber(directive.x) +
117
+ formatNumber(leaderEndX) +
99
118
  '" y2="' +
100
119
  formatNumber(leaderEndY) +
101
120
  '" stroke="' +
102
121
  escapeHtml(color) +
103
122
  '" stroke-width="1" />' +
104
123
  '<circle cx="' +
105
- formatNumber(directive.x) +
124
+ formatNumber(circleCenterX) +
106
125
  '" cy="' +
107
126
  formatNumber(circleCenterY) +
108
127
  '" r="' +
@@ -112,17 +131,20 @@ export class SchematicDirectiveRenderer {
112
131
  '" stroke-width="1" />' +
113
132
  createSvgText(
114
133
  'schematic-directive-label',
115
- directive.x,
116
- labelY,
134
+ labelPlacement.x,
135
+ labelPlacement.y,
117
136
  String(directive.name || ''),
118
137
  color,
119
- 'middle',
138
+ labelPlacement.anchor,
120
139
  labelOptions
121
140
  ) +
122
141
  createSvgText(
123
142
  'schematic-directive-info',
124
- directive.x,
125
- circleCenterY + Number(infoOptions.fontSize || 8) * 0.34,
143
+ circleCenterX,
144
+ circleCenterY +
145
+ SchematicDirectiveRenderer.#baselineOffset(
146
+ Number(infoOptions.fontSize || 8)
147
+ ),
126
148
  'i',
127
149
  color,
128
150
  'middle',
@@ -132,6 +154,95 @@ export class SchematicDirectiveRenderer {
132
154
  )
133
155
  }
134
156
 
157
+ /**
158
+ * Resolves Altium's four-way callout orientation into an outward vector.
159
+ * @param {number | undefined} orientation
160
+ * @returns {{ x: number, y: number }}
161
+ */
162
+ static #resolveCalloutDirection(orientation) {
163
+ switch (Number(orientation || 0)) {
164
+ case 1:
165
+ return { x: 0, y: -1 }
166
+ case 2:
167
+ return { x: -1, y: 0 }
168
+ case 3:
169
+ return { x: 0, y: 1 }
170
+ default:
171
+ return { x: 1, y: 0 }
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Resolves the distance from a directive anchor to its info marker center.
177
+ * @param {{ x: number, y: number }} direction
178
+ * @returns {number}
179
+ */
180
+ static #resolveCalloutDistance(direction) {
181
+ return direction.y < 0 ? 12 : 18
182
+ }
183
+
184
+ /**
185
+ * Resolves label placement outside one directive info marker.
186
+ * @param {number} circleCenterX
187
+ * @param {number} circleCenterY
188
+ * @param {{ x: number, y: number }} direction
189
+ * @param {number} circleRadius
190
+ * @param {number} fontSize
191
+ * @returns {{ x: number, y: number, anchor: 'start' | 'middle' | 'end' }}
192
+ */
193
+ static #resolveLabelPlacement(
194
+ circleCenterX,
195
+ circleCenterY,
196
+ direction,
197
+ circleRadius,
198
+ fontSize
199
+ ) {
200
+ const labelDistance = circleRadius + fontSize
201
+
202
+ if (direction.x < 0) {
203
+ return {
204
+ x: circleCenterX - labelDistance,
205
+ y:
206
+ circleCenterY +
207
+ SchematicDirectiveRenderer.#baselineOffset(fontSize),
208
+ anchor: 'end'
209
+ }
210
+ }
211
+
212
+ if (direction.x > 0) {
213
+ return {
214
+ x: circleCenterX + labelDistance,
215
+ y:
216
+ circleCenterY +
217
+ SchematicDirectiveRenderer.#baselineOffset(fontSize),
218
+ anchor: 'start'
219
+ }
220
+ }
221
+
222
+ if (direction.y < 0) {
223
+ return {
224
+ x: circleCenterX,
225
+ y: circleCenterY - circleRadius - 2,
226
+ anchor: 'middle'
227
+ }
228
+ }
229
+
230
+ return {
231
+ x: circleCenterX,
232
+ y: circleCenterY + direction.y * labelDistance,
233
+ anchor: 'middle'
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Returns a baseline offset that visually centers text around a marker.
239
+ * @param {number} fontSize
240
+ * @returns {number}
241
+ */
242
+ static #baselineOffset(fontSize) {
243
+ return fontSize * 0.34
244
+ }
245
+
135
246
  /**
136
247
  * Builds the paired-trace differential-pair marker glyph.
137
248
  * @param {{ x: number, y: number, color: string }} directive
@@ -0,0 +1,88 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SchematicColorResolver } from './SchematicColorResolver.mjs'
6
+
7
+ /**
8
+ * Resolves schematic line stroke colors from normalized line metadata.
9
+ */
10
+ export class SchematicLineColorResolver {
11
+ /**
12
+ * Resolves one schematic line stroke color.
13
+ * @param {{ color?: string, ownerIndex?: string, isBus?: boolean, recordType?: string, x1?: number, x2?: number }} line Line primitive.
14
+ * @returns {string}
15
+ */
16
+ static resolveColor(line) {
17
+ if (SchematicLineColorResolver.isElectricalLine(line)) {
18
+ return SchematicColorResolver.resolveNonTextColor(
19
+ line?.color,
20
+ '--schematic-default-ink-color'
21
+ )
22
+ }
23
+
24
+ if (SchematicLineColorResolver.#isOwnerVerticalLine(line)) {
25
+ const resolvedColor = SchematicColorResolver.resolveNonTextColor(
26
+ line?.color,
27
+ '--schematic-default-ink-color',
28
+ true
29
+ )
30
+ return resolvedColor.startsWith('var(')
31
+ ? resolvedColor
32
+ : SchematicColorResolver.resolveMutedSourceColor(
33
+ line?.color,
34
+ '--schematic-default-ink-color'
35
+ )
36
+ }
37
+
38
+ return SchematicColorResolver.resolveColor(
39
+ line?.color,
40
+ '--schematic-default-ink-color'
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Returns true when one normalized line can carry schematic net color.
46
+ * @param {{ ownerIndex?: string, isBus?: boolean, recordType?: string } | null | undefined} line Line primitive.
47
+ * @returns {boolean}
48
+ */
49
+ static isElectricalLine(line) {
50
+ if (line?.ownerIndex || line?.isBus === true) {
51
+ return false
52
+ }
53
+
54
+ if (!Object.prototype.hasOwnProperty.call(line || {}, 'recordType')) {
55
+ return true
56
+ }
57
+
58
+ return !['6', '7', '26'].includes(String(line.recordType || ''))
59
+ }
60
+
61
+ /**
62
+ * Returns true when owner geometry encodes a vertical side rail as a line.
63
+ * @param {{ ownerIndex?: string, x1?: number, x2?: number } | null | undefined} line Line primitive.
64
+ * @returns {boolean}
65
+ */
66
+ static #isOwnerVerticalLine(line) {
67
+ return (
68
+ Boolean(String(line?.ownerIndex || '').trim()) &&
69
+ SchematicLineColorResolver.#isVerticalLine(line)
70
+ )
71
+ }
72
+
73
+ /**
74
+ * Returns true when one line is vertical within parser coordinate tolerance.
75
+ * @param {{ x1?: number, x2?: number } | null | undefined} line Line primitive.
76
+ * @returns {boolean}
77
+ */
78
+ static #isVerticalLine(line) {
79
+ const x1 = Number(line?.x1)
80
+ const x2 = Number(line?.x2)
81
+
82
+ return (
83
+ Number.isFinite(x1) &&
84
+ Number.isFinite(x2) &&
85
+ Math.abs(x1 - x2) <= 0.001
86
+ )
87
+ }
88
+ }
@@ -8,6 +8,7 @@ import { SchematicTypography } from './SchematicTypography.mjs'
8
8
 
9
9
  const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
10
10
  const MINIMUM_NOTE_TEXT_SIZE = 4
11
+ const COMPACT_SINGLE_LINE_HEIGHT_RATIO = 1.6
11
12
 
12
13
  /**
13
14
  * Renders boxed schematic notes recovered from Altium note records.
@@ -328,7 +329,10 @@ export class SchematicNoteRenderer {
328
329
  String(line || '').trim()
329
330
  ).length
330
331
 
331
- return visibleLineCount === 1 && height <= requestedTextSize * 1.5
332
+ return (
333
+ visibleLineCount === 1 &&
334
+ height <= requestedTextSize * COMPACT_SINGLE_LINE_HEIGHT_RATIO
335
+ )
332
336
  }
333
337
 
334
338
  /**