circuitjson-toolkit 1.0.17 → 1.1.1

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 (155) hide show
  1. package/NOTICE.md +31 -0
  2. package/README.md +274 -107
  3. package/docs/api.md +507 -96
  4. package/docs/capabilities.md +70 -0
  5. package/docs/migration/behaviors.md +45 -0
  6. package/docs/migration/parser.md +60 -0
  7. package/docs/migration/renderers.md +515 -0
  8. package/docs/migration/root.md +740 -0
  9. package/docs/migration.md +126 -0
  10. package/docs/model-format.md +175 -57
  11. package/docs/provenance.md +206 -0
  12. package/docs/release-notes-v1.1.0.md +154 -0
  13. package/docs/release-notes-v1.1.1.md +24 -0
  14. package/docs/testing.md +122 -7
  15. package/package.json +32 -5
  16. package/spec/api-baseline-v1.0.17.json +1 -0
  17. package/spec/baseline-provenance-v1.0.17.json +7 -0
  18. package/spec/circuitjson-schema-snapshot.json +321 -0
  19. package/spec/circuitjson-schema-source.json +28 -0
  20. package/spec/feature-preservation.json +1 -0
  21. package/spec/library-scope.md +27 -20
  22. package/src/capabilities.mjs +1 -0
  23. package/src/core/ArchiveEntryPath.mjs +93 -0
  24. package/src/core/ArchiveLimits.mjs +31 -0
  25. package/src/core/ArchiveLimitsValidator.mjs +107 -0
  26. package/src/core/AsyncInputOwnership.mjs +56 -0
  27. package/src/core/AttachedValueLimits.mjs +67 -0
  28. package/src/core/CircuitJsonDiagnosticIndexer.mjs +184 -0
  29. package/src/core/CircuitJsonDocument.mjs +19 -61
  30. package/src/core/CircuitJsonElementTypes.mjs +10 -0
  31. package/src/core/CircuitJsonElementValidator.mjs +98 -847
  32. package/src/core/CircuitJsonIndexer.mjs +274 -194
  33. package/src/core/CircuitJsonManufacturingBuilder.mjs +167 -164
  34. package/src/core/CircuitJsonParser.mjs +75 -13
  35. package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +12 -6
  36. package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +108 -10
  37. package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +1 -1
  38. package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +44 -38
  39. package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +146 -28
  40. package/src/core/CircuitJsonPcbPrimitiveFields.mjs +70 -4
  41. package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +18 -2
  42. package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +26 -9
  43. package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +7 -6
  44. package/src/core/CircuitJsonSerializedInputAudit.mjs +87 -0
  45. package/src/core/CircuitJsonSourceMetadata.mjs +5 -1
  46. package/src/core/CircuitJsonSupportMatrixBuilder.mjs +3 -1
  47. package/src/core/CircuitJsonToolkitElementSchema.mjs +218 -0
  48. package/src/core/CircuitJsonUnitParsers.mjs +101 -0
  49. package/src/core/CircuitJsonUnits.mjs +13 -87
  50. package/src/core/CircuitJsonUpstreamSchema.mjs +9 -0
  51. package/src/core/CircuitJsonUpstreamValidator.mjs +418 -0
  52. package/src/core/CircuitJsonValidationUnits.mjs +6 -0
  53. package/src/core/ManufacturingService.mjs +323 -0
  54. package/src/core/Parser.mjs +343 -0
  55. package/src/core/ParserOptions.mjs +333 -0
  56. package/src/core/PcbBoundsSelectionModel.mjs +55 -19
  57. package/src/core/PcbDiagnosticFocusModel.mjs +42 -11
  58. package/src/core/PcbInteractionIndex.mjs +368 -0
  59. package/src/core/PcbInteractionPrimitiveModel.mjs +393 -62
  60. package/src/core/ProjectAsyncInputOwner.mjs +70 -0
  61. package/src/core/ProjectLoader.mjs +975 -0
  62. package/src/core/SimulationService.mjs +790 -0
  63. package/src/core/ToolkitCapabilities.mjs +130 -0
  64. package/src/core/ZipArchiveInspector.mjs +649 -0
  65. package/src/core/context/BinaryDataSnapshot.mjs +217 -0
  66. package/src/core/context/CircuitJsonContextIndexes.mjs +96 -0
  67. package/src/core/context/CircuitJsonDerivedCache.mjs +114 -0
  68. package/src/core/context/CircuitJsonDocumentContext.mjs +353 -0
  69. package/src/core/context/CircuitJsonLegacyModel.mjs +147 -0
  70. package/src/core/context/CircuitJsonLegacyNormalizer.mjs +847 -0
  71. package/src/core/context/CircuitJsonMetadataBoundary.mjs +76 -0
  72. package/src/core/context/CircuitJsonModelFreezeTraversal.mjs +179 -0
  73. package/src/core/context/CircuitJsonReadOnlyDocument.mjs +920 -0
  74. package/src/core/context/CircuitJsonSchematicTableNormalizer.mjs +314 -0
  75. package/src/core/context/CircuitJsonValidationAuthority.mjs +39 -0
  76. package/src/core/context/CircuitJsonValidationProof.mjs +217 -0
  77. package/src/core/context/PcbPrimitivePreparation.mjs +198 -0
  78. package/src/core/context/PcbSpatialIndex.mjs +701 -0
  79. package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +128 -0
  80. package/src/core/context/StructuredDataSnapshot.mjs +683 -0
  81. package/src/core/contracts/DocumentResult.mjs +198 -0
  82. package/src/core/contracts/ProjectResult.mjs +96 -0
  83. package/src/core/contracts/RuntimeProxyBoundary.mjs +48 -0
  84. package/src/core/contracts/ToolkitAsset.mjs +493 -0
  85. package/src/core/contracts/ToolkitDiagnostic.mjs +38 -0
  86. package/src/core/contracts/ToolkitError.mjs +176 -0
  87. package/src/core/contracts/ToolkitProgress.mjs +89 -0
  88. package/src/core/interaction/CanonicalInteractionOptions.mjs +246 -0
  89. package/src/core/interaction/PcbInteractionBounds.mjs +167 -0
  90. package/src/core/query/CircuitTraversal.mjs +343 -0
  91. package/src/core/query/ComponentGrouping.mjs +275 -0
  92. package/src/core/query/QueryNetlistBuilder.mjs +306 -0
  93. package/src/core/query/QueryService.mjs +435 -0
  94. package/src/core/query/RegexPattern.mjs +75 -0
  95. package/src/core/rendering/CanonicalBomOrder.mjs +81 -0
  96. package/src/core/rendering/CanonicalBomRows.mjs +92 -0
  97. package/src/core/rendering/CanonicalRenderOptions.mjs +498 -0
  98. package/src/core/rendering/CanonicalSvgDocument.mjs +102 -0
  99. package/src/core/rendering/PcbRenderPlan.mjs +429 -0
  100. package/src/core/rendering/SchematicSheetSelector.mjs +335 -0
  101. package/src/core/scene3d/PcbScene3dBuilder.mjs +906 -0
  102. package/src/core/scene3d/PcbScene3dPreparator.mjs +47 -0
  103. package/src/core/scene3d/Scene3dAssetIndex.mjs +284 -0
  104. package/src/core/scene3d/Scene3dBoardModel.mjs +596 -0
  105. package/src/core/scene3d/Scene3dDocumentMetadata.mjs +167 -0
  106. package/src/core/scene3d/Scene3dFreeze.mjs +37 -0
  107. package/src/core/scene3d/Scene3dIdRegistry.mjs +34 -0
  108. package/src/core/scene3d/Scene3dInputPreflight.mjs +193 -0
  109. package/src/core/scene3d/Scene3dMaterials.mjs +58 -0
  110. package/src/core/scene3d/Scene3dModelReference.mjs +134 -0
  111. package/src/core/scene3d/Scene3dOptions.mjs +363 -0
  112. package/src/core/scene3d/SceneAssetResolver.mjs +441 -0
  113. package/src/core/simulation/SimulationParameterCloner.mjs +543 -0
  114. package/src/core/worker/ParserWorkerClient.mjs +999 -0
  115. package/src/core/worker/ToolkitWorkerProtocol.mjs +412 -0
  116. package/src/core/worker/WorkerRequestData.mjs +650 -0
  117. package/src/core/worker/WorkerResponseData.mjs +905 -0
  118. package/src/extensions.mjs +37 -0
  119. package/src/index.mjs +14 -9
  120. package/src/interaction.mjs +2 -0
  121. package/src/manufacturing.mjs +1 -0
  122. package/src/parser.mjs +12 -2
  123. package/src/project.mjs +5 -0
  124. package/src/query.mjs +1 -0
  125. package/src/renderers.mjs +3 -29
  126. package/src/scene3d.mjs +3 -0
  127. package/src/simulation.mjs +1 -0
  128. package/src/styles/renderers.css +24 -0
  129. package/src/testing/ToolkitContractFixtures.mjs +124 -0
  130. package/src/testing/ToolkitLoopbackWorker.mjs +174 -0
  131. package/src/testing/runToolkitContract.mjs +705 -0
  132. package/src/testing.mjs +3 -0
  133. package/src/ui/BomTableRenderer.mjs +304 -0
  134. package/src/ui/CircuitJsonPcbBoardSvgRenderer.mjs +80 -0
  135. package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +3 -5
  136. package/src/ui/CircuitJsonPcbSvgRenderer.mjs +63 -43
  137. package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +3 -5
  138. package/src/ui/CircuitJsonSchematicDebugRenderer.mjs +164 -0
  139. package/src/ui/CircuitJsonSchematicImageSvgRenderer.mjs +210 -0
  140. package/src/ui/CircuitJsonSchematicLineRenderer.mjs +86 -0
  141. package/src/ui/CircuitJsonSchematicSheetSymbolSvgRenderer.mjs +98 -0
  142. package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +117 -17
  143. package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +67 -20
  144. package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +45 -9
  145. package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +151 -148
  146. package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +4 -292
  147. package/src/ui/PcbSvgRenderer.mjs +41 -0
  148. package/src/ui/SafeSvgPaint.mjs +26 -0
  149. package/src/ui/SafeXmlText.mjs +60 -0
  150. package/src/ui/SchematicGeometryBounds.mjs +540 -0
  151. package/src/ui/SchematicSvgRenderer.mjs +110 -0
  152. package/src/ui/SchematicTableGeometry.mjs +319 -0
  153. package/src/ui/SchematicTextAnchor.mjs +55 -0
  154. package/src/ui/SchematicTextBounds.mjs +98 -0
  155. package/src/workers/parser.worker.mjs +59 -0
