pcb-scene3d-viewer 1.1.49 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +46 -21
  2. package/docs/api.md +109 -8
  3. package/docs/circuitjson.md +143 -29
  4. package/docs/model-format.md +50 -7
  5. package/docs/release-notes-v1.2.0.md +122 -0
  6. package/docs/testing.md +30 -1
  7. package/package.json +6 -3
  8. package/spec/library-scope.md +10 -2
  9. package/src/CircuitJsonCadModelAssetResolver.mjs +697 -83
  10. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +43 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +40 -18
  12. package/src/PcbAssemblyModelMeshLoader.mjs +101 -150
  13. package/src/PcbAssemblyPadMeshBuilder.mjs +22 -0
  14. package/src/PcbModelArchiveExporter.mjs +28 -112
  15. package/src/PcbModelArchiveSourceBundle.mjs +326 -0
  16. package/src/PcbScene3dAabbIndex.mjs +464 -0
  17. package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -1
  18. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +9 -5
  19. package/src/PcbScene3dBoardMaterialPalette.mjs +29 -0
  20. package/src/PcbScene3dBoardShapeFactory.mjs +11 -118
  21. package/src/PcbScene3dBoardSolderMaskFactory.mjs +65 -39
  22. package/src/PcbScene3dCircuitJsonAdapter.mjs +151 -48
  23. package/src/PcbScene3dCircuitJsonDrillDetail.mjs +31 -0
  24. package/src/PcbScene3dCircuitJsonGeometry.mjs +184 -33
  25. package/src/PcbScene3dCircuitJsonInput.mjs +132 -0
  26. package/src/PcbScene3dCircuitJsonModelAsset.mjs +40 -0
  27. package/src/PcbScene3dController.mjs +40 -34
  28. package/src/PcbScene3dCopperFactory.mjs +36 -22
  29. package/src/PcbScene3dCopperFillAreaClipper.mjs +133 -235
  30. package/src/PcbScene3dCopperFillCoverageContext.mjs +204 -0
  31. package/src/PcbScene3dCopperFillLoopSetResolver.mjs +192 -0
  32. package/src/PcbScene3dCopperFillMeshBuilder.mjs +77 -295
  33. package/src/PcbScene3dCopperTextFactory.mjs +12 -4
  34. package/src/PcbScene3dCutoutCircleDetector.mjs +34 -17
  35. package/src/PcbScene3dCutoutGeometryFilter.mjs +104 -269
  36. package/src/PcbScene3dCutoutGridIndex.mjs +184 -0
  37. package/src/PcbScene3dDeferredModelFinalizer.mjs +52 -0
  38. package/src/PcbScene3dDescriptorSafeRecord.mjs +38 -0
  39. package/src/PcbScene3dDrillCutoutFilter.mjs +149 -143
  40. package/src/PcbScene3dDrillPathFactory.mjs +86 -16
  41. package/src/PcbScene3dDrillVoidFactory.mjs +35 -10
  42. package/src/PcbScene3dExternalModelGroupLoader.mjs +472 -31
  43. package/src/PcbScene3dExternalModels.mjs +23 -24
  44. package/src/PcbScene3dFacetedModelGroupBuilder.mjs +217 -0
  45. package/src/PcbScene3dGeometryZCompressor.mjs +4 -2
  46. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +18 -4
  47. package/src/PcbScene3dMaskCoveredCopperSurfaceFilter.mjs +75 -10
  48. package/src/PcbScene3dModelContent.mjs +236 -0
  49. package/src/PcbScene3dModelFetchPolicy.mjs +304 -0
  50. package/src/PcbScene3dModelIdentity.mjs +106 -0
  51. package/src/PcbScene3dPlatedDrillSpecResolver.mjs +141 -0
  52. package/src/PcbScene3dPreparedPolygon.mjs +709 -0
  53. package/src/PcbScene3dPreparedPolygonSet.mjs +70 -0
  54. package/src/PcbScene3dRuntime.mjs +45 -45
  55. package/src/PcbScene3dRuntimeBoardMeshes.mjs +85 -1
  56. package/src/PcbScene3dShapeHoleGeometryCleaner.mjs +4 -2
  57. package/src/PcbScene3dShellRenderer.mjs +75 -6
  58. package/src/PcbScene3dSilkscreenCutoutContext.mjs +255 -0
  59. package/src/PcbScene3dSilkscreenFactory.mjs +87 -127
  60. package/src/PcbScene3dSilkscreenFillSeamBuilder.mjs +11 -5
  61. package/src/PcbScene3dStepLoader.mjs +11 -10
  62. package/src/PcbScene3dText.mjs +1 -1
  63. package/src/PcbScene3dTriangleVertexQueryBounds.mjs +302 -0
