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,167 @@
1
+ /**
2
+ * Reads owned scene document metadata without accessors or object coercion.
3
+ */
4
+ export class Scene3dDocumentMetadata {
5
+ /**
6
+ * Returns a primitive source format.
7
+ * @param {object} context Prepared document context.
8
+ * @returns {string} Source format.
9
+ */
10
+ static sourceFormat(context) {
11
+ const value = context.source?.format
12
+ if (value === undefined || value === null || value === '') {
13
+ return 'circuitjson'
14
+ }
15
+ if (typeof value !== 'string') {
16
+ throw new TypeError('CircuitJSON source format must be a string.')
17
+ }
18
+ return value
19
+ }
20
+
21
+ /**
22
+ * Returns a primitive source file name.
23
+ * @param {object} context Prepared document context.
24
+ * @returns {string} Source file name.
25
+ */
26
+ static sourceFileName(context) {
27
+ const value = context.source?.fileName
28
+ if (value === undefined || value === null) return ''
29
+ if (typeof value !== 'string') {
30
+ throw new TypeError('CircuitJSON source fileName must be a string.')
31
+ }
32
+ return value
33
+ }
34
+
35
+ /**
36
+ * Returns true when a source extension declares usable native scene data.
37
+ * @param {string} format Source format.
38
+ * @param {unknown} extension Source extension.
39
+ * @returns {boolean} Whether native scene data is declared.
40
+ */
41
+ static hasNativeExtension(format, extension) {
42
+ if (
43
+ format === 'circuitjson' ||
44
+ !extension ||
45
+ typeof extension !== 'object' ||
46
+ Array.isArray(extension)
47
+ ) {
48
+ return false
49
+ }
50
+ const metadata = extension.$meta
51
+ Scene3dDocumentMetadata.#validateExtensionMetadata(metadata)
52
+ const completeness = metadata?.completeness || 'none'
53
+ if (!['canonical', 'full'].includes(completeness)) return false
54
+ const included = metadata?.included || []
55
+ return (
56
+ included.some(
57
+ (feature) =>
58
+ feature === 'scene3d' || feature.startsWith('scene3d.')
59
+ ) || Boolean(extension.scene3d)
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Validates extension metadata as primitive owned data.
65
+ * @param {unknown} metadata Extension metadata candidate.
66
+ * @returns {void}
67
+ */
68
+ static #validateExtensionMetadata(metadata) {
69
+ if (metadata === undefined || metadata === null) return
70
+ if (typeof metadata !== 'object' || Array.isArray(metadata)) {
71
+ throw new TypeError(
72
+ 'Scene extension metadata must be a plain data object.'
73
+ )
74
+ }
75
+ const prototype = Object.getPrototypeOf(metadata)
76
+ if (prototype !== Object.prototype && prototype !== null) {
77
+ throw new TypeError(
78
+ 'Scene extension metadata must be a plain data object.'
79
+ )
80
+ }
81
+ for (const key of Reflect.ownKeys(metadata)) {
82
+ if (typeof key !== 'string') {
83
+ throw new TypeError(
84
+ 'Scene extension metadata keys must be strings.'
85
+ )
86
+ }
87
+ const descriptor = Object.getOwnPropertyDescriptor(metadata, key)
88
+ if (!descriptor || !Object.hasOwn(descriptor, 'value')) {
89
+ throw new TypeError(
90
+ 'Scene extension metadata must contain only data properties.'
91
+ )
92
+ }
93
+ Scene3dDocumentMetadata.#validateMetadataValue(
94
+ key,
95
+ descriptor.value
96
+ )
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Validates one primitive metadata field or primitive array.
102
+ * @param {string} key Metadata field name.
103
+ * @param {unknown} value Metadata value.
104
+ * @returns {void}
105
+ */
106
+ static #validateMetadataValue(key, value) {
107
+ if (key === 'schema' || key === 'completeness') {
108
+ if (typeof value === 'string') return
109
+ throw new TypeError(`Scene extension ${key} must be a string.`)
110
+ }
111
+ if (key === 'included' || key === 'omitted') {
112
+ if (
113
+ Array.isArray(value) &&
114
+ Scene3dDocumentMetadata.#primitiveArray(value, true)
115
+ ) {
116
+ return
117
+ }
118
+ throw new TypeError(
119
+ `Scene extension ${key} must contain only strings.`
120
+ )
121
+ }
122
+ if (Array.isArray(value)) {
123
+ if (Scene3dDocumentMetadata.#primitiveArray(value, false)) return
124
+ } else if (Scene3dDocumentMetadata.#isPrimitive(value)) {
125
+ return
126
+ }
127
+ throw new TypeError(
128
+ `Scene extension ${key} must contain only primitive data.`
129
+ )
130
+ }
131
+
132
+ /**
133
+ * Tests a dense array of primitive extension entries.
134
+ * @param {unknown[]} values Metadata entries.
135
+ * @param {boolean} stringsOnly Whether every entry must be a string.
136
+ * @returns {boolean} Whether all entries are accepted primitives.
137
+ */
138
+ static #primitiveArray(values, stringsOnly) {
139
+ for (let index = 0; index < values.length; index += 1) {
140
+ const value = values[index]
141
+ if (
142
+ stringsOnly
143
+ ? typeof value !== 'string'
144
+ : !Scene3dDocumentMetadata.#isPrimitive(value)
145
+ ) {
146
+ return false
147
+ }
148
+ }
149
+ return true
150
+ }
151
+
152
+ /**
153
+ * Tests one clone-safe primitive without coercion.
154
+ * @param {unknown} value Metadata value.
155
+ * @returns {boolean} Whether the value is a supported primitive.
156
+ */
157
+ static #isPrimitive(value) {
158
+ return (
159
+ value === null ||
160
+ value === undefined ||
161
+ ['string', 'number', 'boolean', 'bigint'].includes(typeof value)
162
+ )
163
+ }
164
+ }
165
+
166
+ Object.freeze(Scene3dDocumentMetadata.prototype)
167
+ Object.freeze(Scene3dDocumentMetadata)
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Freezes data-only scene graphs iteratively without recursing into binary data.
3
+ * @param {unknown} value Scene value.
4
+ * @returns {any} The same value.
5
+ */
6
+ export function freezeScene(value) {
7
+ if (!value || typeof value !== 'object') return value
8
+ const seen = new WeakSet()
9
+ const pending = [value]
10
+ const ordered = []
11
+
12
+ while (pending.length) {
13
+ const current = pending.pop()
14
+ if (
15
+ !current ||
16
+ typeof current !== 'object' ||
17
+ seen.has(current) ||
18
+ current instanceof ArrayBuffer ||
19
+ ArrayBuffer.isView(current)
20
+ ) {
21
+ continue
22
+ }
23
+ seen.add(current)
24
+ ordered.push(current)
25
+ for (const key of Reflect.ownKeys(current)) {
26
+ const descriptor = Object.getOwnPropertyDescriptor(current, key)
27
+ if (descriptor && Object.hasOwn(descriptor, 'value')) {
28
+ pending.push(descriptor.value)
29
+ }
30
+ }
31
+ }
32
+
33
+ for (let index = ordered.length - 1; index >= 0; index -= 1) {
34
+ if (!Object.isFrozen(ordered[index])) Object.freeze(ordered[index])
35
+ }
36
+ return value
37
+ }
@@ -0,0 +1,34 @@
1
+ import { ToolkitError } from '../contracts/ToolkitError.mjs'
2
+
3
+ /**
4
+ * Enforces unambiguous identifiers within canonical scene collections.
5
+ */
6
+ export class Scene3dIdRegistry {
7
+ /**
8
+ * Rejects duplicate non-empty identifiers in one output collection.
9
+ * @param {string} collection Canonical collection name.
10
+ * @param {object[]} rows Canonical rows.
11
+ * @returns {void}
12
+ */
13
+ static assertUnique(collection, rows) {
14
+ const ids = new Set()
15
+ for (const row of rows) {
16
+ const id = row.id
17
+ if (!id || !ids.has(id)) {
18
+ if (id) ids.add(id)
19
+ continue
20
+ }
21
+ throw new ToolkitError(
22
+ `Scene ${collection} id is ambiguous: ${id}.`,
23
+ {
24
+ code: 'ERR_SCENE_ID_AMBIGUOUS',
25
+ category: 'unsupported',
26
+ details: { collection, id }
27
+ }
28
+ )
29
+ }
30
+ }
31
+ }
32
+
33
+ Object.freeze(Scene3dIdRegistry.prototype)
34
+ Object.freeze(Scene3dIdRegistry)
@@ -0,0 +1,193 @@
1
+ import { ToolkitError } from '../contracts/ToolkitError.mjs'
2
+ import { CircuitJsonReadOnlyDocument } from '../context/CircuitJsonReadOnlyDocument.mjs'
3
+ import { Scene3dOptions } from './Scene3dOptions.mjs'
4
+
5
+ const DOCUMENT_SCHEMA = 'ecad-toolkit.document.v1'
6
+ const ENVELOPE_FIELDS = ['schema', 'model', 'source', 'extensions', 'assets']
7
+
8
+ /**
9
+ * Rejects unsafe envelopes and resident asset limits before context copying.
10
+ */
11
+ export class Scene3dInputPreflight {
12
+ /**
13
+ * Preflights one document envelope without invoking caller accessors.
14
+ * @param {unknown} input Scene input.
15
+ * @param {ReturnType<Scene3dOptions['normalize']>} options Scene options.
16
+ * @returns {boolean} Whether envelope assets were preflighted.
17
+ */
18
+ static check(input, options) {
19
+ if (Array.isArray(input) || !input || typeof input !== 'object') {
20
+ return false
21
+ }
22
+ const schema = Scene3dInputPreflight.#descriptor(input, 'schema')
23
+ if (!schema) return false
24
+ if (!Object.hasOwn(schema, 'value')) {
25
+ throw new TypeError(
26
+ 'Canonical document schema must be an own data property.'
27
+ )
28
+ }
29
+ if (schema.value !== DOCUMENT_SCHEMA) return false
30
+
31
+ const values = Object.create(null)
32
+ for (const field of ENVELOPE_FIELDS) {
33
+ const descriptor = Scene3dInputPreflight.#descriptor(input, field)
34
+ if (!descriptor) {
35
+ if (field === 'schema' || field === 'model') {
36
+ throw new TypeError(
37
+ `Canonical document ${field} must be an own data property.`
38
+ )
39
+ }
40
+ values[field] = undefined
41
+ continue
42
+ }
43
+ if (!Object.hasOwn(descriptor, 'value')) {
44
+ throw new TypeError(
45
+ `Canonical document ${field} must be an own data property.`
46
+ )
47
+ }
48
+ values[field] = descriptor.value
49
+ }
50
+ Scene3dInputPreflight.checkAssets(values.assets, options)
51
+ return true
52
+ }
53
+
54
+ /**
55
+ * Applies one global metadata and resident-byte budget to document assets.
56
+ * @param {unknown} values Canonical document assets.
57
+ * @param {ReturnType<Scene3dOptions['normalize']>} options Scene options.
58
+ * @returns {object[]} Captured assets in authored order.
59
+ */
60
+ static checkAssets(values, options) {
61
+ const owner = values === undefined ? [] : values
62
+ const assets = Scene3dOptions.dataArray(
63
+ owner,
64
+ 'Scene assets',
65
+ Scene3dOptions.maxAssetCount
66
+ )
67
+ const metadataBudget =
68
+ CircuitJsonReadOnlyDocument.createMetadataBudget()
69
+ let total = 0
70
+ for (let index = 0; index < assets.length; index += 1) {
71
+ const asset = assets[index]
72
+ const captured = CircuitJsonReadOnlyDocument.captureAsset(
73
+ asset,
74
+ metadataBudget,
75
+ true,
76
+ (bytes, identity) => {
77
+ const assetId =
78
+ typeof identity.id === 'string' ? identity.id : ''
79
+ if (bytes > options.maxAssetBytes) {
80
+ throw Scene3dInputPreflight.#limit(
81
+ bytes,
82
+ options.maxAssetBytes,
83
+ 'asset',
84
+ assetId
85
+ )
86
+ }
87
+ if (total + bytes > options.maxTotalAssetBytes) {
88
+ throw Scene3dInputPreflight.#limit(
89
+ total + bytes,
90
+ options.maxTotalAssetBytes,
91
+ 'total',
92
+ assetId
93
+ )
94
+ }
95
+ }
96
+ )
97
+ assets[index] = captured
98
+ Scene3dInputPreflight.#replaceAsset(owner, index, asset, captured)
99
+ const bytes = CircuitJsonReadOnlyDocument.assetPayloadByteLength(
100
+ captured,
101
+ metadataBudget
102
+ )
103
+ const assetId = Scene3dInputPreflight.#assetId(captured)
104
+ if (bytes > options.maxAssetBytes) {
105
+ throw Scene3dInputPreflight.#limit(
106
+ bytes,
107
+ options.maxAssetBytes,
108
+ 'asset',
109
+ assetId
110
+ )
111
+ }
112
+ total += bytes
113
+ if (total > options.maxTotalAssetBytes) {
114
+ throw Scene3dInputPreflight.#limit(
115
+ total,
116
+ options.maxTotalAssetBytes,
117
+ 'total',
118
+ assetId
119
+ )
120
+ }
121
+ }
122
+ return assets
123
+ }
124
+
125
+ /**
126
+ * Replaces one caller array item with the exact preflight snapshot.
127
+ * @param {object[]} owner Original dense asset array.
128
+ * @param {number} index Asset index.
129
+ * @param {object} asset Original asset.
130
+ * @param {object} captured Owned asset snapshot.
131
+ * @returns {void}
132
+ */
133
+ static #replaceAsset(owner, index, asset, captured) {
134
+ if (asset === captured) return
135
+ let descriptor
136
+ try {
137
+ descriptor = Object.getOwnPropertyDescriptor(owner, String(index))
138
+ Object.defineProperty(owner, String(index), {
139
+ ...descriptor,
140
+ value: captured
141
+ })
142
+ } catch {
143
+ throw new TypeError(
144
+ 'Canonical document assets could not be captured safely.'
145
+ )
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Reads one own descriptor and normalizes proxy failures.
151
+ * @param {object} owner Envelope candidate.
152
+ * @param {string} key Field name.
153
+ * @returns {PropertyDescriptor | undefined} Own descriptor.
154
+ */
155
+ static #descriptor(owner, key) {
156
+ try {
157
+ return Object.getOwnPropertyDescriptor(owner, key)
158
+ } catch {
159
+ throw new TypeError(
160
+ 'Canonical document could not be inspected safely.'
161
+ )
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Reads one validated asset identifier without coercion.
167
+ * @param {object} asset Canonical asset.
168
+ * @returns {string} Asset id when present.
169
+ */
170
+ static #assetId(asset) {
171
+ const descriptor = Scene3dInputPreflight.#descriptor(asset, 'id')
172
+ return typeof descriptor?.value === 'string' ? descriptor.value : ''
173
+ }
174
+
175
+ /**
176
+ * Creates a typed preflight byte-limit error.
177
+ * @param {number} actual Actual resident bytes.
178
+ * @param {number} maximum Configured maximum.
179
+ * @param {'asset' | 'total'} scope Limit scope.
180
+ * @param {string} assetId Current asset id.
181
+ * @returns {ToolkitError} Typed limit error.
182
+ */
183
+ static #limit(actual, maximum, scope, assetId) {
184
+ return new ToolkitError('Scene asset bytes exceed the safe limit.', {
185
+ code: 'ERR_ASSET_LIMIT',
186
+ category: 'unsupported',
187
+ details: { assetId, scope, actual, maximum }
188
+ })
189
+ }
190
+ }
191
+
192
+ Object.freeze(Scene3dInputPreflight.prototype)
193
+ Object.freeze(Scene3dInputPreflight)
@@ -0,0 +1,58 @@
1
+ import { freezeScene } from './Scene3dFreeze.mjs'
2
+
3
+ /**
4
+ * Builds canonical scene materials while preserving CircuitJSON appearance.
5
+ */
6
+ export class Scene3dMaterials {
7
+ /**
8
+ * Builds one frozen material list from the primary board appearance.
9
+ * @param {object[]} boards PCB board elements.
10
+ * @returns {object[]} Canonical materials.
11
+ */
12
+ static build(boards) {
13
+ const board = boards[0] || {}
14
+ return freezeScene([
15
+ {
16
+ id: 'board-core',
17
+ kind: String(board.material || 'board'),
18
+ color: Scene3dMaterials.#color(
19
+ board.solder_mask_color,
20
+ '#2f7d32'
21
+ ),
22
+ opacity: 1
23
+ },
24
+ {
25
+ id: 'component-body',
26
+ kind: 'component',
27
+ color: '#252525',
28
+ opacity: 1
29
+ },
30
+ {
31
+ id: 'copper',
32
+ kind: 'copper',
33
+ color: '#b87333',
34
+ opacity: 1
35
+ },
36
+ {
37
+ id: 'silkscreen',
38
+ kind: 'silkscreen',
39
+ color: Scene3dMaterials.#color(
40
+ board.silkscreen_color,
41
+ '#f3f3e7'
42
+ ),
43
+ opacity: 1
44
+ }
45
+ ])
46
+ }
47
+
48
+ /**
49
+ * Preserves one bounded color token or returns the canonical fallback.
50
+ * @param {unknown} value Color candidate.
51
+ * @param {string} fallback Fallback color.
52
+ * @returns {string} Color token.
53
+ */
54
+ static #color(value, fallback) {
55
+ const color = String(value || '').trim()
56
+ return color && color.length <= 64 ? color : fallback
57
+ }
58
+ }
@@ -0,0 +1,134 @@
1
+ import { ToolkitError } from '../contracts/ToolkitError.mjs'
2
+
3
+ const MODEL_FIELDS = [
4
+ ['model_obj_url', 'obj'],
5
+ ['model_stl_url', 'stl'],
6
+ ['model_3mf_url', '3mf'],
7
+ ['model_gltf_url', 'gltf'],
8
+ ['model_glb_url', 'glb'],
9
+ ['model_step_url', 'step'],
10
+ ['model_wrl_url', 'wrl']
11
+ ]
12
+
13
+ /**
14
+ * Normalizes canonical CAD component model references.
15
+ */
16
+ export class Scene3dModelReference {
17
+ /**
18
+ * Resolves the first canonical CAD model reference by field precedence.
19
+ * @param {object} cadComponent CAD component element.
20
+ * @returns {{ name: string, format: string, mediaType: string, requiresAsset: boolean, inlineModel?: unknown, generator?: string } | null} Model reference.
21
+ */
22
+ static fromCadComponent(cadComponent) {
23
+ for (const [field, format] of MODEL_FIELDS) {
24
+ const name = String(cadComponent[field] || '').trim()
25
+ if (name) {
26
+ return {
27
+ name: Scene3dModelReference.#boundedName(name),
28
+ format,
29
+ mediaType: Scene3dModelReference.#mediaType(format),
30
+ requiresAsset: true
31
+ }
32
+ }
33
+ }
34
+ const embedded = cadComponent.model_asset
35
+ if (embedded && typeof embedded === 'object') {
36
+ const name = String(
37
+ embedded.project_relative_path || embedded.url || ''
38
+ ).trim()
39
+ if (name) {
40
+ const format = Scene3dModelReference.#formatFromName(name)
41
+ return {
42
+ name: Scene3dModelReference.#boundedName(name),
43
+ format,
44
+ mediaType: String(
45
+ embedded.mimetype ||
46
+ Scene3dModelReference.#mediaType(format)
47
+ ),
48
+ requiresAsset: true
49
+ }
50
+ }
51
+ }
52
+ if (
53
+ Object.hasOwn(cadComponent, 'model_jscad') &&
54
+ cadComponent.model_jscad !== undefined
55
+ ) {
56
+ return {
57
+ name: '',
58
+ format: 'jscad',
59
+ mediaType: 'application/vnd.jscad+json',
60
+ requiresAsset: false,
61
+ inlineModel: cadComponent.model_jscad
62
+ }
63
+ }
64
+ const footprinter = String(cadComponent.footprinter_string || '').trim()
65
+ if (footprinter) {
66
+ return {
67
+ name: '',
68
+ format: 'footprinter',
69
+ mediaType: 'application/x-footprinter',
70
+ requiresAsset: false,
71
+ generator: Scene3dModelReference.#boundedName(footprinter)
72
+ }
73
+ }
74
+ if (cadComponent.size || cadComponent.show_as_bounding_box === true) {
75
+ return {
76
+ name: '',
77
+ format: 'bounding-box',
78
+ mediaType: 'application/x-ecad-bounding-box',
79
+ requiresAsset: false
80
+ }
81
+ }
82
+ return null
83
+ }
84
+
85
+ /**
86
+ * Normalizes a comparison-only asset path.
87
+ * @param {unknown} value Path value.
88
+ * @returns {string} Normalized path.
89
+ */
90
+ static normalizedPath(value) {
91
+ return String(value || '')
92
+ .replaceAll('\\', '/')
93
+ .replace(/^\.\//u, '')
94
+ }
95
+
96
+ /**
97
+ * Bounds one asset reference string.
98
+ * @param {string} name Asset name.
99
+ * @returns {string} Bounded name.
100
+ */
101
+ static #boundedName(name) {
102
+ if (name.length > 4096) {
103
+ throw new ToolkitError('Scene asset name exceeds the safe limit.', {
104
+ code: 'ERR_ASSET_LIMIT',
105
+ category: 'unsupported',
106
+ details: { maximumCharacters: 4096 }
107
+ })
108
+ }
109
+ return name
110
+ }
111
+
112
+ /**
113
+ * Resolves a model format from a file suffix.
114
+ * @param {string} name Asset name.
115
+ * @returns {string} Lowercase model format.
116
+ */
117
+ static #formatFromName(name) {
118
+ const suffix = name.split(/[?#]/u)[0].split('.').pop()
119
+ return String(suffix || 'model').toLowerCase()
120
+ }
121
+
122
+ /**
123
+ * Resolves a common model media type.
124
+ * @param {string} format Model format.
125
+ * @returns {string} Media type.
126
+ */
127
+ static #mediaType(format) {
128
+ return format === 'gltf'
129
+ ? 'model/gltf+json'
130
+ : format === 'glb'
131
+ ? 'model/gltf-binary'
132
+ : `model/${format}`
133
+ }
134
+ }