circuitjson-toolkit 1.0.16 → 1.1.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.
- package/AGENTS.md +15 -0
- package/NOTICE.md +31 -0
- package/README.md +267 -107
- package/docs/api.md +501 -96
- package/docs/capabilities.md +70 -0
- package/docs/migration/behaviors.md +45 -0
- package/docs/migration/parser.md +60 -0
- package/docs/migration/renderers.md +515 -0
- package/docs/migration/root.md +740 -0
- package/docs/migration.md +120 -0
- package/docs/model-format.md +175 -57
- package/docs/provenance.md +206 -0
- package/docs/release-notes-v1.1.0.md +154 -0
- package/docs/testing.md +117 -7
- package/package.json +31 -5
- package/spec/api-baseline-v1.0.17.json +1 -0
- package/spec/baseline-provenance-v1.0.17.json +7 -0
- package/spec/circuitjson-schema-snapshot.json +321 -0
- package/spec/circuitjson-schema-source.json +28 -0
- package/spec/feature-preservation.json +1 -0
- package/spec/library-scope.md +27 -20
- package/src/capabilities.mjs +1 -0
- package/src/core/ArchiveEntryPath.mjs +93 -0
- package/src/core/ArchiveLimits.mjs +31 -0
- package/src/core/ArchiveLimitsValidator.mjs +107 -0
- package/src/core/AsyncInputOwnership.mjs +56 -0
- package/src/core/AttachedValueLimits.mjs +67 -0
- package/src/core/CircuitJsonDiagnosticIndexer.mjs +184 -0
- package/src/core/CircuitJsonDocument.mjs +19 -61
- package/src/core/CircuitJsonElementTypes.mjs +10 -0
- package/src/core/CircuitJsonElementValidator.mjs +98 -847
- package/src/core/CircuitJsonIndexer.mjs +274 -194
- package/src/core/CircuitJsonManufacturingBuilder.mjs +167 -164
- package/src/core/CircuitJsonParser.mjs +75 -13
- package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +12 -6
- package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +108 -10
- package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +1 -1
- package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +44 -38
- package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +146 -28
- package/src/core/CircuitJsonPcbPrimitiveFields.mjs +70 -4
- package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +18 -2
- package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +26 -9
- package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +7 -6
- package/src/core/CircuitJsonSerializedInputAudit.mjs +87 -0
- package/src/core/CircuitJsonSourceMetadata.mjs +5 -1
- package/src/core/CircuitJsonSupportMatrixBuilder.mjs +3 -1
- package/src/core/CircuitJsonToolkitElementSchema.mjs +218 -0
- package/src/core/CircuitJsonUnitParsers.mjs +101 -0
- package/src/core/CircuitJsonUnits.mjs +13 -87
- package/src/core/CircuitJsonUpstreamSchema.mjs +9 -0
- package/src/core/CircuitJsonUpstreamValidator.mjs +418 -0
- package/src/core/CircuitJsonValidationUnits.mjs +6 -0
- package/src/core/ManufacturingService.mjs +323 -0
- package/src/core/Parser.mjs +343 -0
- package/src/core/ParserOptions.mjs +333 -0
- package/src/core/PcbBoundsSelectionModel.mjs +55 -19
- package/src/core/PcbDiagnosticFocusModel.mjs +42 -11
- package/src/core/PcbInteractionIndex.mjs +368 -0
- package/src/core/PcbInteractionPrimitiveModel.mjs +393 -62
- package/src/core/ProjectAsyncInputOwner.mjs +70 -0
- package/src/core/ProjectLoader.mjs +975 -0
- package/src/core/SimulationService.mjs +790 -0
- package/src/core/ToolkitCapabilities.mjs +130 -0
- package/src/core/ZipArchiveInspector.mjs +649 -0
- package/src/core/context/BinaryDataSnapshot.mjs +217 -0
- package/src/core/context/CircuitJsonContextIndexes.mjs +96 -0
- package/src/core/context/CircuitJsonDerivedCache.mjs +114 -0
- package/src/core/context/CircuitJsonDocumentContext.mjs +353 -0
- package/src/core/context/CircuitJsonLegacyModel.mjs +147 -0
- package/src/core/context/CircuitJsonLegacyNormalizer.mjs +847 -0
- package/src/core/context/CircuitJsonMetadataBoundary.mjs +76 -0
- package/src/core/context/CircuitJsonModelFreezeTraversal.mjs +179 -0
- package/src/core/context/CircuitJsonReadOnlyDocument.mjs +920 -0
- package/src/core/context/CircuitJsonSchematicTableNormalizer.mjs +314 -0
- package/src/core/context/CircuitJsonValidationAuthority.mjs +39 -0
- package/src/core/context/CircuitJsonValidationProof.mjs +217 -0
- package/src/core/context/PcbPrimitivePreparation.mjs +198 -0
- package/src/core/context/PcbSpatialIndex.mjs +701 -0
- package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +128 -0
- package/src/core/context/StructuredDataSnapshot.mjs +683 -0
- package/src/core/contracts/DocumentResult.mjs +198 -0
- package/src/core/contracts/ProjectResult.mjs +96 -0
- package/src/core/contracts/RuntimeProxyBoundary.mjs +48 -0
- package/src/core/contracts/ToolkitAsset.mjs +493 -0
- package/src/core/contracts/ToolkitDiagnostic.mjs +38 -0
- package/src/core/contracts/ToolkitError.mjs +176 -0
- package/src/core/contracts/ToolkitProgress.mjs +89 -0
- package/src/core/interaction/CanonicalInteractionOptions.mjs +246 -0
- package/src/core/interaction/PcbInteractionBounds.mjs +167 -0
- package/src/core/query/CircuitTraversal.mjs +343 -0
- package/src/core/query/ComponentGrouping.mjs +275 -0
- package/src/core/query/QueryNetlistBuilder.mjs +306 -0
- package/src/core/query/QueryService.mjs +435 -0
- package/src/core/query/RegexPattern.mjs +75 -0
- package/src/core/rendering/CanonicalBomOrder.mjs +81 -0
- package/src/core/rendering/CanonicalBomRows.mjs +92 -0
- package/src/core/rendering/CanonicalRenderOptions.mjs +498 -0
- package/src/core/rendering/CanonicalSvgDocument.mjs +102 -0
- package/src/core/rendering/PcbRenderPlan.mjs +429 -0
- package/src/core/rendering/SchematicSheetSelector.mjs +335 -0
- package/src/core/scene3d/PcbScene3dBuilder.mjs +906 -0
- package/src/core/scene3d/PcbScene3dPreparator.mjs +47 -0
- package/src/core/scene3d/Scene3dAssetIndex.mjs +284 -0
- package/src/core/scene3d/Scene3dBoardModel.mjs +596 -0
- package/src/core/scene3d/Scene3dDocumentMetadata.mjs +167 -0
- package/src/core/scene3d/Scene3dFreeze.mjs +37 -0
- package/src/core/scene3d/Scene3dIdRegistry.mjs +34 -0
- package/src/core/scene3d/Scene3dInputPreflight.mjs +193 -0
- package/src/core/scene3d/Scene3dMaterials.mjs +58 -0
- package/src/core/scene3d/Scene3dModelReference.mjs +134 -0
- package/src/core/scene3d/Scene3dOptions.mjs +363 -0
- package/src/core/scene3d/SceneAssetResolver.mjs +441 -0
- package/src/core/simulation/SimulationParameterCloner.mjs +543 -0
- package/src/core/worker/ParserWorkerClient.mjs +997 -0
- package/src/core/worker/ToolkitWorkerProtocol.mjs +412 -0
- package/src/core/worker/WorkerRequestData.mjs +623 -0
- package/src/core/worker/WorkerResponseData.mjs +905 -0
- package/src/extensions.mjs +37 -0
- package/src/index.mjs +14 -9
- package/src/interaction.mjs +2 -0
- package/src/manufacturing.mjs +1 -0
- package/src/parser.mjs +12 -2
- package/src/project.mjs +5 -0
- package/src/query.mjs +1 -0
- package/src/renderers.mjs +3 -29
- package/src/scene3d.mjs +3 -0
- package/src/simulation.mjs +1 -0
- package/src/styles/renderers.css +24 -0
- package/src/testing/ToolkitContractFixtures.mjs +124 -0
- package/src/testing/ToolkitLoopbackWorker.mjs +174 -0
- package/src/testing/runToolkitContract.mjs +705 -0
- package/src/testing.mjs +3 -0
- package/src/ui/BomTableRenderer.mjs +304 -0
- package/src/ui/CircuitJsonPcbBoardSvgRenderer.mjs +80 -0
- package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +3 -5
- package/src/ui/CircuitJsonPcbSvgRenderer.mjs +63 -43
- package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +3 -5
- package/src/ui/CircuitJsonSchematicDebugRenderer.mjs +164 -0
- package/src/ui/CircuitJsonSchematicImageSvgRenderer.mjs +210 -0
- package/src/ui/CircuitJsonSchematicLineRenderer.mjs +86 -0
- package/src/ui/CircuitJsonSchematicSheetSymbolSvgRenderer.mjs +98 -0
- package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +117 -17
- package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +67 -20
- package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +45 -9
- package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +151 -148
- package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +4 -292
- package/src/ui/PcbSvgRenderer.mjs +41 -0
- package/src/ui/SafeSvgPaint.mjs +26 -0
- package/src/ui/SafeXmlText.mjs +60 -0
- package/src/ui/SchematicGeometryBounds.mjs +540 -0
- package/src/ui/SchematicSvgRenderer.mjs +110 -0
- package/src/ui/SchematicTableGeometry.mjs +319 -0
- package/src/ui/SchematicTextAnchor.mjs +55 -0
- package/src/ui/SchematicTextBounds.mjs +98 -0
- 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
|
+
}
|