@@ -23,18 +23,68 @@ export class PcbScene3dCircuitJsonGeometry {
23
23
  * @returns {object}
24
24
  */
25
25
  static buildBoard(index, options = {}) {
26
- const explicitBoardElement =
27
- index.elementsByType.get('pcb_panel')?.[0] ||
28
- index.elementsByType.get('pcb_board')?.[0]
29
- const boardElement =
30
- explicitBoardElement ||
31
- PcbScene3dCircuitJsonGeometry.#fauxBoardElement(index, options) ||
32
- {}
33
- const widthMil = CircuitJsonUnits.mmToMil(
26
+ const panelElements = index.elementsByType.get('pcb_panel') || []
27
+ const boardElements = index.elementsByType.get('pcb_board') || []
28
+ const explicitElements = panelElements.length
29
+ ? panelElements
30
+ : boardElements
31
+ const elements = explicitElements.length
32
+ ? explicitElements
33
+ : [
34
+ PcbScene3dCircuitJsonGeometry.#fauxBoardElement(
35
+ index,
36
+ options
37
+ ) || {}
38
+ ]
39
+ const sourceContours = elements.map((element) =>
40
+ PcbScene3dCircuitJsonGeometry.#buildBoardContour(
41
+ index,
42
+ element,
43
+ options
44
+ )
45
+ )
46
+ const bounds =
47
+ PcbScene3dCircuitJsonGeometry.#contourBounds(sourceContours)
48
+ const centerX = (bounds.minX + bounds.maxX) / 2
49
+ const centerY = (bounds.minY + bounds.maxY) / 2
50
+ const contours = sourceContours.map((contour) => ({
51
+ ...contour,
52
+ centerX,
53
+ centerY
54
+ }))
55
+ const primary = contours[0]
56
+
57
+ return {
58
+ widthMil: bounds.maxX - bounds.minX,
59
+ heightMil: bounds.maxY - bounds.minY,
60
+ thicknessMil: Math.max(
61
+ ...contours.map((contour) => contour.thicknessMil)
62
+ ),
63
+ minX: bounds.minX,
64
+ minY: bounds.minY,
65
+ centerX,
66
+ centerY,
67
+ segments: primary.segments,
68
+ cutouts: PcbScene3dCircuitJsonGeometry.#uniqueCutouts(contours),
69
+ contours,
70
+ surfaceColor: null,
71
+ edgeColor: null
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Builds one independently renderable board or panel contour.
77
+ * @param {{ elementsByType: Map<string, object[]> }} index CircuitJSON index.
78
+ * @param {object} boardElement Board or panel element.
79
+ * @param {{ boardDrillQuality?: string }} options Adapter options.
80
+ * @returns {object}
81
+ */
82
+ static #buildBoardContour(index, boardElement, options) {
83
+ const declaredWidthMil = CircuitJsonUnits.mmToMil(
34
84
  boardElement.width,
35
85
  DEFAULT_BOARD_WIDTH_MM
36
86
  )
37
- const heightMil = CircuitJsonUnits.mmToMil(
87
+ const declaredHeightMil = CircuitJsonUnits.mmToMil(
38
88
  boardElement.height,
39
89
  DEFAULT_BOARD_HEIGHT_MM
40
90
  )
@@ -43,24 +93,35 @@ export class PcbScene3dCircuitJsonGeometry {
43
93
  DEFAULT_BOARD_THICKNESS_MM
44
94
  )
45
95
  const center = CircuitJsonUnits.pointMmToMil(boardElement.center || {})
46
- const minX = center.x - widthMil / 2
47
- const minY = center.y - heightMil / 2
96
+ const fallback = {
97
+ minX: center.x - declaredWidthMil / 2,
98
+ minY: center.y - declaredHeightMil / 2,
99
+ widthMil: declaredWidthMil,
100
+ heightMil: declaredHeightMil
101
+ }
48
102
  const segments = PcbScene3dCircuitJsonGeometry.#buildBoardSegments(
49
103
  boardElement,
50
- {
51
- minX,
52
- minY,
53
- widthMil,
54
- heightMil
55
- }
104
+ fallback
105
+ )
106
+ const bounds = PcbScene3dCircuitJsonGeometry.#segmentBounds(
107
+ segments,
108
+ fallback
56
109
  )
57
110
 
58
111
  return {
59
- widthMil,
60
- heightMil,
112
+ sourceId: String(
113
+ boardElement?.pcb_panel_id ||
114
+ boardElement?.panel_id ||
115
+ boardElement?.pcb_board_id ||
116
+ boardElement?.board_id ||
117
+ ''
118
+ ),
119
+ sourceType: String(boardElement?.type || 'pcb_board'),
120
+ widthMil: bounds.maxX - bounds.minX,
121
+ heightMil: bounds.maxY - bounds.minY,
61
122
  thicknessMil,
62
- minX,
63
- minY,
123
+ minX: bounds.minX,
124
+ minY: bounds.minY,
64
125
  centerX: center.x,
65
126
  centerY: center.y,
66
127
  segments,
@@ -74,6 +135,78 @@ export class PcbScene3dCircuitJsonGeometry {
74
135
  }
75
136
  }
76
137
 
138
+ /**
139
+ * Resolves finite bounds around one contour's ordered segments.
140
+ * @param {object[]} segments Board segments.
141
+ * @param {{ minX: number, minY: number, widthMil: number, heightMil: number }} fallback Rectangle fallback.
142
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
143
+ */
144
+ static #segmentBounds(segments, fallback) {
145
+ const points = segments.flatMap((segment) => [
146
+ { x: Number(segment?.x1), y: Number(segment?.y1) },
147
+ { x: Number(segment?.x2), y: Number(segment?.y2) }
148
+ ])
149
+ const finite = points.filter((point) =>
150
+ Number.isFinite(point.x + point.y)
151
+ )
152
+ if (!finite.length) {
153
+ return {
154
+ minX: fallback.minX,
155
+ minY: fallback.minY,
156
+ maxX: fallback.minX + fallback.widthMil,
157
+ maxY: fallback.minY + fallback.heightMil
158
+ }
159
+ }
160
+
161
+ return finite.reduce(
162
+ (bounds, point) => ({
163
+ minX: Math.min(bounds.minX, point.x),
164
+ minY: Math.min(bounds.minY, point.y),
165
+ maxX: Math.max(bounds.maxX, point.x),
166
+ maxY: Math.max(bounds.maxY, point.y)
167
+ }),
168
+ { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
169
+ )
170
+ }
171
+
172
+ /**
173
+ * Resolves aggregate bounds around every physical contour.
174
+ * @param {object[]} contours Board contours.
175
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
176
+ */
177
+ static #contourBounds(contours) {
178
+ return contours.reduce(
179
+ (bounds, contour) => ({
180
+ minX: Math.min(bounds.minX, contour.minX),
181
+ minY: Math.min(bounds.minY, contour.minY),
182
+ maxX: Math.max(bounds.maxX, contour.minX + contour.widthMil),
183
+ maxY: Math.max(bounds.maxY, contour.minY + contour.heightMil)
184
+ }),
185
+ { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
186
+ )
187
+ }
188
+
189
+ /**
190
+ * Flattens contour cutouts without duplicating global cutout rows.
191
+ * @param {object[]} contours Board contours.
192
+ * @returns {object[]}
193
+ */
194
+ static #uniqueCutouts(contours) {
195
+ const seen = new Set()
196
+ return contours
197
+ .flatMap((contour) => contour.cutouts || [])
198
+ .filter((cutout) => {
199
+ const key =
200
+ cutout.sourceId ||
201
+ JSON.stringify(
202
+ (cutout.points || []).map((point) => [point.x, point.y])
203
+ )
204
+ if (seen.has(key)) return false
205
+ seen.add(key)
206
+ return true
207
+ })
208
+ }
209
+
77
210
  /**
78
211
  * Builds a generated board element around component bounds when requested.
79
212
  * @param {{ elementsByType: Map<string, object[]> }} index CircuitJSON index.
@@ -175,43 +308,61 @@ export class PcbScene3dCircuitJsonGeometry {
175
308
  /**
176
309
  * Resolves drill opening metadata from circular, pill, and offset fields.
177
310
  * @param {object} hole CircuitJSON hole-like element.
178
- * @returns {{ center: { x: number, y: number }, diameter: number, slotLength: number, rotationDeg: number }}
311
+ * @param {object | null} [primitive] Shared normalized hole primitive.
312
+ * @returns {{ center: { x: number, y: number }, diameter: number, width: number, height: number, shape: 'circle' | 'pill' | 'rect', slotLength: number, rotationDeg: number }}
179
313
  */