@@ -0,0 +1,906 @@
1
+ import { CircuitJsonIndexer } from '../CircuitJsonIndexer.mjs'
2
+ import { CircuitJsonUnits } from '../CircuitJsonUnits.mjs'
3
+ import { ToolkitDiagnostic } from '../contracts/ToolkitDiagnostic.mjs'
4
+ import { ToolkitError } from '../contracts/ToolkitError.mjs'
5
+ import { CircuitJsonDocumentContext } from '../context/CircuitJsonDocumentContext.mjs'
6
+ import { freezeScene } from './Scene3dFreeze.mjs'
7
+ import { Scene3dAssetIndex } from './Scene3dAssetIndex.mjs'
8
+ import { Scene3dBoardModel } from './Scene3dBoardModel.mjs'
9
+ import { Scene3dDocumentMetadata } from './Scene3dDocumentMetadata.mjs'
10
+ import { Scene3dInputPreflight } from './Scene3dInputPreflight.mjs'
11
+ import { Scene3dIdRegistry } from './Scene3dIdRegistry.mjs'
12
+ import { Scene3dMaterials } from './Scene3dMaterials.mjs'
13
+ import { Scene3dModelReference } from './Scene3dModelReference.mjs'
14
+ import { Scene3dOptions } from './Scene3dOptions.mjs'
15
+
16
+ const CANONICAL_SCENE_OWNERS = new WeakMap()
17
+ const ZONE_TYPES = new Set([
18
+ 'pcb_copper_pour',
19
+ 'pcb_ground_plane',
20
+ 'pcb_ground_plane_region'
21
+ ])
22
+ const PAD_TYPES = new Set(['pcb_smtpad', 'pcb_plated_hole'])
23
+ const MAIN_TYPES = new Set([
24
+ 'pcb_board',
25
+ 'pcb_component',
26
+ 'pcb_trace',
27
+ 'pcb_via',
28
+ ...PAD_TYPES,
29
+ ...ZONE_TYPES
30
+ ])
31
+ /**
32
+ * Builds canonical, data-only PCB scene descriptions from CircuitJSON.
33
+ */
34
+ export class PcbScene3dBuilder {
35
+ /**
36
+ * Builds one deterministic right-handed Z-up scene in millimeters.
37
+ * @param {unknown} input Document result, CircuitJSON model, or context.
38
+ * @param {unknown} [options] Scene options.
39
+ * @returns {object} Canonical scene description.
40
+ */
41
+ static build(input, options = {}) {
42
+ const normalized = Scene3dOptions.normalize(options)
43
+ Scene3dOptions.assertNotAborted(normalized.signal)
44
+ const assetsPreflighted = Scene3dInputPreflight.check(input, normalized)
45
+ const context = CircuitJsonDocumentContext.prepare(input, {
46
+ indexes: ['elements']
47
+ })
48
+ if (!assetsPreflighted) {
49
+ Scene3dInputPreflight.checkAssets(context.assets, normalized)
50
+ }
51
+ const base = PcbScene3dBuilder.#canonical(context, normalized)
52
+ if (normalized.fidelity === 'canonical') {
53
+ return PcbScene3dBuilder.#sceneWithPlan(
54
+ base,
55
+ PcbScene3dBuilder.#cadPlan(context, false, normalized),
56
+ false
57
+ )
58
+ }
59
+
60
+ const native = PcbScene3dBuilder.#nativePlan(context, normalized)
61
+ if (native) {
62
+ return PcbScene3dBuilder.#sceneWithPlan(base, native, true)
63
+ }
64
+ return PcbScene3dBuilder.#sceneWithPlan(
65
+ base,
66
+ PcbScene3dBuilder.#cadPlan(context, false, normalized),
67
+ false
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Adds CAD assets and placements to one cached geometry foundation.
73
+ * @param {object} base Cached canonical scene geometry.
74
+ * @param {{ assets: object[], externalPlacements: object[] }} plan CAD plan.
75
+ * @param {boolean} nativeFidelity Whether native fidelity was selected.
76
+ * @returns {object} Materialized scene.
77
+ */
78
+ static #sceneWithPlan(base, plan, nativeFidelity) {
79
+ if (
80
+ !nativeFidelity &&
81
+ !plan.assets.length &&
82
+ !plan.externalPlacements.length
83
+ ) {
84
+ return base
85
+ }
86
+ return freezeScene({
87
+ ...base,
88
+ assets: plan.assets,
89
+ externalPlacements: plan.externalPlacements,
90
+ statistics: {
91
+ ...base.statistics,
92
+ assetCount: plan.assets.length,
93
+ externalPlacementCount: plan.externalPlacements.length,
94
+ nativeFidelity: nativeFidelity ? 1 : 0
95
+ }
96
+ })
97
+ }
98
+
99
+ /**
100
+ * Returns one context-cached canonical scene foundation.
101
+ * @param {CircuitJsonDocumentContext} context Prepared context.
102
+ * @param {ReturnType<Scene3dOptions['normalize']>} options Normalized options.
103
+ * @returns {object} Frozen canonical scene.
104
+ */
105
+ static #canonical(context, options) {
106
+ const firstBoard = PcbScene3dBuilder.#all(
107
+ context.getIndex('elements').elementsByType || new Map(),
108
+ 'pcb_board'
109
+ )[0]
110
+ const sourceThickness = CircuitJsonUnits.optionalLength(
111
+ firstBoard?.thickness
112
+ )
113
+ const cacheThickness =
114
+ !options.boardThicknessProvided &&
115
+ sourceThickness !== null &&
116
+ sourceThickness > 0 &&
117
+ sourceThickness <= 1000
118
+ ? sourceThickness
119
+ : options.boardThickness
120
+ const key = options.boardThicknessProvided
121
+ ? `canonical:override:${options.boardThickness}`
122
+ : `canonical:${cacheThickness}`
123
+ const scene = context.getOrCreateDerived('scene3d', key, () => {
124
+ const built = PcbScene3dBuilder.#buildCanonical(context, options)
125
+ CANONICAL_SCENE_OWNERS.set(built, { context, key })
126
+ return built
127
+ })
128
+ const owner = CANONICAL_SCENE_OWNERS.get(scene)
129
+ if (owner?.context !== context || owner?.key !== key) {
130
+ throw new ToolkitError(
131
+ 'CircuitJSON scene cache contains an unowned value.',
132
+ {
133
+ code: 'ERR_CONTEXT_CACHE_COLLISION',
134
+ category: 'runtime',
135
+ details: { namespace: 'scene3d', key }
136
+ }
137
+ )
138
+ }
139
+ return scene
140
+ }
141
+
142
+ /**
143
+ * Builds the canonical scene once for one context and thickness.
144
+ * @param {CircuitJsonDocumentContext} context Prepared context.
145
+ * @param {ReturnType<Scene3dOptions['normalize']>} options Normalized options.
146
+ * @returns {object} Frozen scene.
147
+ */
148
+ static #buildCanonical(context, options) {
149
+ const index = context.getIndex('elements')
150
+ const byType = index.elementsByType || new Map()
151
+ const boards = PcbScene3dBuilder.#all(byType, 'pcb_board')
152
+ const cutouts = PcbScene3dBuilder.#all(byType, 'pcb_cutout')
153
+ const boardModel = new Scene3dBoardModel(
154
+ boards,
155
+ cutouts,
156
+ options.boardThickness,
157
+ options.boardThicknessProvided
158
+ )
159
+ Scene3dIdRegistry.assertUnique(
160
+ 'board.outlines',
161
+ boardModel.board.outlines
162
+ )
163
+ const components = PcbScene3dBuilder.#all(byType, 'pcb_component').map(
164
+ (element) => PcbScene3dBuilder.#component(element, boardModel)
165
+ )
166
+ const pads = []
167
+ for (const type of PAD_TYPES) {
168
+ for (const element of PcbScene3dBuilder.#all(byType, type)) {
169
+ pads.push(PcbScene3dBuilder.#pad(element, boardModel))
170
+ }
171
+ }
172
+ const tracks = PcbScene3dBuilder.#all(byType, 'pcb_trace').map(
173
+ (element) => PcbScene3dBuilder.#track(element, boardModel)
174
+ )
175
+ const vias = PcbScene3dBuilder.#all(byType, 'pcb_via').map((element) =>
176
+ PcbScene3dBuilder.#via(element)
177
+ )
178
+ const zones = []
179
+ for (const type of ZONE_TYPES) {
180
+ for (const element of PcbScene3dBuilder.#all(byType, type)) {
181
+ zones.push(PcbScene3dBuilder.#zone(element, boardModel))
182
+ }
183
+ }
184
+ const texts = []
185
+ const objects = []
186
+ for (const element of context.model) {
187
+ const type = String(element.type || '')
188
+ if (PcbScene3dBuilder.#isTextType(type)) {
189
+ texts.push(PcbScene3dBuilder.#text(element, boardModel))
190
+ continue
191
+ }
192
+ if (PcbScene3dBuilder.#isObjectType(type)) {
193
+ objects.push(PcbScene3dBuilder.#object(element, boardModel))
194
+ }
195
+ }
196
+ const diagnostics = []
197
+ const materials = Scene3dMaterials.build(boards)
198
+ for (const [collection, rows] of Object.entries({
199
+ components,
200
+ pads,
201
+ tracks,
202
+ vias,
203
+ zones,
204
+ texts,
205
+ objects
206
+ })) {
207
+ Scene3dIdRegistry.assertUnique(collection, rows)
208
+ }
209
+ if (!boards.length) {
210
+ diagnostics.push(
211
+ ToolkitDiagnostic.create({
212
+ code: 'SCENE_BOARD_MISSING',
213
+ severity: 'warning',
214
+ message: 'The CircuitJSON model contains no PCB board.',
215
+ source: Scene3dDocumentMetadata.sourceFileName(context),
216
+ details: {}
217
+ })
218
+ )
219
+ }
220
+
221
+ return freezeScene({
222
+ schema: 'ecad-toolkit.scene3d.v1',
223
+ units: 'mm',
224
+ coordinateSystem: 'right-handed-z-up',
225
+ board: boardModel.board,
226
+ components,
227
+ pads,
228
+ tracks,
229
+ vias,
230
+ zones,
231
+ texts,
232
+ objects,
233
+ materials,
234
+ assets: [],
235
+ externalPlacements: [],
236
+ diagnostics,
237
+ statistics: {
238
+ elementCount: context.model.length,
239
+ boardCount: boards.length,
240
+ componentCount: components.length,
241
+ padCount: pads.length,
242
+ trackCount: tracks.length,
243
+ viaCount: vias.length,
244
+ zoneCount: zones.length,
245
+ textCount: texts.length,
246
+ objectCount: objects.length,
247
+ materialCount: materials.length,
248
+ assetCount: 0,
249
+ externalPlacementCount: 0,
250
+ nativeFidelity: 0
251
+ }
252
+ })
253
+ }
254
+
255
+ /**
256
+ * Builds source-native CAD placement and asset records when fully usable.
257
+ * @param {CircuitJsonDocumentContext} context Prepared context.
258
+ * @param {ReturnType<Scene3dOptions['normalize']>} options Normalized options.
259
+ * @returns {{ assets: object[], externalPlacements: object[] } | null} Native plan or canonical fallback.
260
+ */
261
+ static #nativePlan(context, options) {
262
+ const format = Scene3dDocumentMetadata.sourceFormat(context)
263
+ const extension = context.extensions?.[format]
264
+ if (!Scene3dDocumentMetadata.hasNativeExtension(format, extension)) {
265
+ if (options.fidelity === 'auto') return null
266
+ throw new ToolkitError(
267
+ `Native scene extension data is required for ${format}.`,
268
+ {
269
+ code: 'ERR_EXTENSION_DATA_REQUIRED',
270
+ category: 'unsupported',
271
+ format,
272
+ source: Scene3dDocumentMetadata.sourceFileName(context),
273
+ details: { format, feature: 'scene3d' }
274
+ }
275
+ )
276
+ }
277
+
278
+ if (options.fidelity === 'auto') {
279
+ const cadComponents = PcbScene3dBuilder.#all(
280
+ context.getIndex('elements').elementsByType || new Map(),
281
+ 'cad_component'
282
+ )
283
+ for (const component of cadComponents) {
284
+ const reference =
285
+ Scene3dModelReference.fromCadComponent(component)
286
+ if (!reference?.requiresAsset) return null
287
+ }
288
+ }
289
+
290
+ const plan = PcbScene3dBuilder.#cadPlan(context, true, options)
291
+ if (!plan.externalPlacements.length) {
292
+ if (options.fidelity === 'auto') return null
293
+ throw new ToolkitError(
294
+ `Native scene placement data is required for ${format}.`,
295
+ {
296
+ code: 'ERR_EXTENSION_DATA_REQUIRED',
297
+ category: 'unsupported',
298
+ format,
299
+ source: Scene3dDocumentMetadata.sourceFileName(context),
300
+ details: { format, feature: 'scene3d-placements' }
301
+ }
302
+ )
303
+ }
304
+ const unresolved = plan.assets.find((asset) => asset.data === null)
305
+ if (unresolved && !options.resolveAsset) {
306
+ if (options.fidelity === 'auto') return null
307
+ throw new ToolkitError(
308
+ `Native scene asset data is required: ${unresolved.id}.`,
309
+ {
310
+ code: 'ERR_ASSET_DATA_REQUIRED',
311
+ category: 'unsupported',
312
+ format,
313
+ source: Scene3dDocumentMetadata.sourceFileName(context),
314
+ details: { assetId: unresolved.id }
315
+ }
316
+ )
317
+ }
318
+ return plan
319
+ }
320
+
321
+ /**
322
+ * Builds canonical CAD placement records and their asset requests.
323
+ * @param {CircuitJsonDocumentContext} context Prepared context.
324
+ * @param {boolean} requireReferences Whether every CAD row needs a model.
325
+ * @param {ReturnType<Scene3dOptions['normalize']>} options Scene options.
326
+ * @returns {{ assets: object[], externalPlacements: object[] }} CAD plan.
327
+ */
328
+ static #cadPlan(context, requireReferences, options) {
329
+ const cadComponents = PcbScene3dBuilder.#all(
330
+ context.getIndex('elements').elementsByType || new Map(),
331
+ 'cad_component'
332
+ )
333
+ if (cadComponents.length > Scene3dOptions.maxAssetCount) {
334
+ throw new ToolkitError(
335
+ 'Scene asset count exceeds the safe limit.',
336
+ {
337
+ code: 'ERR_ASSET_LIMIT',
338
+ category: 'unsupported',
339
+ details: {
340
+ count: cadComponents.length,
341
+ maximum: Scene3dOptions.maxAssetCount
342
+ }
343
+ }
344
+ )
345
+ }
346
+ if (!cadComponents.length) {
347
+ return { assets: [], externalPlacements: [] }
348
+ }
349
+ const assetIndex = new Scene3dAssetIndex(context.assets, options)
350
+ const externalPlacements = []
351
+ const format = Scene3dDocumentMetadata.sourceFormat(context)
352
+
353
+ for (const cadComponent of cadComponents) {
354
+ const reference =
355
+ Scene3dModelReference.fromCadComponent(cadComponent)
356
+ if (!reference || (requireReferences && !reference.requiresAsset)) {
357
+ if (requireReferences) {
358
+ throw new ToolkitError(
359
+ `CAD component has no model asset reference: ${PcbScene3dBuilder.#id(cadComponent)}.`,
360
+ {
361
+ code: 'ERR_ASSET_DATA_REQUIRED',
362
+ category: 'unsupported',
363
+ format,
364
+ source: Scene3dDocumentMetadata.sourceFileName(
365
+ context
366
+ ),
367
+ details: {
368
+ cadComponentId:
369
+ PcbScene3dBuilder.#id(cadComponent)
370
+ }
371
+ }
372
+ )
373
+ }
374
+ continue
375
+ }
376
+ const asset = reference.requiresAsset
377
+ ? assetIndex.resolve(reference)
378
+ : null
379
+ externalPlacements.push(
380
+ PcbScene3dBuilder.#externalPlacement(
381
+ cadComponent,
382
+ reference,
383
+ asset,
384
+ format,
385
+ context
386
+ )
387
+ )
388
+ }
389
+
390
+ Scene3dIdRegistry.assertUnique('externalPlacements', externalPlacements)
391
+ return { assets: assetIndex.assets, externalPlacements }
392
+ }
393
+
394
+ /**
395
+ * Builds one canonical external placement.
396
+ * @param {object} cadComponent CAD component.
397
+ * @param {{ name: string, format: string, inlineModel?: unknown, generator?: string }} reference Model reference.
398
+ * @param {object | null} asset Canonical asset.
399
+ * @param {string} format Source format.
400
+ * @param {CircuitJsonDocumentContext} context Prepared context.
401
+ * @returns {object} External placement record.
402
+ */
403
+ static #externalPlacement(cadComponent, reference, asset, format, context) {
404
+ const componentId = String(cadComponent.pcb_component_id || '')
405
+ const linked = context
406
+ .getIndex('elements')
407
+ .pcbComponentById?.get(componentId)
408
+ const side = PcbScene3dBuilder.#side(
409
+ cadComponent.layer || linked?.layer
410
+ )
411
+ return {
412
+ id: PcbScene3dBuilder.#id(cadComponent),
413
+ componentId,
414
+ sourceComponentId: String(cadComponent.source_component_id || ''),
415
+ side,
416
+ position: PcbScene3dBuilder.#point3(cadComponent.position),
417
+ rotation: PcbScene3dBuilder.#point3(cadComponent.rotation),
418
+ size: PcbScene3dBuilder.#point3(cadComponent.size),
419
+ scale: PcbScene3dBuilder.#positiveNumber(
420
+ cadComponent.model_unit_to_mm_scale_factor,
421
+ 1
422
+ ),
423
+ model: {
424
+ assetId: asset?.id || null,
425
+ format: reference.format,
426
+ name: reference.name,
427
+ inlineModel: reference.inlineModel ?? null,
428
+ generator: reference.generator || '',
429
+ boardNormalDirection: String(
430
+ cadComponent.model_board_normal_direction || 'z+'
431
+ ),
432
+ originPosition: PcbScene3dBuilder.#point3(
433
+ cadComponent.model_origin_position
434
+ ),
435
+ originAlignment: String(
436
+ cadComponent.model_origin_alignment || 'unknown'
437
+ ),
438
+ objectFit: String(
439
+ cadComponent.model_object_fit || 'contain_within_bounds'
440
+ )
441
+ },
442
+ translucent: cadComponent.show_as_translucent_model === true,
443
+ boundingBox: cadComponent.show_as_bounding_box === true,
444
+ extensionRef: {
445
+ format,
446
+ cadComponentId: PcbScene3dBuilder.#id(cadComponent)
447
+ }
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Maps one PCB component to a canonical placement.
453
+ * @param {object} element PCB component.
454
+ * @param {Scene3dBoardModel} boardModel Board geometry model.
455
+ * @returns {object} Scene component.
456
+ */
457
+ static #component(element, boardModel) {
458
+ const side = PcbScene3dBuilder.#side(element.layer)
459
+ const center = PcbScene3dBuilder.#point2(element.center)
460
+ return {
461
+ id: PcbScene3dBuilder.#id(element),
462
+ sourceComponentId: String(element.source_component_id || ''),
463
+ side,
464
+ position: {
465
+ ...center,
466
+ z: boardModel.surfaceZ(side, center)
467
+ },
468
+ rotation: {
469
+ x: 0,
470
+ y: 0,
471
+ z: CircuitJsonUnits.angle(element.rotation, 0)
472
+ },
473
+ size: {
474
+ x: CircuitJsonUnits.length(element.width, 0),
475
+ y: CircuitJsonUnits.length(element.height, 0),
476
+ z: CircuitJsonUnits.length(
477
+ element.height_3d || element.depth,
478
+ 0
479
+ )
480
+ },
481
+ materialId: 'component-body'
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Maps one SMT or plated pad.
487
+ * @param {object} element Pad element.
488
+ * @param {Scene3dBoardModel} boardModel Board geometry model.
489
+ * @returns {object} Scene pad.
490
+ */
491
+ static #pad(element, boardModel) {
492
+ const side = PcbScene3dBuilder.#side(element.layer)
493
+ const position = PcbScene3dBuilder.#elementPoint(element)
494
+ const diameter = CircuitJsonUnits.length(
495
+ element.outer_diameter || element.diameter,
496
+ 0
497
+ )
498
+ return {
499
+ id: PcbScene3dBuilder.#id(element),
500
+ componentId: String(element.pcb_component_id || ''),
501
+ side,
502
+ layerId: String(element.layer || ''),
503
+ position: {
504
+ ...position,
505
+ z: boardModel.surfaceZ(side, position)
506
+ },
507
+ rotation: {
508
+ x: 0,
509
+ y: 0,
510
+ z: CircuitJsonUnits.angle(
511
+ element.ccw_rotation || element.rotation,
512
+ 0
513
+ )
514
+ },
515
+ shape: String(element.shape || element.hole_shape || 'circle'),
516
+ size: {
517
+ x: CircuitJsonUnits.length(element.width, diameter),
518
+ y: CircuitJsonUnits.length(element.height, diameter)
519
+ },
520
+ holeDiameter: CircuitJsonUnits.length(element.hole_diameter, 0),
521
+ points: PcbScene3dBuilder.#points(element.points),
522
+ netId: String(element.net_id || element.net || ''),
523
+ materialId: 'copper'
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Maps one routed trace with per-route-point surface coordinates.
529
+ * @param {object} element PCB trace.
530
+ * @param {Scene3dBoardModel} boardModel Board geometry model.
531
+ * @returns {object} Scene track.
532
+ */
533
+ static #track(element, boardModel) {
534
+ const route = []
535
+ for (const point of Array.isArray(element.route) ? element.route : []) {
536
+ const side = PcbScene3dBuilder.#side(point.layer || element.layer)
537
+ const position = PcbScene3dBuilder.#elementPoint(point)
538
+ route.push({
539
+ kind: String(point.route_type || point.type || 'wire'),
540
+ position: {
541
+ ...position,
542
+ z: boardModel.surfaceZ(side, position)
543
+ },
544
+ width: CircuitJsonUnits.length(point.width || element.width, 0),
545
+ layerId: String(point.layer || element.layer || ''),
546
+ viaId: String(point.pcb_via_id || '')
547
+ })
548
+ }
549
+ return {
550
+ id: PcbScene3dBuilder.#id(element),
551
+ netId: String(element.net_id || element.net || ''),
552
+ route,
553
+ materialId: 'copper'
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Maps one PCB via.
559
+ * @param {object} element Via element.
560
+ * @returns {object} Scene via.
561
+ */
562
+ static #via(element) {
563
+ const layers = Array.isArray(element.layers)
564
+ ? element.layers.map(String)
565
+ : [element.from_layer, element.to_layer].map(String).filter(Boolean)
566
+ return {
567
+ id: PcbScene3dBuilder.#id(element),
568
+ position: { ...PcbScene3dBuilder.#elementPoint(element), z: 0 },
569
+ diameter: CircuitJsonUnits.length(
570
+ element.outer_diameter || element.diameter,
571
+ 0
572
+ ),
573
+ holeDiameter: CircuitJsonUnits.length(element.hole_diameter, 0),
574
+ layerIds: layers,
575
+ netId: String(element.net_id || element.net || ''),
576
+ materialId: 'copper'
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Maps one copper zone.
582
+ * @param {object} element Zone element.
583
+ * @param {Scene3dBoardModel} boardModel Board geometry model.
584
+ * @returns {object} Scene zone.
585
+ */
586
+ static #zone(element, boardModel) {
587
+ const side = PcbScene3dBuilder.#side(element.layer)
588
+ const position = PcbScene3dBuilder.#elementPoint(element)
589
+ return {
590
+ id: PcbScene3dBuilder.#id(element),
591
+ side,
592
+ layerId: String(element.layer || ''),
593
+ position: {
594
+ ...position,
595
+ z: boardModel.surfaceZ(side, position)
596
+ },
597
+ shape: String(element.shape || 'polygon'),
598
+ points: PcbScene3dBuilder.#points(element.points),
599
+ rings: PcbScene3dBuilder.#rings(element.rings),
600
+ netId: String(element.net_id || element.net || ''),
601
+ materialId: 'copper'
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Maps one PCB text element.
607
+ * @param {object} element Text element.
608
+ * @param {Scene3dBoardModel} boardModel Board geometry model.
609
+ * @returns {object} Scene text.
610
+ */
611
+ static #text(element, boardModel) {
612
+ const side = PcbScene3dBuilder.#side(element.layer)
613
+ const position = PcbScene3dBuilder.#elementPoint(element)
614
+ return {
615
+ id: PcbScene3dBuilder.#id(element),
616
+ kind: String(element.type),
617
+ side,
618
+ layerId: String(element.layer || ''),
619
+ position: {
620
+ ...position,
621
+ z: boardModel.surfaceZ(side, position)
622
+ },
623
+ rotation: {
624
+ x: 0,
625
+ y: 0,
626
+ z: CircuitJsonUnits.angle(element.rotation, 0)
627
+ },
628
+ value: String(element.text || element.value || ''),
629
+ size: CircuitJsonUnits.length(
630
+ element.font_size || element.fontSize,
631
+ 0
632
+ ),
633
+ materialId: String(element.type).includes('silkscreen')
634
+ ? 'silkscreen'
635
+ : 'copper'
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Maps an additional PCB geometry object.
641
+ * @param {object} element PCB element.
642
+ * @param {Scene3dBoardModel} boardModel Board geometry model.
643
+ * @returns {object} Generic scene object.
644
+ */
645
+ static #object(element, boardModel) {
646
+ const side = PcbScene3dBuilder.#side(element.layer)
647
+ const position = PcbScene3dBuilder.#elementPoint(element)
648
+ return {
649
+ id: PcbScene3dBuilder.#id(element),
650
+ kind: String(element.type),
651
+ side,
652
+ layerId: String(element.layer || ''),
653
+ position: {
654
+ ...position,
655
+ z: String(element.layer || '').includes('board')
656
+ ? 0
657
+ : boardModel.surfaceZ(side, position)
658
+ },
659
+ rotation: {
660
+ x: 0,
661
+ y: 0,
662
+ z: CircuitJsonUnits.angle(
663
+ element.rotation || element.ccw_rotation,
664
+ 0
665
+ )
666
+ },
667
+ size: {
668
+ x: CircuitJsonUnits.length(
669
+ element.width || element.diameter,
670
+ 0
671
+ ),
672
+ y: CircuitJsonUnits.length(
673
+ element.height || element.diameter,
674
+ 0
675
+ ),
676
+ z: 0
677
+ },
678
+ points: PcbScene3dBuilder.#points(element.points || element.route),
679
+ materialId: String(element.type).includes('silkscreen')
680
+ ? 'silkscreen'
681
+ : 'board-core'
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Returns true for PCB text element types.
687
+ * @param {string} type Element type.
688
+ * @returns {boolean} Whether the type is text.
689
+ */
690
+ static #isTextType(type) {
691
+ return (
692
+ type === 'pcb_text' ||
693
+ (type.startsWith('pcb_') && type.endsWith('_text'))
694
+ )
695
+ }
696
+
697
+ /**
698
+ * Returns true for additional non-diagnostic PCB geometry.
699
+ * @param {string} type Element type.
700
+ * @returns {boolean} Whether the element belongs in objects.
701
+ */
702
+ static #isObjectType(type) {
703
+ return (
704
+ type.startsWith('pcb_') &&
705
+ !MAIN_TYPES.has(type) &&
706
+ !PcbScene3dBuilder.#isTextType(type) &&
707
+ !/(?:error|warning)$/u.test(type)
708
+ )
709
+ }
710
+
711
+ /**
712
+ * Returns an element list for one indexed type.
713
+ * @param {Map<string, object[]>} byType Element type map.
714
+ * @param {string} type Element type.
715
+ * @returns {object[]} Elements.
716
+ */
717
+ static #all(byType, type) {
718
+ const values = byType.get(type)
719
+ return Array.isArray(values) ? values : []
720
+ }
721
+
722
+ /**
723
+ * Resolves the standard element id.
724
+ * @param {object} element CircuitJSON element.
725
+ * @returns {string} Element id.
726
+ */
727
+ static #id(element) {
728
+ return CircuitJsonIndexer.getElementId(element)
729
+ }
730
+
731
+ /**
732
+ * Resolves a canonical element position.
733
+ * @param {object} element CircuitJSON element or route point.
734
+ * @returns {{ x: number, y: number }} Position.
735
+ */
736
+ static #elementPoint(element) {
737
+ if (element.center && typeof element.center === 'object') {
738
+ return PcbScene3dBuilder.#point2(element.center)
739
+ }
740
+ if (element.position && typeof element.position === 'object') {
741
+ return PcbScene3dBuilder.#point2(element.position)
742
+ }
743
+ if (element.x !== undefined || element.y !== undefined) {
744
+ return PcbScene3dBuilder.#point2({
745
+ x: element.x,
746
+ y: element.y
747
+ })
748
+ }
749
+ const points = PcbScene3dBuilder.#points(element.points)
750
+ if (points.length) return PcbScene3dBuilder.#representativePoint(points)
751
+ const rings = PcbScene3dBuilder.#rings(element.rings)
752
+ if (rings[0]?.length) {
753
+ return PcbScene3dBuilder.#representativePoint(rings[0])
754
+ }
755
+ return { x: 0, y: 0 }
756
+ }
757
+
758
+ /**
759
+ * Returns the arithmetic center of one non-empty geometry point set.
760
+ * @param {{ x: number, y: number }[]} points Canonical points.
761
+ * @returns {{ x: number, y: number }} Representative point.
762
+ */
763
+ static #representativePoint(points) {
764
+ let x = 0
765
+ let y = 0
766
+ for (const point of points) {
767
+ x += point.x
768
+ y += point.y
769
+ }
770
+ const center = { x: x / points.length, y: y / points.length }
771
+ if (PcbScene3dBuilder.#polygonContains(center, points)) return center
772
+ for (let index = 0; index < points.length; index += 1) {
773
+ const start = points[index]
774
+ const end = points[(index + 1) % points.length]
775
+ if (start.x === end.x && start.y === end.y) continue
776
+ return {
777
+ x: (start.x + end.x) / 2,
778
+ y: (start.y + end.y) / 2
779
+ }
780
+ }
781
+ return { ...points[0] }
782
+ }
783
+
784
+ /**
785
+ * Tests whether a point lies inside or on one polygon.
786
+ * @param {{ x: number, y: number }} point Candidate point.
787
+ * @param {{ x: number, y: number }[]} points Polygon points.
788
+ * @returns {boolean} Whether the polygon contains the point.
789
+ */
790
+ static #polygonContains(point, points) {
791
+ let inside = false
792
+ for (
793
+ let current = 0, previous = points.length - 1;
794
+ current < points.length;
795
+ previous = current, current += 1
796
+ ) {
797
+ const a = points[current]
798
+ const b = points[previous]
799
+ if (PcbScene3dBuilder.#pointOnSegment(point, a, b)) return true
800
+ const crosses =
801
+ a.y > point.y !== b.y > point.y &&
802
+ point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x
803
+ if (crosses) inside = !inside
804
+ }
805
+ return inside
806
+ }
807
+
808
+ /**
809
+ * Tests whether a point lies on a closed line segment.
810
+ * @param {{ x: number, y: number }} point Candidate point.
811
+ * @param {{ x: number, y: number }} start Segment start.
812
+ * @param {{ x: number, y: number }} end Segment end.
813
+ * @returns {boolean} Whether the point lies on the segment.
814
+ */
815
+ static #pointOnSegment(point, start, end) {
816
+ const cross =
817
+ (point.y - start.y) * (end.x - start.x) -
818
+ (point.x - start.x) * (end.y - start.y)
819
+ if (Math.abs(cross) > 1e-9) return false
820
+ return (
821
+ point.x >= Math.min(start.x, end.x) &&
822
+ point.x <= Math.max(start.x, end.x) &&
823
+ point.y >= Math.min(start.y, end.y) &&
824
+ point.y <= Math.max(start.y, end.y)
825
+ )
826
+ }
827
+
828
+ /**
829
+ * Normalizes a two-dimensional point.
830
+ * @param {unknown} point Point candidate.
831
+ * @returns {{ x: number, y: number }} Point.
832
+ */
833
+ static #point2(point) {
834
+ return {
835
+ x: CircuitJsonUnits.length(point?.x, 0),
836
+ y: CircuitJsonUnits.length(point?.y, 0)
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Normalizes a three-dimensional point.
842
+ * @param {unknown} point Point candidate.
843
+ * @returns {{ x: number, y: number, z: number }} Point.
844
+ */
845
+ static #point3(point) {
846
+ return {
847
+ x: CircuitJsonUnits.length(point?.x, 0),
848
+ y: CircuitJsonUnits.length(point?.y, 0),
849
+ z: CircuitJsonUnits.length(point?.z, 0)
850
+ }
851
+ }
852
+
853
+ /**
854
+ * Maps a bounded point array without variadic spreads.
855
+ * @param {unknown} points Point candidates.
856
+ * @returns {{ x: number, y: number }[]} Points.
857
+ */
858
+ static #points(points) {
859
+ if (!Array.isArray(points)) return []
860
+ const result = []
861
+ for (const point of points) {
862
+ if (!point || typeof point !== 'object') continue
863
+ result.push(PcbScene3dBuilder.#point2(point))
864
+ }
865
+ return result
866
+ }
867
+
868
+ /**
869
+ * Maps nested polygon rings.
870
+ * @param {unknown} rings Ring candidates.
871
+ * @returns {{ x: number, y: number }[][]} Rings.
872
+ */
873
+ static #rings(rings) {
874
+ if (!Array.isArray(rings)) return []
875
+ const result = []
876
+ for (const ring of rings) {
877
+ if (Array.isArray(ring)) {
878
+ result.push(PcbScene3dBuilder.#points(ring))
879
+ } else if (Array.isArray(ring?.points)) {
880
+ result.push(PcbScene3dBuilder.#points(ring.points))
881
+ }
882
+ }
883
+ return result
884
+ }
885
+
886
+ /**
887
+ * Resolves top or bottom from a common layer value.
888
+ * @param {unknown} layer Layer candidate.
889
+ * @returns {'top' | 'bottom'} Canonical side.
890
+ */
891
+ static #side(layer) {
892
+ const value = String(layer || '').toLowerCase()
893
+ return /(?:bottom|back|b\.)/u.test(value) ? 'bottom' : 'top'
894
+ }
895
+
896
+ /**
897
+ * Normalizes a positive number.
898
+ * @param {unknown} value Candidate number.
899
+ * @param {number} fallback Fallback number.
900
+ * @returns {number} Positive number.
901
+ */
902
+ static #positiveNumber(value, fallback) {
903
+ const number = Number(value)
904
+ return Number.isFinite(number) && number > 0 ? number : fallback
905
+ }
906
+ }