altium-toolkit 1.4.4 → 1.4.6

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.
@@ -0,0 +1,23 @@
1
+ # altium-toolkit 1.4.6
2
+
3
+ Version 1.4.6 preserves anisotropic ROUND/CIRCLE SMT pad dimensions when
4
+ converting Altium renderer data to Circuit JSON.
5
+
6
+ ## Anisotropic ROUND/CIRCLE SMT projection
7
+
8
+ - Unequal positive dimensions become dimension-preserving `pill` geometry, or
9
+ `rotated_pill` when the authored rotation is meaningful.
10
+ - Zero-hole SMT filtering and stable source ordering keep through-hole and
11
+ unrelated pads unchanged.
12
+ - Equal-diameter ROUND/CIRCLE pads retain their existing circle geometry.
13
+
14
+ ## Verification
15
+
16
+ - Public `Parser.parse()` regression coverage exercises schematic and PCB-only models,
17
+ mixed SMT/through-hole order, rotation tolerance, and metadata preservation.
18
+ - The complete test suite, performance check, feature-preservation check,
19
+ formatting check, and npm publish dry run passed for this release metadata.
20
+
21
+ ## Contributor
22
+
23
+ Thanks to Ahmed Alshaybani for the original anisotropic pad projection work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -0,0 +1,84 @@
1
+ # Anisotropic Round SMT Pad Projection Design
2
+
3
+ ## Goal
4
+
5
+ Preserve the authored X/Y geometry of every Altium surface-mount pad whose
6
+ native shape is `ROUND` or `CIRCLE` and whose two positive dimensions differ.
7
+ The canonical CircuitJSON model must expose these pads as `pill` or
8
+ `rotated_pill` records instead of expanding them into overlapping circles.
9
+
10
+ ## Current Behavior and Root Cause
11
+
12
+ The historical CircuitJSON adapter classifies all Altium `ROUND` pads as
13
+ circles and assigns a radius derived from the larger dimension. That behavior
14
+ is part of the provenance-pinned 1.1.41 implementation and must remain
15
+ unchanged. The public convergence layer currently forwards the lossy SMT pad
16
+ records without restoring the native dimensions.
17
+
18
+ ## Chosen Approach
19
+
20
+ Apply a source-neutral correction in `AltiumCircuitJsonProjection` after the
21
+ historical adapter has produced its immutable rows. Pair canonical
22
+ `pcb_smtpad` rows with native surface-mount pads in their deterministic source
23
+ order. For native round pads with unequal positive X/Y dimensions, replace only
24
+ the canonical geometry fields:
25
+
26
+ - use `pill` when rotation is effectively zero;
27
+ - use `rotated_pill` and `ccw_rotation` otherwise;
28
+ - retain width and height in millimetres;
29
+ - set the end radius to half the shorter dimension;
30
+ - preserve identifiers, ownership, layer, port, net, position, and metadata.
31
+
32
+ Use the same small rotation tolerance as the existing canonical adapter so
33
+ floating-point noise does not create a rotated shape. Equal round pads and all
34
+ other pad shapes remain byte-for-byte unchanged.
35
+
36
+ ## Alternatives Considered
37
+
38
+ 1. Modify the historical adapter. This is smaller locally but violates the
39
+ frozen native source contract and its feature-preservation gates.
40
+ 2. Add an ECAD Forge renderer workaround. This would leave canonical data
41
+ incorrect for every other consumer and duplicate library behavior in the
42
+ host app.
43
+ 3. Correct the convergence projection. This keeps the historical extension
44
+ stable while fixing the public canonical contract for all consumers and is
45
+ therefore the selected approach.
46
+
47
+ ## Testing
48
+
49
+ Add repo-owned synthetic tests without committing any supplied native board:
50
+
51
+ - verify the public `Parser.parse()` result for unrotated and rotated
52
+ anisotropic round SMT pads;
53
+ - verify equal round pads stay circles;
54
+ - verify a through-hole pad interleaved between SMT pads does not disturb the
55
+ native-to-canonical pairing;
56
+ - verify near-zero rotation uses an ordinary pill;
57
+ - run the complete library test, format, performance, package dry-run, and
58
+ feature-preservation gates;
59
+ - verify the public parser against the existing local NodeMCU demo as
60
+ non-committed evidence that all 16 affected SMT pads retain their geometry.
61
+
62
+ The new test file credits both the original contributor and the project
63
+ copyright holder and remains GPL-3.0-or-later.
64
+
65
+ ## Release and Deployment
66
+
67
+ Release the fix as the next patch of `altium-toolkit`, preserving the original
68
+ pull-request contributor in commit attribution and release notes. Publish the
69
+ GitHub release and npm package, then verify the registry version and `latest`
70
+ tag.
71
+
72
+ Update ECAD Forge through npm to the latest published versions of all five
73
+ toolkit dependencies, bump the app patch version, synchronize structured data,
74
+ run the full test, structured-data, static-build, and formatting gates, commit
75
+ and push `main`, create the GitHub release, watch the deployment workflow to a
76
+ successful conclusion, and verify the production app version and corrected pad
77
+ geometry.
78
+
79
+ ## Non-Goals
80
+
81
+ Anisotropic through-hole copper and slot-hole representation is a related but
82
+ separate canonical-model issue because it requires preserving both the outer
83
+ copper aperture and drill geometry. This release does not silently change that
84
+ contract.
@@ -0,0 +1,91 @@
1
+ # Anisotropic ROUND SMT Pad Projection Implementation Plan
2
+
3
+ > **Execution note:** Follow the `superpowers:executing-plans` skill and verify each checkpoint before continuing.
4
+
5
+ **Goal:** Preserve anisotropic Altium ROUND/CIRCLE SMT pad dimensions by projecting them to Circuit JSON pill geometry, release the fix in `altium-toolkit`, and deploy ECAD Forge with current releases of all toolkit dependencies.
6
+
7
+ **Architecture:** Keep the vendored parser adapter unchanged. Add a convergence-layer projection after native schematic projection, pairing adapted pads with canonical renderer pads by stable order. Only zero-hole ROUND/CIRCLE pads with unequal positive dimensions are reclassified. Use a small angular epsilon so floating-point noise near zero does not turn a horizontal pill into a rotated pill.
8
+
9
+ **Tech stack:** Node.js ESM, Node test runner, npm, GitHub CLI, GitHub Actions.
10
+
11
+ ---
12
+
13
+ ## Task 1: Add a public-parser regression test
14
+
15
+ **Files:**
16
+
17
+ - Create: `tests/canonical-pcb-round-pad-projection.test.mjs`
18
+ - Reference: `src/Parser.mjs`
19
+ - Reference: `src/AltiumParser.mjs`
20
+
21
+ 1. Add a synthetic renderer-model fixture by temporarily replacing `AltiumParser.parseArrayBufferToRendererModel`.
22
+ 2. Exercise `Parser.parse()` rather than the convergence helper directly.
23
+ 3. Include, in mixed renderer order:
24
+ - an unequal ROUND SMT pad at zero rotation;
25
+ - a through-hole ROUND pad between SMT pads;
26
+ - an unequal ROUND SMT pad with near-zero rotation;
27
+ - an unequal ROUND SMT pad at 90 degrees;
28
+ - an equal-diameter ROUND SMT pad.
29
+ 4. Assert the public Circuit JSON output contains `pill`, `pill`, `rotated_pill`, and `circle` respectively, with both anisotropic dimensions preserved.
30
+ 5. Run the focused test and confirm it fails because the unequal pads are still circles.
31
+ 6. Commit the failing regression test with contributor attribution.
32
+
33
+ ## Task 2: Implement the convergence projection
34
+
35
+ **Files:**
36
+
37
+ - Modify: `src/convergence/AltiumCircuitJsonProjection.mjs`
38
+ - Test: `tests/canonical-pcb-round-pad-projection.test.mjs`
39
+
40
+ 1. Add a private projection method that filters native renderer pads to SMT pads and pairs them with canonical SMT pads in stable order.
41
+ 2. For renderer `ROUND`/`CIRCLE` pads with zero hole, positive unequal X/Y sizes, update the canonical pad shape:
42
+ - `pill` when absolute rotation is at most `1e-6` degrees;
43
+ - `rotated_pill` otherwise.
44
+ 3. Set `radius = min(width, height) / 2`, preserve `width` and `height`, and leave IDs, layers, positions, rotations, and metadata untouched.
45
+ 4. Call the projection after the existing native schematic projection and before model construction.
46
+ 5. Run the focused test and confirm it passes.
47
+ 6. Run `npm test`, `npm run check:format`, `npm run test:performance`, and `npm run test:features`.
48
+ 7. Parse the noncommitted NodeMCU sample on main and on the fixed tree; verify all 16 previously circular unequal ROUND SMT pads become dimension-preserving pills without changing the total SMT-pad count.
49
+ 8. Commit the implementation with `Co-authored-by: Ahmed Alshaybani`.
50
+
51
+ ## Task 3: Review and release `altium-toolkit`
52
+
53
+ **Files:**
54
+
55
+ - Modify: `package.json`
56
+ - Modify: `package-lock.json`
57
+ - Create: `docs/release-notes-v1.4.6.md`
58
+
59
+ 1. Request an independent code review of the full change from the original main revision.
60
+ 2. Address all valid findings and rerun affected tests.
61
+ 3. Bump the package patch version to `1.4.6` without creating an automatic tag.
62
+ 4. Add concise release notes describing the generalized ROUND/CIRCLE SMT projection, public-parser regression coverage, and contributor credit.
63
+ 5. Run fresh release gates: `npm test`, `npm run check:format`, `npm run test:performance`, `npm run test:features`, and `npm publish --dry-run`.
64
+ 6. Commit the release metadata and push `main`.
65
+ 7. Create and verify GitHub release `v1.4.6`.
66
+ 8. Publish `altium-toolkit@1.4.6` to npm through web authentication and verify the registry version and `latest` dist-tag.
67
+ 9. Comment on PR #1 with the adapted release outcome and contributor credit.
68
+
69
+ ## Task 4: Update and verify ECAD Forge
70
+
71
+ **Files:**
72
+
73
+ - Modify: `package.json`
74
+ - Modify: `package-lock.json`
75
+ - Modify generated structured-data HTML under `src/` if the sync command changes it
76
+
77
+ 1. Query npm for the latest versions of `gerber-toolkit`, `circuitjson-toolkit`, `altium-toolkit`, `kicad-toolkit`, and `pcb-scene3d-viewer`.
78
+ 2. Install all five current releases explicitly, ensuring `altium-toolkit@1.4.6` is selected.
79
+ 3. Bump the ECAD Forge patch version to `1.13.22` without creating an automatic tag.
80
+ 4. Run `npm run sync:structured-data` and inspect the generated changes.
81
+ 5. Run `npm test`, `npm run check:structured-data`, `npm run build:static`, and the repository format check if present.
82
+ 6. Commit only the intended dependency, version, and generated structured-data changes.
83
+
84
+ ## Task 5: Release, deploy, and verify ECAD Forge
85
+
86
+ 1. Push ECAD Forge `main`.
87
+ 2. Create and verify GitHub release `v1.13.22` with concise release notes.
88
+ 3. Resolve the `Deploy to FTP (main)` workflow run associated with the pushed commit and watch it to a successful conclusion.
89
+ 4. Verify the production site responds successfully and reports the new app version.
90
+ 5. Load an Altium PCB through the production app and confirm the affected anisotropic ROUND SMT pads render as elongated pads rather than circles.
91
+ 6. Confirm both repositories are clean and summarize exact commits, releases, npm versions, test results, deployment conclusion, and production evidence.
@@ -25,11 +25,21 @@ export class AltiumCircuitJsonProjection {
25
25
  */
26
26
  static project(adapted, native) {
27
27
  const nativeSchematic = native?.schematic
28
- if (!nativeSchematic) return [...adapted]
28
+ if (!nativeSchematic) {
29
+ return AltiumCircuitJsonProjection.#projectPcbSmtPadShapes(
30
+ adapted,
31
+ native?.pcb
32
+ )
33
+ }
29
34
  const schematic =
30
35
  AltiumCircuitJsonProjection.#schematicWithSourceTypes(
31
36
  nativeSchematic
32
37
  )