180
- static holeDrillSpec(hole) {
314
+ static holeDrillSpec(hole, primitive = null) {
181
315
  const center = CircuitJsonUnits.pointMmToMil({
182
316
  x: Number(hole?.x || 0) + Number(hole?.hole_offset_x || 0),
183
317
  y: Number(hole?.y || 0) + Number(hole?.hole_offset_y || 0)
184
318
  })
185
319
  const width = CircuitJsonUnits.mmToMil(
186
- hole?.hole_width || hole?.hole_diameter,
320
+ primitive?.holeWidth || hole?.hole_width || hole?.hole_diameter,
187
321
  0
188
322
  )
189
323
  const height = CircuitJsonUnits.mmToMil(
190
- hole?.hole_height || hole?.hole_diameter || hole?.hole_width,
324
+ primitive?.holeHeight ||
325
+ hole?.hole_height ||
326
+ hole?.hole_diameter ||
327
+ hole?.hole_width,
191
328
  0
192
329
  )
193
330
  const diameter = PcbScene3dCircuitJsonGeometry.#firstPositive([
194
- hole?.hole_diameter
195
- ? CircuitJsonUnits.mmToMil(hole.hole_diameter, 0)
196
- : 0,
331
+ primitive?.holeDiameter
332
+ ? CircuitJsonUnits.mmToMil(primitive.holeDiameter, 0)
333
+ : hole?.hole_diameter
334
+ ? CircuitJsonUnits.mmToMil(hole.hole_diameter, 0)
335
+ : 0,
197
336
  Math.min(width || Infinity, height || Infinity),
198
337
  width,
199
338
  height
200
339
  ])
