altium-toolkit 1.4.7 → 1.4.9

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,42 @@
1
+ # altium-toolkit 1.4.8
2
+
3
+ Version 1.4.8 corrects embedded STEP placement for bottom-side components and
4
+ for package owners recovered after initial scene construction.
5
+
6
+ ## Signed STEP source orientation
7
+
8
+ - Embedded STEP registry entries expose signed source bounds derived from the
9
+ model payload and its declared length units.
10
+ - Bottom-side models whose geometry is predominantly below the authored origin
11
+ preserve their authored half-turn instead of being normalized upside down.
12
+ - Direct scene construction and asynchronous scene preparation share the same
13
+ convergence registry and builder path.
14
+
15
+ ## Late-owner model seating
16
+
17
+ - Final placement owners are reconciled with their original component-body
18
+ metadata after historical ownership adapters complete.
19
+ - A finite zero authored standoff clears only an unchanged positive source
20
+ `dzMil`, allowing the shared viewer's model-bounds seating to place the model
21
+ on the PCB surface.
22
+ - Positive authored standoffs, unresolved bodies, downstream-adjusted offsets,
23
+ and unrelated model transformations remain unchanged.
24
+ - The rule is derived from ownership and placement metadata without component,
25
+ model, vendor, package, project, or fixture-specific matching.
26
+
27
+ ## Compatibility
28
+
29
+ - Historical native source remains frozen; the corrections live in the public
30
+ convergence layer.
31
+ - Existing parser, renderer, extension, and viewer scene contracts remain
32
+ unchanged.
33
+
34
+ ## Verification
35
+
36
+ - Repository-owned fake regressions cover signed-source bottom half-turns,
37
+ zero-standoff late owners, positive authored standoffs, unresolved bodies,
38
+ and downstream-adjusted offsets.
39
+ - Exact local-board verification checks the affected model seating together
40
+ with the previously reported bottom connector orientation.
41
+ - Release gates include the complete package suite, formatting check, npm dry
42
+ run, ECAD Forge integration tests, structured-data check, and static build.
@@ -0,0 +1,37 @@
1
+ # altium-toolkit 1.4.9
2
+
3
+ Version 1.4.9 restores complete mechanical and documentation artwork in native
4
+ Altium PCB rendering and supports fitting the viewport to visible layers.
5
+
6
+ ## Drawing annotations
7
+
8
+ - Text from mechanical, assembly, fabrication, documentation, notes, dimension,
9
+ and courtyard layers is rendered alongside the corresponding drawing
10
+ geometry.
11
+ - Side-specific assembly annotations follow the active board side.
12
+ - Off-board drawing annotations remain outside the board clip, preserving title
13
+ blocks and fabrication notes at their authored positions.
14
+
15
+ ## Visible-layer viewport
16
+
17
+ - PCB render options accept hidden layer aliases when calculating the root SVG
18
+ viewport.
19
+ - Hidden drawing layers retain their SVG markup for instant visibility toggles
20
+ while no longer expanding the fitted viewport.
21
+ - Component placements far outside the board are excluded from board-first
22
+ viewport fitting when drawing layers are hidden.
23
+
24
+ ## Compatibility
25
+
26
+ - Historical native renderer source remains frozen; the behavior is composed in
27
+ the public convergence renderer.
28
+ - Existing render calls without hidden layers preserve their previous output and
29
+ bounds.
30
+ - Layer-only exports retain the historical output contract.
31
+
32
+ ## Verification
33
+
34
+ - Repository-owned fake regressions cover top- and bottom-side drawing text,
35
+ unclipped annotations, retained hidden markup, and visible-layer bounds.
36
+ - The complete package suite, formatting check, performance guard, npm package
37
+ dry run, and ECAD Forge integration gates are required for release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.4.7",
3
+ "version": "1.4.9",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -0,0 +1,213 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { PcbLayerGroups } from '../core/altium/PcbLayerGroups.mjs'
5
+
6
+ /**
7
+ * Resolves normalized layer identity for convergence-owned PCB rendering.
8
+ */
9
+ export class PcbConvergenceLayerModel {
10
+ /**
11
+ * Resolves distinct layer descriptors from stack and primitive metadata.
12
+ * @param {object} documentModel PCB document.
13
+ * @returns {object[]}
14
+ */
15
+ static resolve(documentModel) {
16
+ const pcb = documentModel?.pcb || {}
17
+ const layers = [...(pcb.layers || []), ...(pcb.primitiveLayers || [])]
18
+ const descriptors = []
19
+ const identities = new Set()
20
+
21
+ for (const layer of layers) {
22
+ const descriptor = PcbConvergenceLayerModel.#descriptor(layer)
23
+ const identity = descriptor.layerKey || descriptor.displayName
24
+ if (!identity || identities.has(identity)) continue
25
+ identities.add(identity)
26
+ descriptors.push(descriptor)
27
+ }
28
+ return descriptors
29
+ }
30
+
31
+ /**
32
+ * Builds layer lookup maps for primitive and text matching.
33
+ * @param {object} documentModel PCB document.
34
+ * @returns {{ byId: Map<number, object>, byName: Map<string, object>, layers: object[] }}
35
+ */
36
+ static buildLookup(documentModel) {
37
+ const layers = PcbConvergenceLayerModel.resolve(documentModel)
38
+ const byId = new Map()
39
+ const byName = new Map()
40
+ for (const layer of layers) {
41
+ for (const id of [layer.layerId, layer.legacyLayerId]) {
42
+ if (Number.isInteger(id) && !byId.has(id)) byId.set(id, layer)
43
+ }
44
+ byName.set(
45
+ PcbConvergenceLayerModel.normalize(layer.displayName),
46
+ layer
47
+ )
48
+ }
49
+ return { byId, byName, layers }
50
+ }
51
+
52
+ /**
53
+ * Resolves the known layer for one primitive.
54
+ * @param {object} primitive Primitive record.
55
+ * @param {{ byId: Map<number, object>, byName: Map<string, object> }} lookup Layer lookup.
56
+ * @returns {object | null}
57
+ */
58
+ static layerForPrimitive(primitive, lookup) {
59
+ for (const value of [primitive?.layerId, primitive?.layerCode]) {
60
+ const id = Number(value)
61
+ if (Number.isInteger(id) && lookup.byId.has(id)) {
62
+ return lookup.byId.get(id)
63
+ }
64
+ }
65
+ const name = PcbConvergenceLayerModel.normalize(
66
+ primitive?.layerName || primitive?.layer || primitive?.side
67
+ )
68
+ return name ? lookup.byName.get(name) || null : null
69
+ }
70
+
71
+ /**
72
+ * Returns all stable aliases for one layer descriptor.
73
+ * @param {object | null} layer Layer descriptor.
74
+ * @returns {string[]}
75
+ */
76
+ static aliases(layer) {
77
+ if (!layer) return []
78
+ return [
79
+ layer.layerKey,
80
+ layer.displayName,
81
+ layer.layerId,
82
+ layer.legacyLayerId,
83
+ Number.isInteger(layer.layerId) ? 'L' + layer.layerId : '',
84
+ Number.isInteger(layer.legacyLayerId)
85
+ ? 'L' + layer.legacyLayerId
86
+ : ''
87
+ ]
88
+ .map(PcbConvergenceLayerModel.normalize)
89
+ .filter(Boolean)
90
+ }
91
+
92
+ /**
93
+ * Returns true for mechanical and documentation drawing layers.
94
+ * @param {object} layer Layer descriptor.
95
+ * @returns {boolean}
96
+ */
97
+ static isDrawingLayer(layer) {
98
+ const text = [layer?.displayName, layer?.role]
99
+ .filter(Boolean)
100
+ .join(' ')
101
+ .toLowerCase()
102
+ return /(mechanical|assembly|\basm\b|fabrication|\bfab\b|drawing|dimension|documentation|document|notes?|courtyard|crtyd)/u.test(
103
+ text
104
+ )
105
+ }
106
+
107
+ /**
108
+ * Returns true when one drawing layer belongs to the requested side.
109
+ * @param {object} layer Layer descriptor.
110
+ * @param {'top' | 'bottom'} side Board side.
111
+ * @returns {boolean}
112
+ */
113
+ static isDrawingLayerForSide(layer, side) {
114
+ if (!PcbConvergenceLayerModel.isDrawingLayer(layer)) return false
115
+ const text = [layer?.displayName, layer?.role]
116
+ .filter(Boolean)
117
+ .join(' ')
118
+ .toLowerCase()
119
+ const compact = text.replace(/[^a-z0-9]+/gu, '')
120
+ const bottom =
121
+ /\bbottom\b|\bbot\b|botside|backassembly|bassembly|bcrtyd/u.test(
122
+ text
123
+ ) || compact.includes('assemblybottom')
124
+ const top =
125
+ /\btop\b|frontassembly|fassembly|fcrtyd/u.test(text) ||
126
+ compact.includes('assemblytop')
127
+
128
+ if (bottom && !top) return side === 'bottom'
129
+ if (top && !bottom) return side === 'top'
130
+ return true
131
+ }
132
+
133
+ /**
134
+ * Normalizes one semantic layer alias.
135
+ * @param {unknown} value Raw alias.
136
+ * @returns {string}
137
+ */
138
+ static normalize(value) {
139
+ return String(value ?? '')
140
+ .trim()
141
+ .toUpperCase()
142
+ }
143
+
144
+ /**
145
+ * Builds one normalized descriptor.
146
+ * @param {object} layer Source layer.
147
+ * @returns {object}
148
+ */
149
+ static #descriptor(layer) {
150
+ const layerId = PcbConvergenceLayerModel.#firstInteger([
151
+ layer?.layerId,
152
+ layer?.id,
153
+ layer?.index,
154
+ layer?.number
155
+ ])
156
+ const legacyLayerId = PcbConvergenceLayerModel.#firstInteger([
157
+ layer?.legacyLayerId,
158
+ layer?.legacyId
159
+ ])
160
+ const displayName = String(
161
+ layer?.displayName || layer?.name || layer?.label || ''
162
+ )
163
+ return {
164
+ layerId,
165
+ legacyLayerId,
166
+ layerKey: Number.isInteger(layerId)
167
+ ? 'L' + layerId
168
+ : PcbConvergenceLayerModel.normalize(displayName),
169
+ displayName,
170
+ role:
171
+ layer?.role ||
172
+ layer?.layerRole ||
173
+ PcbConvergenceLayerModel.#inferRole(
174
+ displayName,
175
+ legacyLayerId ?? layerId
176
+ )
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Resolves a broad layer role.
182
+ * @param {string} name Layer name.
183
+ * @param {number | undefined} layerId Layer id.
184
+ * @returns {string}
185
+ */
186
+ static #inferRole(name, layerId) {
187
+ if (PcbLayerGroups.isMechanical(layerId)) return 'mechanical'
188
+ if (PcbLayerGroups.isOverlay(layerId)) return 'overlay'
189
+ if (PcbLayerGroups.isCopper(layerId)) return 'copper'
190
+ const normalized = name.toLowerCase()
191
+ if (/mechanical|dimension|drawing/u.test(normalized)) {
192
+ return 'mechanical'
193
+ }
194
+ if (/assembly|\basm\b/u.test(normalized)) return 'assembly'
195
+ if (/notes?|document|courtyard|crtyd/u.test(normalized)) {
196
+ return 'documentation'
197
+ }
198
+ return 'other'
199
+ }
200
+
201
+ /**
202
+ * Returns the first integer in a value list.
203
+ * @param {unknown[]} values Candidate values.
204
+ * @returns {number | undefined}
205
+ */
206
+ static #firstInteger(values) {
207
+ for (const value of values) {
208
+ const number = Number(value)
209
+ if (Number.isInteger(number)) return number
210
+ }
211
+ return undefined
212
+ }
213
+ }
@@ -0,0 +1,121 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { PcbTextPrimitiveRenderer } from '../ui/PcbTextPrimitiveRenderer.mjs'
5
+ import { PcbConvergenceLayerModel } from './PcbConvergenceLayerModel.mjs'
6
+
7
+ /**
8
+ * Adds side-correct drawing annotations outside the board-only text clip.
9
+ */
10
+ export class PcbDrawingTextComposite {
11
+ /**
12
+ * Adds mechanical and documentation text to rendered PCB markup.
13
+ * @param {string} markup Historical renderer markup.
14
+ * @param {object} documentModel PCB document.
15
+ * @param {{ side?: 'top' | 'bottom' }} [options] Render options.
16
+ * @returns {string}
17
+ */
18
+ static apply(markup, documentModel, options = {}) {
19
+ const pcb = documentModel?.pcb
20
+ if (!pcb) return markup
21
+ const side = options?.side === 'bottom' ? 'bottom' : 'top'
22
+ const lookup = PcbConvergenceLayerModel.buildLookup(documentModel)
23
+ const drawingLayers = lookup.layers.filter((layer) =>
24
+ PcbConvergenceLayerModel.isDrawingLayerForSide(layer, side)
25
+ )
26
+ if (!drawingLayers.length) return markup
27
+
28
+ const selectorLayers = drawingLayers
29
+ .filter((layer) => Number.isInteger(layer.layerId))
30
+ .map((layer) => ({
31
+ layerId: layer.layerId,
32
+ name: 'Top Overlay'
33
+ }))
34
+ const texts = PcbTextPrimitiveRenderer.select(
35
+ selectorLayers,
36
+ pcb.texts || [],
37
+ 'top'
38
+ )
39
+ if (!texts.length) return markup
40
+
41
+ const textMarkup = PcbTextPrimitiveRenderer.render(texts, {
42
+ semanticContext: PcbDrawingTextComposite.#semanticContext(
43
+ pcb,
44
+ lookup
45
+ )
46
+ })
47
+ return PcbDrawingTextComposite.#insertDrawingGroup(markup, textMarkup)
48
+ }
49
+
50
+ /**
51
+ * Builds the public semantic context consumed by the historical text
52
+ * primitive renderer.
53
+ * @param {object} pcb PCB model.
54
+ * @param {{ byId: Map<number, object> }} lookup Layer lookup.
55
+ * @returns {object}
56
+ */
57
+ static #semanticContext(pcb, lookup) {
58
+ return {
59
+ layersById: lookup.byId,
60
+ primitiveIndexes: {
61
+ texts: new Map(
62
+ (pcb.texts || []).map((text, index) => [text, index])
63
+ )
64
+ },
65
+ netByIndex: new Map(
66
+ (pcb.nets || []).map((net) => [Number(net.netIndex), net])
67
+ ),
68
+ netClassNamesByNetName: new Map(),
69
+ componentsByIndex: new Map(
70
+ (pcb.components || []).map((component) => [
71
+ Number(component.componentIndex),
72
+ component
73
+ ])
74
+ )
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Inserts one un-clipped group immediately after the ordinary text group.
80
+ * @param {string} markup SVG panel markup.
81
+ * @param {string} textMarkup Drawing text markup.
82
+ * @returns {string}
83
+ */
84
+ static #insertDrawingGroup(markup, textMarkup) {
85
+ const start = markup.indexOf('<g class="pcb-texts"')
86
+ if (start < 0) return markup
87
+ const openEnd = markup.indexOf('>', start)
88
+ const end = PcbDrawingTextComposite.#groupEnd(markup, openEnd + 1)
89
+ if (openEnd < 0 || end < 0) return markup
90
+ const openTag = markup.slice(start, openEnd + 1)
91
+ const transform = openTag.match(/\stransform="[^"]*"/u)?.[0] || ''
92
+ const group =
93
+ '<g class="pcb-drawing-texts"' +
94
+ transform +
95
+ '>' +
96
+ textMarkup +
97
+ '</g>'
98
+ return markup.slice(0, end) + group + markup.slice(end)
99
+ }
100
+
101
+ /**
102
+ * Finds the offset after the matching close tag for one SVG group.
103
+ * @param {string} markup SVG markup.
104
+ * @param {number} contentStart Group content offset.
105
+ * @returns {number}
106
+ */
107
+ static #groupEnd(markup, contentStart) {
108
+ const pattern = /<g\b[^>]*>|<\/g>/gu
109
+ let depth = 1
110
+ pattern.lastIndex = contentStart
111
+ for (
112
+ let match = pattern.exec(markup);
113
+ match;
114
+ match = pattern.exec(markup)
115
+ ) {
116
+ depth += match[0].startsWith('</') ? -1 : 1
117
+ if (depth === 0) return pattern.lastIndex
118
+ }
119
+ return -1
120
+ }
121
+ }
@@ -0,0 +1,243 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { PcbScene3dBuilder as HistoricalPcbScene3dBuilder } from '../ui/PcbScene3dBuilder.mjs'
5
+
6
+ const NEGATIVE_SOURCE_Z_DOMINANCE_RATIO = 0.8
7
+ const POSITION_EPSILON_MIL = 1e-6
8
+
9
+ /**
10
+ * Adds signed-source model orientation correction around the preserved native
11
+ * scene builder.
12
+ */
13
+ export class PcbScene3dBuilder {
14
+ /**
15
+ * Builds a scene and preserves structurally negative-Z source half-turns.
16
+ * @param {object} documentModel Parsed Altium document model.
17
+ * @param {object} [options] Native scene-builder options.
18
+ * @returns {object}
19
+ */
20
+ static build(documentModel, options = {}) {
21
+ const scene = HistoricalPcbScene3dBuilder.build(documentModel, options)
22
+ if (!Array.isArray(scene?.externalPlacements)) {
23
+ return scene
24
+ }
25
+
26
+ return {
27
+ ...scene,
28
+ externalPlacements: scene.externalPlacements.map((placement) =>
29
+ PcbScene3dBuilder.#normalizePlacement(
30
+ placement,
31
+ documentModel?.pcb?.componentBodies,
32
+ documentModel?.pcb?.components
33
+ )
34
+ )
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Preserves one bottom placement half-turn when its source geometry is
40
+ * predominantly below the authored origin.
41
+ * @param {object} placement Built external placement.
42
+ * @param {object[] | undefined} componentBodies Source component bodies.
43
+ * @param {object[] | undefined} components Source PCB components.
44
+ * @returns {object}
45
+ */
46
+ static #normalizePlacement(placement, componentBodies, components) {
47
+ const componentBody = PcbScene3dBuilder.#resolveComponentBody(
48
+ placement,
49
+ componentBodies
50
+ )
51
+ const normalizedPlacement =
52
+ PcbScene3dBuilder.#normalizeRecoveredOwnerVerticalOffset(
53
+ placement,
54
+ componentBody,
55
+ components
56
+ )
57
+
58
+ if (
59
+ String(normalizedPlacement?.mountSide || '').toLowerCase() !==
60
+ 'bottom' ||
61
+ !PcbScene3dBuilder.#isDominantlyNegativeSourceZ(
62
+ normalizedPlacement?.externalModel?.sourceBoundsMil
63
+ )
64
+ ) {
65
+ return normalizedPlacement
66
+ }
67
+
68
+ if (
69
+ PcbScene3dBuilder.#normalizeAngle(
70
+ componentBody?.modelRotationDeg?.x ??
71
+ normalizedPlacement?.externalModel?.transform?.rotationDeg
72
+ ?.x
73
+ ) !== 180
74
+ ) {
75
+ return normalizedPlacement
76
+ }
77
+
78
+ return {
79
+ ...normalizedPlacement,
80
+ modelTransform: {
81
+ ...(normalizedPlacement.modelTransform || {}),
82
+ rotationDeg: {
83
+ ...(normalizedPlacement.modelTransform?.rotationDeg || {}),
84
+ x: -180
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Applies owned-package seating after a later adapter recovered the owner.
92
+ * @param {object} placement Built external placement.
93
+ * @param {object | null} componentBody Resolved source component body.
94
+ * @param {object[] | undefined} components Source PCB components.
95
+ * @returns {object}
96
+ */
97
+ static #normalizeRecoveredOwnerVerticalOffset(
98
+ placement,
99
+ componentBody,
100
+ components
101
+ ) {
102
+ if (
103
+ !PcbScene3dBuilder.#hasResolvedOwner(placement, components) ||
104
+ !PcbScene3dBuilder.#hasZeroAuthoredStandoff(componentBody) ||
105
+ !PcbScene3dBuilder.#retainsPositiveSourceOffset(
106
+ placement,
107
+ componentBody
108
+ )
109
+ ) {
110
+ return placement
111
+ }
112
+
113
+ return {
114
+ ...placement,
115
+ modelTransform: {
116
+ ...(placement.modelTransform || {}),
117
+ dzMil: 0
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Checks whether the final placement designator resolves a source owner.
124
+ * @param {object} placement Built external placement.
125
+ * @param {object[] | undefined} components Source PCB components.
126
+ * @returns {boolean}
127
+ */
128
+ static #hasResolvedOwner(placement, components) {
129
+ const designator = String(placement?.designator || '')
130
+ return (
131
+ designator.length > 0 &&
132
+ (Array.isArray(components) ? components : []).some(
133
+ (component) =>
134
+ String(component?.designator || '') === designator
135
+ )
136
+ )
137
+ }
138
+
139
+ /**
140
+ * Checks whether the source explicitly authors zero model standoff.
141
+ * @param {object | null} componentBody Resolved source component body.
142
+ * @returns {boolean}
143
+ */
144
+ static #hasZeroAuthoredStandoff(componentBody) {
145
+ const sourceStandoff = componentBody?.standoffHeightMil
146
+ if (
147
+ sourceStandoff === null ||
148
+ sourceStandoff === undefined ||
149
+ sourceStandoff === ''
150
+ ) {
151
+ return false
152
+ }
153
+
154
+ const standoff = Number(sourceStandoff)
155
+ return (
156
+ Number.isFinite(standoff) &&
157
+ Math.abs(standoff) <= POSITION_EPSILON_MIL
158
+ )
159
+ }
160
+
161
+ /**
162
+ * Checks whether the final placement still carries the positive source Z.
163
+ * @param {object} placement Built external placement.
164
+ * @param {object | null} componentBody Resolved source component body.
165
+ * @returns {boolean}
166
+ */
167
+ static #retainsPositiveSourceOffset(placement, componentBody) {
168
+ const sourceOffset = Number(componentBody?.dzMil)
169
+ const placementOffset = Number(placement?.modelTransform?.dzMil)
170
+
171
+ return (
172
+ Number.isFinite(sourceOffset) &&
173
+ sourceOffset > 0 &&
174
+ Number.isFinite(placementOffset) &&
175
+ Math.abs(placementOffset - sourceOffset) <= POSITION_EPSILON_MIL
176
+ )
177
+ }
178
+
179
+ /**
180
+ * Resolves the source body associated with one built placement.
181
+ * @param {object} placement Built external placement.
182
+ * @param {object[] | undefined} componentBodies Source component bodies.
183
+ * @returns {object | null}
184
+ */
185
+ static #resolveComponentBody(placement, componentBodies) {
186
+ const bodyPosition = placement?.bodyPositionMil || {}
187
+ const modelName = String(placement?.externalModel?.name || '')
188
+
189
+ return (
190
+ (Array.isArray(componentBodies) ? componentBodies : []).find(
191
+ (componentBody) =>
192
+ String(componentBody?.name || '') === modelName &&
193
+ PcbScene3dBuilder.#positionsMatch(
194
+ componentBody?.positionMil,
195
+ bodyPosition
196
+ )
197
+ ) || null
198
+ )
199
+ }
200
+
201
+ /**
202
+ * Checks whether two model positions represent the same authored anchor.
203
+ * @param {{ x?: number, y?: number } | undefined} left Left position.
204
+ * @param {{ x?: number, y?: number } | undefined} right Right position.
205
+ * @returns {boolean}
206
+ */
207
+ static #positionsMatch(left, right) {
208
+ return (
209
+ Math.abs(Number(left?.x) - Number(right?.x)) <=
210
+ POSITION_EPSILON_MIL &&
211
+ Math.abs(Number(left?.y) - Number(right?.y)) <= POSITION_EPSILON_MIL
212
+ )
213
+ }
214
+
215
+ /**
216
+ * Checks whether at least four fifths of the source Z span lies below the
217
+ * authored origin.
218
+ * @param {{ minZ?: number, maxZ?: number } | null | undefined} sourceBoundsMil Signed source-model bounds.
219
+ * @returns {boolean}
220
+ */
221
+ static #isDominantlyNegativeSourceZ(sourceBoundsMil) {
222
+ const minZ = Number(sourceBoundsMil?.minZ)
223
+ const maxZ = Number(sourceBoundsMil?.maxZ)
224
+ if (!Number.isFinite(minZ) || !Number.isFinite(maxZ) || maxZ <= minZ) {
225
+ return false
226
+ }
227
+
228
+ const negativeSpan = Math.max(0, Math.min(0, maxZ) - minZ)
229
+ return negativeSpan / (maxZ - minZ) >= NEGATIVE_SOURCE_Z_DOMINANCE_RATIO
230
+ }
231
+
232
+ /**
233
+ * Normalizes one angle into the positive 0-359 degree range.
234
+ * @param {unknown} angle Source angle.
235
+ * @returns {number}
236
+ */
237
+ static #normalizeAngle(angle) {
238
+ const numericAngle = Number(angle || 0)
239
+ const normalized = numericAngle % 360
240
+
241
+ return normalized < 0 ? normalized + 360 : normalized
242
+ }
243
+ }
@@ -0,0 +1,184 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { PcbScene3dModelRegistry as HistoricalPcbScene3dModelRegistry } from '../ui/PcbScene3dModelRegistry.mjs'
5
+
6
+ const MILS_PER_METER = 39370.07874015748
7
+ const MILS_PER_INCH = 1000
8
+
9
+ /**
10
+ * Adds signed STEP source bounds to the preserved native model registry.
11
+ */
12
+ export class PcbScene3dModelRegistry {
13
+ #registry
14
+
15
+ /**
16
+ * Creates one converged registry around either a preserved native registry
17
+ * or the native constructor's normalized model rows.
18
+ * @param {HistoricalPcbScene3dModelRegistry | object[]} registryOrModelFiles Native registry or normalized session models.
19
+ * @param {object[]} embeddedModels Normalized embedded models.
20
+ */
21
+ constructor(registryOrModelFiles, embeddedModels) {
22
+ this.#registry =
23
+ registryOrModelFiles instanceof HistoricalPcbScene3dModelRegistry
24
+ ? registryOrModelFiles
25
+ : new HistoricalPcbScene3dModelRegistry(
26
+ registryOrModelFiles,
27
+ embeddedModels
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Creates one model registry from session and embedded model assets.
33
+ * @param {{ name?: string, relativePath?: string, source?: string }[]} sessionFiles Session model files.
34
+ * @param {{ id?: string, checksum?: number | null, name?: string, format?: string, payloadText?: string, sourceStream?: string, transform?: object }[]} [embeddedModels] Embedded model payloads.
35
+ * @returns {PcbScene3dModelRegistry}
36
+ */
37
+ static create(sessionFiles, embeddedModels = []) {
38
+ return new PcbScene3dModelRegistry(
39
+ HistoricalPcbScene3dModelRegistry.create(
40
+ sessionFiles,
41
+ embeddedModels
42
+ )
43
+ )
44
+ }
45
+
46
+ /**
47
+ * Resolves the best available model for one component.
48
+ * @param {{ pattern?: string, source?: string, modelPath?: string }} component Component metadata.
49
+ * @returns {object | null}
50
+ */
51
+ resolveComponentModel(component) {
52
+ return this.#registry.resolveComponentModel(component)
53
+ }
54
+
55
+ /**
56
+ * Resolves a component-body model and exposes its signed STEP bounds.
57
+ * @param {{ modelId?: string, checksum?: number | null, name?: string }} componentBody Component-body metadata.
58
+ * @returns {object | null}
59
+ */
60
+ resolveComponentBodyModel(componentBody) {
61
+ const model = this.#registry.resolveComponentBodyModel(componentBody)
62
+ const sourceBoundsMil =
63
+ PcbScene3dModelRegistry.#resolveSourceBoundsMil(model)
64
+
65
+ return sourceBoundsMil ? { ...model, sourceBoundsMil } : model
66
+ }
67
+
68
+ /**
69
+ * Resolves a project-level full-board assembly model.
70
+ * @param {{ fileName?: string }} documentModel Document metadata.
71
+ * @returns {object | null}
72
+ */
73
+ resolveBoardAssemblyModel(documentModel) {
74
+ return this.#registry.resolveBoardAssemblyModel(documentModel)
75
+ }
76
+
77
+ /**
78
+ * Resolves signed bounds for one inline STEP model.
79
+ * @param {{ format?: string, payloadText?: string } | null} model Resolved model.
80
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number, minZ: number, maxZ: number } | null}
81
+ */
82
+ static #resolveSourceBoundsMil(model) {
83
+ const format = String(model?.format || '').toLowerCase()
84
+ if ((format !== 'step' && format !== 'stp') || !model?.payloadText) {
85
+ return null
86
+ }
87
+
88
+ const text = String(model.payloadText)
89
+ const points = []
90
+ const pointPattern =
91
+ /CARTESIAN_POINT\s*\(\s*(?:'[^']*'|[^,]*),\s*\(([^)]*)\)\s*\)/giu
92
+ let match = pointPattern.exec(text)
93
+
94
+ while (match) {
95
+ const coordinates = String(match[1] || '')
96
+ .split(',')
97
+ .slice(0, 3)
98
+ .map((value) => Number(value.trim()))
99
+ if (
100
+ coordinates.length === 3 &&
101
+ coordinates.every((value) => Number.isFinite(value))
102
+ ) {
103
+ points.push(coordinates)
104
+ }
105
+ match = pointPattern.exec(text)
106
+ }
107
+
108
+ if (points.length < 2) {
109
+ return null
110
+ }
111
+
112
+ const [firstPoint] = points
113
+ const bounds = {
114
+ minX: firstPoint[0],
115
+ maxX: firstPoint[0],
116
+ minY: firstPoint[1],
117
+ maxY: firstPoint[1],
118
+ minZ: firstPoint[2],
119
+ maxZ: firstPoint[2]
120
+ }
121
+ points.slice(1).forEach(([x, y, z]) => {
122
+ bounds.minX = Math.min(bounds.minX, x)
123
+ bounds.maxX = Math.max(bounds.maxX, x)
124
+ bounds.minY = Math.min(bounds.minY, y)
125
+ bounds.maxY = Math.max(bounds.maxY, y)
126
+ bounds.minZ = Math.min(bounds.minZ, z)
127
+ bounds.maxZ = Math.max(bounds.maxZ, z)
128
+ })
129
+
130
+ const scale = PcbScene3dModelRegistry.#resolveStepMilScale(text)
131
+ return Object.fromEntries(
132
+ Object.entries(bounds).map(([key, value]) => [key, value * scale])
133
+ )
134
+ }
135
+
136
+ /**
137
+ * Resolves the STEP length-unit scale to mils.
138
+ * @param {string} payloadText STEP text payload.
139
+ * @returns {number}
140
+ */
141
+ static #resolveStepMilScale(payloadText) {
142
+ const text = String(payloadText || '').toUpperCase()
143
+ if (/\bINCH\b|\.INCH\./u.test(text)) {
144
+ return MILS_PER_INCH
145
+ }
146
+
147
+ const siUnitMatch = text.match(
148
+ /SI_UNIT\s*\(\s*(\.[A-Z]+\.|\$)\s*,\s*\.METRE\.\s*\)/u
149
+ )
150
+ return (
151
+ PcbScene3dModelRegistry.#resolveSiPrefixScale(siUnitMatch?.[1]) *
152
+ MILS_PER_METER
153
+ )
154
+ }
155
+
156
+ /**
157
+ * Resolves one STEP SI length prefix to a meter multiplier.
158
+ * @param {string | undefined} prefix STEP SI prefix.
159
+ * @returns {number}
160
+ */
161
+ static #resolveSiPrefixScale(prefix) {
162
+ const scales = {
163
+ '.EXA.': 1e18,
164
+ '.PETA.': 1e15,
165
+ '.TERA.': 1e12,
166
+ '.GIGA.': 1e9,
167
+ '.MEGA.': 1e6,
168
+ '.KILO.': 1e3,
169
+ '.HECTO.': 1e2,
170
+ '.DECA.': 1e1,
171
+ $: 1,
172
+ '.DECI.': 1e-1,
173
+ '.CENTI.': 1e-2,
174
+ '.MILLI.': 1e-3,
175
+ '.MICRO.': 1e-6,
176
+ '.NANO.': 1e-9,
177
+ '.PICO.': 1e-12,
178
+ '.FEMTO.': 1e-15,
179
+ '.ATTO.': 1e-18
180
+ }
181
+
182
+ return scales[prefix || '.MILLI.'] ?? 1e-3
183
+ }
184
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { PcbScene3dBuilder } from './PcbScene3dBuilder.mjs'
5
+ import { PcbScene3dModelRegistry } from './PcbScene3dModelRegistry.mjs'
6
+
7
+ /**
8
+ * Builds converged renderer-ready scene descriptions for async preprocessing.
9
+ */
10
+ export class PcbScene3dScenePreparator {
11
+ /**
12
+ * Builds one scene description with the converged registry and builder.
13
+ * @param {object} documentModel Parsed Altium document model.
14
+ * @param {{ sessionAssets?: object[], modelRegistry?: object | null, buildScene?: (documentModel: object, options: { modelRegistry: object }) => object }} [options] Scene preparation options.
15
+ * @returns {Promise<object>}
16
+ */
17
+ static async prepare(documentModel, options = {}) {
18
+ const modelRegistry =
19
+ options.modelRegistry ||
20
+ PcbScene3dModelRegistry.create(
21
+ options.sessionAssets || [],
22
+ Array.isArray(documentModel?.pcb?.embeddedModels)
23
+ ? documentModel.pcb.embeddedModels
24
+ : []
25
+ )
26
+ const buildScene =
27
+ options.buildScene ||
28
+ ((nextDocumentModel, buildOptions) =>
29
+ PcbScene3dBuilder.build(nextDocumentModel, buildOptions))
30
+
31
+ return buildScene(documentModel, { modelRegistry })
32
+ }
33
+ }
@@ -2,6 +2,8 @@
2
2
  // SPDX-License-Identifier: GPL-3.0-or-later
3
3
 
4
4
  import { PcbSvgRenderer as LegacyPcbSvgRenderer } from '../ui/PcbSvgRenderer.mjs'
5
+ import { PcbDrawingTextComposite } from './PcbDrawingTextComposite.mjs'
6
+ import { PcbVisibleLayerViewport } from './PcbVisibleLayerViewport.mjs'
5
7
 
6
8
  /**
7
9
  * Renders native Altium PCB models through the preserved historical renderer
@@ -19,7 +21,22 @@ export class PcbSvgRenderer {
19
21
  * @returns {string} Rendered SVG panel markup.
20
22
  */
21
23
  static render(documentModel, options = {}) {
22
- const markup = LegacyPcbSvgRenderer.render(documentModel, options)
24
+ const historicalMarkup = LegacyPcbSvgRenderer.render(
25
+ documentModel,
26
+ options
27
+ )
28
+ const drawingMarkup = PcbDrawingTextComposite.apply(
29
+ historicalMarkup,
30
+ documentModel,
31
+ options
32
+ )
33
+ const markup = PcbVisibleLayerViewport.apply(
34
+ drawingMarkup,
35
+ documentModel,
36
+ options,
37
+ (filteredDocument, filteredOptions) =>
38
+ LegacyPcbSvgRenderer.render(filteredDocument, filteredOptions)
39
+ )
23
40
  const subsurfacePadIndexes = PcbSvgRenderer.#subsurfacePadIndexes(
24
41
  documentModel,
25
42
  options
@@ -0,0 +1,129 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { PcbConvergenceLayerModel } from './PcbConvergenceLayerModel.mjs'
5
+
6
+ /**
7
+ * Replaces PCB viewport bounds with bounds rendered from visible layers.
8
+ */
9
+ export class PcbVisibleLayerViewport {
10
+ /**
11
+ * Applies visible-layer viewBox bounds while retaining original SVG markup.
12
+ * @param {string} markup Complete SVG panel markup.
13
+ * @param {object} documentModel PCB document.
14
+ * @param {{ hiddenLayers?: (string | number)[] }} options Render options.
15
+ * @param {(documentModel: object, options: object) => string} renderHistorical Historical render callback.
16
+ * @returns {string}
17
+ */
18
+ static apply(markup, documentModel, options, renderHistorical) {
19
+ const hidden = new Set(
20
+ (Array.isArray(options?.hiddenLayers) ? options.hiddenLayers : [])
21
+ .map(PcbConvergenceLayerModel.normalize)
22
+ .filter(Boolean)
23
+ )
24
+ if (!hidden.size || !documentModel?.pcb) return markup
25
+
26
+ const filteredDocument = PcbVisibleLayerViewport.#filterDocument(
27
+ documentModel,
28
+ hidden
29
+ )
30
+ const filteredMarkup = renderHistorical(filteredDocument, {
31
+ ...options,
32
+ hiddenLayers: undefined
33
+ })
34
+ const viewBox = filteredMarkup.match(
35
+ /<svg class="pcb-svg" viewBox="([^"]+)"/u
36
+ )?.[1]
37
+ if (!viewBox) return markup
38
+
39
+ return markup.replace(
40
+ /(<svg class="pcb-svg"[^>]*\sviewBox=")[^"]+("[^>]*>)/u,
41
+ '$1' + viewBox + '$2'
42
+ )
43
+ }
44
+
45
+ /**
46
+ * Creates a shallow document clone with hidden-layer primitives removed.
47
+ * @param {object} documentModel Source document.
48
+ * @param {Set<string>} hidden Hidden layer aliases.
49
+ * @returns {object}
50
+ */
51
+ static #filterDocument(documentModel, hidden) {
52
+ const pcb = documentModel.pcb
53
+ const lookup = PcbConvergenceLayerModel.buildLookup(documentModel)
54
+ const visible = (primitive) =>
55
+ !PcbVisibleLayerViewport.#isHidden(primitive, lookup, hidden)
56
+ const nearBoard = (component) =>
57
+ PcbVisibleLayerViewport.#isComponentNearBoard(
58
+ component,
59
+ pcb.boardOutline
60
+ )
61
+ const filterKeys = [
62
+ 'polygons',
63
+ 'fills',
64
+ 'tracks',
65
+ 'arcs',
66
+ 'regions',
67
+ 'shapeBasedRegions',
68
+ 'vias',
69
+ 'pads',
70
+ 'texts',
71
+ 'dimensions'
72
+ ]
73
+ const filteredPcb = { ...pcb }
74
+ for (const key of filterKeys) {
75
+ filteredPcb[key] = (pcb[key] || []).filter(visible)
76
+ }
77
+ filteredPcb.components = (pcb.components || []).filter(
78
+ (component) => visible(component) && nearBoard(component)
79
+ )
80
+ return { ...documentModel, pcb: filteredPcb }
81
+ }
82
+
83
+ /**
84
+ * Returns true when one primitive belongs to a hidden layer.
85
+ * @param {object} primitive Primitive record.
86
+ * @param {object} lookup Layer lookup.
87
+ * @param {Set<string>} hidden Hidden aliases.
88
+ * @returns {boolean}
89
+ */
90
+ static #isHidden(primitive, lookup, hidden) {
91
+ const layer = PcbConvergenceLayerModel.layerForPrimitive(
92
+ primitive,
93
+ lookup
94
+ )
95
+ const aliases = [
96
+ ...PcbConvergenceLayerModel.aliases(layer),
97
+ primitive?.layer,
98
+ primitive?.layerName,
99
+ primitive?.layerId,
100
+ primitive?.layerCode
101
+ ]
102
+ .map(PcbConvergenceLayerModel.normalize)
103
+ .filter(Boolean)
104
+ return aliases.some((alias) => hidden.has(alias))
105
+ }
106
+
107
+ /**
108
+ * Returns true for placements near enough to affect a board-first fit.
109
+ * @param {object} component Component placement.
110
+ * @param {object} outline Board outline.
111
+ * @returns {boolean}
112
+ */
113
+ static #isComponentNearBoard(component, outline) {
114
+ const x = Number(component?.x)
115
+ const y = Number(component?.y)
116
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return true
117
+ const minX = Number(outline?.minX || 0)
118
+ const minY = Number(outline?.minY || 0)
119
+ const maxX = minX + Number(outline?.widthMil || 0)
120
+ const maxY = minY + Number(outline?.heightMil || 0)
121
+ const tolerance = 240
122
+ return (
123
+ x >= minX - tolerance &&
124
+ x <= maxX + tolerance &&
125
+ y >= minY - tolerance &&
126
+ y <= maxY + tolerance
127
+ )
128
+ }
129
+ }
@@ -4,9 +4,17 @@
4
4
  export * from 'circuitjson-toolkit/extensions'
5
5
 
6
6
  export { AltiumExtensionResolver } from './convergence/AltiumExtensionResolver.mjs'
7
+ export { PcbScene3dBuilder } from './convergence/PcbScene3dBuilder.mjs'
8
+ export { PcbScene3dModelRegistry } from './convergence/PcbScene3dModelRegistry.mjs'
9
+ export { PcbScene3dScenePreparator } from './convergence/PcbScene3dScenePreparator.mjs'
7
10
  export { PcbSvgRenderer } from './convergence/PcbSvgRenderer.mjs'
8
11
  export { SchematicSvgRenderer } from './convergence/SchematicSvgRenderer.mjs'
9
12
  export * from './legacy-parser.mjs'
10
13
  export * from './legacy-netlist-query.mjs'
11
14
  export * from './legacy-renderers.mjs'
12
- export * from './legacy-scene3d.mjs'
15
+ export {
16
+ AltiumScene3dAuthoredBodyAnchorAdapter,
17
+ PcbScene3dPackages,
18
+ PcbScene3dSummaryRenderer,
19
+ PcbScene3dTextBoxLayoutResolver
20
+ } from './legacy-scene3d.mjs'