38
+ const elementsWithProjectedPadShapes =
39
+ AltiumCircuitJsonProjection.#projectPcbSmtPadShapes(
40
+ adapted,
41
+ native?.pcb
42
+ )
33
43
 
34
44
  const sourceFormat = Primitives.sourceFormat(native)
35
45
  const idScope = Primitives.idScope(native, sourceFormat)
@@ -53,18 +63,18 @@ export class AltiumCircuitJsonProjection {
53
63
  ])
54
64
  )
55
65
  const legacySchematicSourceTraceIds = new Set(
56
- adapted
66
+ elementsWithProjectedPadShapes
57
67
  .filter((element) => element?.type === 'schematic_trace')
58
68
  .map((element) => String(element.source_trace_id || ''))
59
69
  .filter(Boolean)
60
70
  )
61
71
  const protectedPcbSourceTraceIds = new Set(
62
- adapted
72
+ elementsWithProjectedPadShapes
63
73
  .filter((element) => element?.type === 'pcb_trace')
64
74
  .map((element) => String(element.source_trace_id || ''))
65
75
  .filter(Boolean)
66
76
  )
67
- const model = adapted
77
+ const model = elementsWithProjectedPadShapes
68
78
  .filter((element) =>
69
79
  AltiumCircuitJsonProjection.#preservesAdaptedElement(
70
80
  element,
@@ -109,6 +119,79 @@ export class AltiumCircuitJsonProjection {
109
119
  )
110
120
  }