340
+ const shapeText = String(
341
+ primitive?.holeShape || hole?.hole_shape || ''
342
+ ).toLowerCase()
343
+ const isRect =
344
+ shapeText.includes('rect') || shapeText.includes('square')
201
345
  const isPill =
202
- String(hole?.hole_shape || '')
203
- .toLowerCase()
204
- .includes('pill') ||
205
- (width > 0 && height > 0 && Math.abs(width - height) > 0.001)
346
+ !isRect &&
347
+ (shapeText.includes('pill') ||
348
+ shapeText.includes('oval') ||
349
+ (width > 0 && height > 0 && Math.abs(width - height) > 0.001))
350
+ const shape = isRect ? 'rect' : isPill ? 'pill' : 'circle'
206
351
  const slotLength = isPill ? Math.max(width, height, diameter) : 0
207
352
  const axisRotation = isPill && height > width ? 90 : 0
208
353
 
209
354
  return {
210
355
  center,
211
356
  diameter,
357
+ width: width || diameter,
358
+ height: height || diameter,
359
+ shape,
212
360
  slotLength: slotLength > diameter ? slotLength : 0,
213
361
  rotationDeg:
214
- PcbScene3dCircuitJsonGeometry.#rotationDeg(hole) + axisRotation
362
+ Number(
363
+ primitive?.holeRotation ??
364
+ PcbScene3dCircuitJsonGeometry.#rotationDeg(hole)
365
+ ) + axisRotation
215
366
  }
216
367
  }
217
368
 