111
121
 
122
+ /**
123
+ * Restores native dimensions for unequal round SMT pads after the frozen
124
+ * historical adapter has classified them as circles.
125
+ * @param {object[]} adapted Historical adapter output.
126
+ * @param {Record<string, any> | undefined} pcb Native PCB model.
127
+ * @returns {object[]} Canonical rows with projected SMT pad shapes.
128
+ */
129
+ static #projectPcbSmtPadShapes(adapted, pcb) {
130
+ const nativeSmtPads = Primitives.array(pcb?.pads).filter(
131
+ (pad) => !Primitives.isThroughHolePad(pad)
132
+ )
133
+ let smtPadIndex = 0
134
+
135
+ return adapted.map((element) => {
136
+ if (element?.type !== 'pcb_smtpad') return element
137
+
138
+ const nativePad = nativeSmtPads[smtPadIndex]
139
+ smtPadIndex += 1
140
+ if (
141
+ !AltiumCircuitJsonProjection.#isAnisotropicRoundSmtPad(
142
+ nativePad
143
+ )
144
+ ) {
145
+ return element
146
+ }
147
+
148
+ const width = Primitives.milNumber(
149
+ nativePad.sizeTopX || nativePad.sizeX || nativePad.width,
150
+ 0
151
+ )
152
+ const height = Primitives.milNumber(
153
+ nativePad.sizeTopY || nativePad.sizeY || nativePad.height,
154
+ 0
155
+ )
156
+ const rotation =
157
+ Primitives.number(
158
+ nativePad.rotation || nativePad.holeRotation,
159
+ 0
160
+ ) || 0
161
+ const hasRotation = Math.abs(rotation) > 0.000001
162
+
163
+ return {
164
+ ...element,
165
+ shape: hasRotation ? 'rotated_pill' : 'pill',
166
+ width,
167
+ height,
168
+ radius: Primitives.round(Math.min(width, height) / 2),
169
+ ...(hasRotation ? { ccw_rotation: rotation } : {})
170
+ }
171
+ })
172
+ }
173
+
174
+ /**
175
+ * Returns whether a native SMT pad requires a dimension-preserving pill.
176
+ * @param {Record<string, any> | undefined} pad Native PCB pad.
177
+ * @returns {boolean} Whether the native pad is an unequal round SMT pad.
178
+ */
179
+ static #isAnisotropicRoundSmtPad(pad) {
180
+ const shape = String(
181
+ pad?.shapeTopName || pad?.shapeName || pad?.shape || ''
182
+ )
183
+ .trim()
184
+ .toLowerCase()
185
+ if (shape !== 'round' && shape !== 'circle') return false
186
+
187
+ const width =
188
+ Primitives.number(pad?.sizeTopX ?? pad?.sizeX ?? pad?.width, 0) || 0
189
+ const height =
190
+ Primitives.number(pad?.sizeTopY ?? pad?.sizeY ?? pad?.height, 0) ||
191
+ 0
192
+ return width > 0 && height > 0 && width !== height
193
+ }
194
+
112
195
  /**
113
196
  * Classifies native Altium record-27 wire segments at the convergence
114
197
  * boundary while preserving explicit source-neutral classifications.
@@ -123,10 +123,14 @@ export class PcbSideResolvedRenderModel {
123
123
  const layerId = PcbSideResolvedRenderModel.#effectivePadLayerId(pad)
124
124
  const apertureSide =
125
125
  layerId === 1 ? 'front' : layerId === 32 ? 'back' : side
126
- if (apertureSide !== 'back') return { ...pad }
126
+ const projectedPad = {
127
+ ...pad,
128
+ copperRenderGroup: apertureSide === side ? 'surface' : 'subsurface'
129
+ }
130
+ if (apertureSide !== 'back') return projectedPad
127
131
 
128
132
  return {
129
- ...pad,
133
+ ...projectedPad,
130
134
  sizeTopX: PcbSideResolvedRenderModel.#firstFiniteValue(
131
135
  pad.sizeBottomX,
132
136
  pad.sizeMidX,
@@ -0,0 +1,130 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { PcbSvgRenderer as LegacyPcbSvgRenderer } from '../ui/PcbSvgRenderer.mjs'
5
+
6
+ /**
7
+ * Renders native Altium PCB models through the preserved historical renderer
8
+ * while applying convergence-owned copper grouping semantics.
9
+ */
10
+ export class PcbSvgRenderer {
11
+ static #SUBSURFACE_GROUP = '<g class="pcb-copper pcb-copper--subsurface">'
12
+ static #SURFACE_GROUP = '<g class="pcb-copper pcb-copper--surface">'
13
+
14
+ /**
15
+ * Renders one native Altium PCB document as SVG markup.
16
+ * @param {Record<string, any>} documentModel Native renderer document.
17
+ * @param {Record<string, any>} [options] Historical renderer options.
18
+ * @returns {string} Rendered SVG panel markup.
19
+ */
20
+ static render(documentModel, options = {}) {
21
+ const markup = LegacyPcbSvgRenderer.render(documentModel, options)
22
+ const subsurfacePadIndexes = PcbSvgRenderer.#subsurfacePadIndexes(
23
+ documentModel,
24
+ options
25
+ )
26
+ if (subsurfacePadIndexes.length === 0) return markup
27
+
28
+ return PcbSvgRenderer.#movePadsToSubsurfaceGroup(
29
+ markup,
30
+ subsurfacePadIndexes
31
+ )
32
+ }
33
+
34
+ /**
35
+ * Renders one deterministic SVG entry per physical or primitive layer.
36
+ * Layer exports intentionally preserve the historical layer-only output.
37
+ * @param {Record<string, any>} documentModel Native renderer document.
38
+ * @returns {{ layerId?: number, layerKey: string, displayName: string, role: string, svg: string }[]}
39
+ */
40
+ static renderLayerSvgs(documentModel) {
41
+ return LegacyPcbSvgRenderer.renderLayerSvgs(documentModel)
42
+ }
43
+
44
+ /**
45
+ * Finds pad indexes whose copper must share the contextual copper group.
46
+ * Layer-only exports do not use composite surface/subsurface grouping.
47
+ * @param {Record<string, any>} documentModel Native renderer document.
48
+ * @param {Record<string, any>} options Historical renderer options.
49
+ * @returns {number[]} Stable pad indexes.
50
+ */
51
+ static #subsurfacePadIndexes(documentModel, options) {
52
+ if (options?.layerView) return []
53
+
54
+ return (documentModel?.pcb?.pads || [])
55
+ .map((pad, index) =>
56
+ pad?.copperRenderGroup === 'subsurface' ? index : -1
57
+ )
58
+ .filter((index) => index >= 0)
59
+ }
60
+
61
+ /**
62
+ * Moves contextual pad groups into the same SVG group as contextual traces
63
+ * so the browser composites both primitives with one shared opacity.
64
+ * @param {string} markup Historical renderer markup.
65
+ * @param {number[]} padIndexes Stable pad indexes to relocate.
66
+ * @returns {string} Copper-group-aware markup.
67
+ */
68
+ static #movePadsToSubsurfaceGroup(markup, padIndexes) {
69
+ const subsurfaceStart = markup.indexOf(PcbSvgRenderer.#SUBSURFACE_GROUP)
70
+ const surfaceStart = markup.indexOf(PcbSvgRenderer.#SURFACE_GROUP)
71
+ if (subsurfaceStart < 0 || surfaceStart <= subsurfaceStart) {
72
+ return markup
73
+ }
74
+
75
+ const subsurfaceClose = markup.lastIndexOf('</g>', surfaceStart)
76
+ if (subsurfaceClose < subsurfaceStart) return markup
77
+
78
+ let remainingMarkup = markup
79
+ const relocatedPads = []
80
+ for (const padIndex of padIndexes) {
81
+ const result = PcbSvgRenderer.#extractPadGroup(
82
+ remainingMarkup,
83
+ padIndex,
84
+ surfaceStart
85
+ )
86
+ if (!result) continue
87
+ remainingMarkup = result.markup
88
+ relocatedPads.push(result.padMarkup)
89
+ }
90
+ if (relocatedPads.length === 0) return markup
91
+
92
+ const updatedSurfaceStart = remainingMarkup.indexOf(
93
+ PcbSvgRenderer.#SURFACE_GROUP
94
+ )
95
+ const updatedSubsurfaceClose = remainingMarkup.lastIndexOf(
96
+ '</g>',
97
+ updatedSurfaceStart
98
+ )
99
+ return (
100
+ remainingMarkup.slice(0, updatedSubsurfaceClose) +
101
+ relocatedPads.join('') +
102
+ remainingMarkup.slice(updatedSubsurfaceClose)
103
+ )
104
+ }
105
+
106
+ /**
107
+ * Extracts one top-level pad group from the historical surface group.
108
+ * Pad groups contain only leaf SVG shapes, so their first closing group is
109
+ * also the matching closing tag.
110
+ * @param {string} markup Current renderer markup.
111
+ * @param {number} padIndex Stable pad index.
112
+ * @param {number} minimumStart Earliest valid surface-group position.
113
+ * @returns {{ markup: string, padMarkup: string } | null} Extraction result.
114
+ */
115
+ static #extractPadGroup(markup, padIndex, minimumStart) {
116
+ const elementKey = 'data-element-key="pcb-pad-' + padIndex + '"'
117
+ const keyStart = markup.indexOf(elementKey, minimumStart)
118
+ if (keyStart < 0) return null
119
+
120
+ const groupStart = markup.lastIndexOf('<g', keyStart)
121
+ const groupEndStart = markup.indexOf('</g>', keyStart)
122
+ if (groupStart < minimumStart || groupEndStart < 0) return null
123
+
124
+ const groupEnd = groupEndStart + '</g>'.length
125
+ return {
126
+ markup: markup.slice(0, groupStart) + markup.slice(groupEnd),
127
+ padMarkup: markup.slice(groupStart, groupEnd)
128
+ }
129
+ }
130
+ }
@@ -4,6 +4,7 @@
4
4
  export * from 'circuitjson-toolkit/extensions'
5
5
 
6
6
  export { AltiumExtensionResolver } from './convergence/AltiumExtensionResolver.mjs'
7
+ export { PcbSvgRenderer } from './convergence/PcbSvgRenderer.mjs'
7
8
  export { SchematicSvgRenderer } from './convergence/SchematicSvgRenderer.mjs'
8
9
  export * from './legacy-parser.mjs'
9
10
  export * from './legacy-netlist-query.mjs'
@@ -270,6 +270,10 @@
270
270
  fill: var(--pcb-copper-solid-fill);
271
271
  }
272
272
 
273
+ .pcb-copper--subsurface .pcb-pad--smd .pcb-pad__ring {
274
+ fill: var(--pcb-subsurface-track-color);
275
+ }
276
+
273
277
  .pcb-footprint-fill {
274
278
  fill: var(--pcb-footprint-fill);
275
279
  }