@@ -0,0 +1,132 @@
1
+ import { CircuitJsonDocumentContext } from 'circuitjson-toolkit'
2
+
3
+ /**
4
+ * Performs non-mutating CircuitJSON input detection for viewer routing.
5
+ */
6
+ export class PcbScene3dCircuitJsonInput {
7
+ /**
8
+ * Returns true for a raw model, canonical document, or prepared context.
9
+ * @param {unknown} value Candidate input.
10
+ * @returns {boolean}
11
+ */
12
+ static isModel(value) {
13
+ try {
14
+ if (value instanceof CircuitJsonDocumentContext) return true
15
+ const model = PcbScene3dCircuitJsonInput.#validationModel(value)
16
+ return Boolean(
17
+ model && PcbScene3dCircuitJsonInput.#hasElementRows(model)
18
+ )
19
+ } catch {
20
+ return false
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Checks the descriptor-safe structural boundary needed before shared
26
+ * normalization and validation prepare the model.
27
+ * @param {object[]} model Dense model candidate.
28
+ * @returns {boolean} Whether every row exposes an own string type.
29
+ */
30
+ static #hasElementRows(model) {
31
+ for (let index = 0; index < model.length; index += 1) {
32
+ const element = model[index]
33
+ const type = PcbScene3dCircuitJsonInput.#ownData(element, 'type')
34
+ if (typeof type !== 'string' || !type.trim()) return false
35
+ }
36
+ return true
37
+ }
38
+
39
+ /**
40
+ * Returns a non-mutating model view for predicate validation.
41
+ * @param {unknown} value Model or canonical document candidate.
42
+ * @returns {object[] | null}
43
+ */
44
+ static #validationModel(value) {
45
+ if (Array.isArray(value)) {
46
+ return PcbScene3dCircuitJsonInput.#plainModel(value)
47
+ }
48
+ if (
49
+ PcbScene3dCircuitJsonInput.#ownData(value, 'schema') !==
50
+ 'ecad-toolkit.document.v1'
51
+ ) {
52
+ return null
53
+ }
54
+ const model = PcbScene3dCircuitJsonInput.#ownData(value, 'model')
55
+ return Array.isArray(model)
56
+ ? PcbScene3dCircuitJsonInput.#plainModel(model)
57
+ : null
58
+ }
59
+
60
+ /**
61
+ * Removes safe legacy array metadata without freezing caller values.
62
+ * @param {object[]} model CircuitJSON model candidate.
63
+ * @returns {object[] | null}
64
+ */
65
+ static #plainModel(model) {
66
+ let prototype
67
+ let descriptors
68
+ try {
69
+ prototype = Object.getPrototypeOf(model)
70
+ descriptors = Object.getOwnPropertyDescriptors(model)
71
+ } catch {
72
+ return null
73
+ }
74
+ const length = descriptors.length?.value
75
+ if (
76
+ prototype !== Array.prototype ||
77
+ !Number.isSafeInteger(length) ||
78
+ length < 0
79
+ ) {
80
+ return null
81
+ }
82
+
83
+ const keys = Reflect.ownKeys(descriptors)
84
+ const plain = keys.length === length + 1 ? null : new Array(length)
85
+ for (let index = 0; index < length; index += 1) {
86
+ const descriptor = descriptors[String(index)]
87
+ if (
88
+ !descriptor ||
89
+ !Object.hasOwn(descriptor, 'value') ||
90
+ descriptor.enumerable !== true
91
+ ) {
92
+ return null
93
+ }
94
+ if (plain) plain[index] = descriptor.value
95
+ }
96
+ for (const key of keys) {
97
+ if (key === 'length') continue
98
+ const index =
99
+ typeof key === 'string' && /^(?:0|[1-9]\d*)$/u.test(key)
100
+ ? Number(key)
101
+ : -1
102
+ if (Number.isSafeInteger(index) && index < length) continue
103
+ const descriptor = descriptors[key]
104
+ if (
105
+ typeof key !== 'string' ||
106
+ !Object.hasOwn(descriptor, 'value') ||
107
+ descriptor.enumerable !== true
108
+ ) {
109
+ return null
110
+ }
111
+ }
112
+ return plain || model
113
+ }
114
+
115
+ /**
116
+ * Reads one own data property without invoking caller accessors.
117
+ * @param {unknown} value Record candidate.
118
+ * @param {string} name Property name.
119
+ * @returns {unknown} Own data value or undefined.
120
+ */
121
+ static #ownData(value, name) {
122
+ if (!value || typeof value !== 'object') return undefined
123
+ try {
124
+ const descriptor = Object.getOwnPropertyDescriptor(value, name)
125
+ return descriptor && Object.hasOwn(descriptor, 'value')
126
+ ? descriptor.value
127
+ : undefined
128
+ } catch {
129
+ return undefined
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Normalizes canonical CircuitJSON CAD model asset references.
3
+ */
4
+ export class PcbScene3dCircuitJsonModelAsset {
5
+ /**
6
+ * Resolves one viewer-supported canonical model asset reference.
7
+ * @param {unknown} asset Model asset candidate.
8
+ * @returns {{ format: string, sourceUrl: string } | null}
9
+ */
10
+ static reference(asset) {
11
+ if (!asset || typeof asset !== 'object') return null
12
+ const sourceUrl = String(
13
+ asset.project_relative_path || asset.url || ''
14
+ ).trim()
15
+ const format = PcbScene3dCircuitJsonModelAsset.#format(asset, sourceUrl)
16
+ return sourceUrl && format ? { format, sourceUrl } : null
17
+ }
18
+
19
+ /**
20
+ * Resolves a supported CAD format from canonical and retained metadata.
21
+ * @param {object} asset Model asset metadata.
22
+ * @param {string} sourceUrl Canonical model asset path.
23
+ * @returns {string}
24
+ */
25
+ static #format(asset, sourceUrl) {
26
+ const text = [asset.format, asset.mimetype, sourceUrl, asset.url]
27
+ .map(String)
28
+ .join(' ')
29
+ .toLowerCase()
30
+ if (/\bglb\b|\.glb(?:[?#]|$)/u.test(text)) return 'glb'
31
+ if (/\bgltf\b|\.gltf(?:[?#]|$)/u.test(text)) return 'gltf'
32
+ if (/\b3mf\b|\.3mf(?:[?#]|$)/u.test(text)) return '3mf'
33
+ if (/\bobj\b|\.obj(?:[?#]|$)/u.test(text)) return 'obj'
34
+ if (/\bstl\b|\.stl(?:[?#]|$)/u.test(text)) return 'stl'
35
+ if (/\bwrl\b|vrml|\.wrl(?:[?#]|$)/u.test(text)) return 'wrl'
36
+ return /step|stp|\.step(?:[?#]|$)|\.stp(?:[?#]|$)/u.test(text)
37
+ ? 'step'
38
+ : ''
39
+ }
40
+ }
@@ -15,25 +15,19 @@ import { PcbScene3dText } from './PcbScene3dText.mjs'
15
15
  export class PcbScene3dController {
16
16
  /** @type {HTMLElement | null} */
17
17
  #viewportNode
18
-
19
18
  /** @type {any} */
20
19
  #documentModel
21
-
22
20
  /** @type {HTMLElement | null} */
23
21
  #rootNode
24
-
25
22
  /** @type {HTMLElement | null} */
26
23
  #diagnosticsNode
27
-
28
24
  /** @type {HTMLElement | null} */
29
25
  #selectionNode
30
-
31
26
  /** @type {HTMLElement | null} */
32
27
  #adjustmentHostNode
33
28
 
34
29
  /** @type {PcbScene3dAdjustmentControlBinder | null} */
35
30
  #adjustmentControls
36
-
37
31
  /** @type {PcbScene3dSelectionVisibilityBinder | null} */
38
32
  #visibilityControls
39
33
  /** @type {Array<{ node: EventTarget, type: string, listener: (event: any) => void }>} */
@@ -41,19 +35,16 @@ export class PcbScene3dController {
41
35
 
42
36
  /** @type {Map<string, { component: any | null, externalPlacement: any | null, staticBodyPlacement: any | null }>} */
43
37
  #selectionIndex
44
-
45
38
  /** @type {Map<string, { scale: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number } }>} */
46
39
  #componentAdjustments
47
-
48
40
  /** @type {string} */
49
41
  #selectedComponentKey
50
-
51
42
  /** @type {any | null} */
52
43
  #runtime
53
-
54
44
  /** @type {any | null} */
55
45
  #sceneDescription
56
-
46
+ /** @type {object | undefined} */
47
+ #modelLoaderOptions
57
48
  /** @type {{ prepareScene?: (documentModel: any, sessionAssets?: any[]) => Promise<any>, dispose?: () => void } | null} */
58
49
  #scenePrepClient
59
50
 
@@ -87,7 +78,7 @@ export class PcbScene3dController {
87
78
  /**
88
79
  * @param {HTMLElement} viewportNode
89
80
  * @param {any} documentModel
90
- * @param {{ rootNode?: HTMLElement | null, documentId?: string, onComponentSelectionChange?: ((change: { documentId: string, componentKey: string, source?: string }) => void) | null, sessionAssets?: any[], autoSearchMissingModels?: boolean, circuitJson?: object[], sceneDescription?: any, buildScene?: (documentModel: any, options: { modelRegistry: any }) => any, createModelRegistry?: (documentModel: any, sessionAssets: any[]) => any, createRuntime?: (viewportNode: HTMLElement, sceneDescription: any, hooks: { setDiagnostics: (messages: string[]) => void, setSelection: (selection: any | null) => void, translate?: ((key: string) => string) | null }) => { setPreset?: (preset: string) => void, setToggle?: (toggleName: string, enabled: boolean) => void, dispose?: () => void, whenReady?: () => Promise<void> | void }, scenePrepClient?: { prepareScene?: (documentModel: any, sessionAssets?: any[]) => Promise<any>, dispose?: () => void } | null, exportArchive?: (options: { archiveBaseName?: string, sceneDescription?: any }) => Promise<{ archiveName: string, archiveBytes: Uint8Array, exportedEntries: any[], skippedEntries: any[] }>, downloadArchive?: (archiveName: string, archiveBytes: Uint8Array) => Promise<void> | void, renderAdjustmentControlsInSelection?: boolean, setLoadingVisible?: (visible: boolean) => void, translate?: ((key: string) => string) | null }} [options]
81
+ * @param {{ rootNode?: HTMLElement | null, documentId?: string, onComponentSelectionChange?: ((change: { documentId: string, componentKey: string, source?: string }) => void) | null, sessionAssets?: any[], autoSearchMissingModels?: boolean, circuitJson?: object[], sceneDescription?: any, modelLoaderOptions?: object, buildScene?: (documentModel: any, options: { modelRegistry: any }) => any, createModelRegistry?: (documentModel: any, sessionAssets: any[]) => any, createRuntime?: (viewportNode: HTMLElement, sceneDescription: any, hooks: { setDiagnostics: (messages: string[]) => void, setSelection: (selection: any | null) => void, translate?: ((key: string) => string) | null, modelLoaderOptions?: object }) => { setPreset?: (preset: string) => void, setToggle?: (toggleName: string, enabled: boolean) => void, dispose?: () => void, whenReady?: () => Promise<void> | void }, scenePrepClient?: { prepareScene?: (documentModel: any, sessionAssets?: any[]) => Promise<any>, dispose?: () => void } | null, exportArchive?: (options: { archiveBaseName?: string, sceneDescription?: any }) => Promise<{ archiveName: string, archiveBytes: Uint8Array, exportedEntries: any[], skippedEntries: any[] }>, downloadArchive?: (archiveName: string, archiveBytes: Uint8Array) => Promise<void> | void, renderAdjustmentControlsInSelection?: boolean, setLoadingVisible?: (visible: boolean) => void, translate?: ((key: string) => string) | null }} [options]
91
82
  */
92
83
  constructor(viewportNode, documentModel, options = {}) {
93
84
  this.#viewportNode = viewportNode
@@ -107,6 +98,7 @@ export class PcbScene3dController {
107
98
  this.#listeners = []
108
99
  this.#scenePrepClient = options.scenePrepClient || null
109
100
  this.#sceneDescription = null
101
+ this.#modelLoaderOptions = options.modelLoaderOptions
110
102
  this.#exportArchive =
111
103
  options.exportArchive ||
112
104
  ((exportOptions) =>
@@ -188,15 +180,6 @@ export class PcbScene3dController {
188
180
  this.#visibilityControls.bindSelectionNode(this.#selectionNode)
189
181
  this.#setSelection(null)
190
182
  this.#setLoadingVisible(true)
191
- const circuitJsonModel = PcbScene3dController.#resolveCircuitJsonModel(
192
- options,
193
- this.#documentModel
194
- )
195
- if (circuitJsonModel) {
196
- this.#initializePreparedScene(circuitJsonModel, options)
197
- return
198
- }
199
-
200
183
  if (options.sceneDescription) {
201
184
  this.#initializePreparedScene(options.sceneDescription, options)
202
185
  return
@@ -207,6 +190,15 @@ export class PcbScene3dController {
207
190
  return
208
191
  }
209
192
 
193
+ const circuitJsonModel = PcbScene3dController.#resolveCircuitJsonModel(
194
+ options,
195
+ this.#documentModel
196
+ )
197
+ if (circuitJsonModel) {
198
+ this.#initializePreparedScene(circuitJsonModel, options)
199
+ return
200
+ }
201
+
210
202
  this.#initializeSceneSync(options)
211
203
  }
212
204
 
@@ -427,6 +419,12 @@ export class PcbScene3dController {
427
419
  * @returns {any}
428
420
  */
429
421
  #prepareSceneDescriptionSync(options) {
422
+ const circuitJsonModel = PcbScene3dController.#resolveCircuitJsonModel(
423
+ options,
424
+ this.#documentModel
425
+ )
426
+ if (circuitJsonModel) return circuitJsonModel
427
+
430
428
  const buildScene = options.buildScene
431
429
  if (typeof buildScene !== 'function') {
432
430
  throw new Error(
@@ -450,12 +448,14 @@ export class PcbScene3dController {
450
448
  /**
451
449
  * Mounts the runtime for one prepared scene description.
452
450
  * @param {any} sceneDescription
453
- * @param {{ createRuntime?: (viewportNode: HTMLElement, sceneDescription: any, hooks: { setDiagnostics: (messages: string[]) => void, setSelection: (selection: any | null) => void, translate?: ((key: string) => string) | null }) => { setPreset?: (preset: string) => void, setToggle?: (toggleName: string, enabled: boolean) => void, dispose?: () => void, whenReady?: () => Promise<void> | void } }} options
451
+ * @param {{ modelLoaderOptions?: object, createRuntime?: (viewportNode: HTMLElement, sceneDescription: any, hooks: { setDiagnostics: (messages: string[]) => void, setSelection: (selection: any | null) => void, translate?: ((key: string) => string) | null, modelLoaderOptions?: object }) => { setPreset?: (preset: string) => void, setToggle?: (toggleName: string, enabled: boolean) => void, dispose?: () => void, whenReady?: () => Promise<void> | void } }} options
454
452
  * @returns {void}
455
453
  */
456
454
  #mountScene(sceneDescription, options) {
457
- const renderModel =
458
- PcbScene3dController.#normalizeSceneDescription(sceneDescription)
455
+ const renderModel = PcbScene3dController.#normalizeSceneDescription(
456
+ sceneDescription,
457
+ options
458
+ )
459
459
  this.#sceneDescription = renderModel
460
460
  this.#selectionIndex =
461
461
  PcbScene3dSelectionIndexBuilder.build(renderModel)
@@ -472,7 +472,8 @@ export class PcbScene3dController {
472
472
  setDiagnostics: (messages) => this.#setDiagnostics(messages),
473
473
  setSelection: (selection) =>
474
474
  this.#handleRuntimeSelection(selection),
475
- translate: this.#translate
475
+ translate: this.#translate,
476
+ modelLoaderOptions: options.modelLoaderOptions
476
477
  })
477
478
  this.#applyInitialToggles()
478
479
  if (!this.#autoSearchMissingModels) {
@@ -621,7 +622,8 @@ export class PcbScene3dController {
621
622
  archiveBaseName: PcbScene3dController.#resolveArchiveBaseName(
622
623
  this.#documentModel
623
624
  ),
624
- sceneDescription: this.#sceneDescription
625
+ sceneDescription: this.#sceneDescription,
626
+ modelLoaderOptions: this.#modelLoaderOptions
625
627
  })
626
628
 
627
629
  if (this.#isDisposed) {
@@ -908,35 +910,34 @@ export class PcbScene3dController {
908
910
  if (
909
911
  PcbScene3dCircuitJsonAdapter.isCircuitJsonModel(options.circuitJson)
910
912
  ) {
911
- return options.circuitJson
913
+ return PcbScene3dCircuitJsonAdapter.prepare(options.circuitJson)
912
914
  }
913
915
  if (
914
916
  PcbScene3dCircuitJsonAdapter.isDirectCircuitJsonModel(documentModel)
915
917
  ) {
916
- return documentModel
918
+ return PcbScene3dCircuitJsonAdapter.prepare(documentModel)
917
919
  }
918
920
  return null
919
921
  }
920
922
 
921
923
  /**
922
- * Converts direct CircuitJSON inputs into the renderer scene model.
923
924
  * @param {any} sceneDescription Scene description or CircuitJSON model.
925
+ * @param {object} options Controller options forwarded to CircuitJSON adaptation.
924
926
  * @returns {any}
925
927
  */
926
- static #normalizeSceneDescription(sceneDescription) {
928
+ static #normalizeSceneDescription(sceneDescription, options) {
927
929
  const normalizedScene =
928
930
  PcbScene3dCircuitJsonAdapter.isDirectCircuitJsonModel(
929
931
  sceneDescription
930
932
  )
931
- ? PcbScene3dCircuitJsonAdapter.build(sceneDescription)
933
+ ? PcbScene3dCircuitJsonAdapter.build(sceneDescription, options)
932
934
  : sceneDescription
933
-
934
935
  return PcbScene3dExternalPlacementDefaults.apply(normalizedScene)
935
936
  }
936
937
 
937
938
  /**
938
939
  * Resolves one archive base name from the mounted document metadata.
939
- * @param {{ summary?: { title?: string }, fileName?: string } | null} documentModel
940
+ * @param {{ summary?: { title?: string }, fileName?: string, source?: { fileName?: string }, document?: { source?: { fileName?: string } } } | null} documentModel
940
941
  * @returns {string}
941
942
  */
942
943
  static #resolveArchiveBaseName(documentModel) {
@@ -945,7 +946,12 @@ export class PcbScene3dController {
945
946
  return summaryTitle
946
947
  }
947
948
 
948
- const fileName = String(documentModel?.fileName || '').trim()
949
+ const fileName = String(
950
+ documentModel?.source?.fileName ||
951
+ documentModel?.document?.source?.fileName ||
952
+ documentModel?.fileName ||
953
+ ''
954
+ ).trim()
949
955
  if (fileName) {
950
956
  return fileName.replace(/\.[^.]+$/, '')
951
957
  }