@instruktlabs/kiln 1.2.2 → 1.2.3
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +74 -0
- package/README.md +7 -0
- package/dist/agent-run.mjs +6392 -1477
- package/dist/build.json +23 -23
- package/dist/cli.mjs +8453 -3407
- package/dist/evaluator-worker.mjs +16608 -12387
- package/dist/mcp-engine.mjs +6746 -1725
- package/dist/mcp-server.mjs +4 -4
- package/dist/viewer/app.js +203 -199
- package/dist/viewer/chat.html +209 -205
- package/dist/viewer/style.css +10 -0
- package/docs/atlas-provenance.md +23 -0
- package/docs/community-exporter.md +27 -0
- package/docs/export-profiles.md +20 -0
- package/docs/material-library.md +9 -5
- package/docs/procedural-surfaces-v3.md +276 -0
- package/docs/projects-and-live-review.md +1 -1
- package/docs/rendering.md +41 -0
- package/docs/sdk.md +53 -0
- package/docs/tools-operations.md +2 -2
- package/docs/tools.md +3 -3
- package/lib/asset-materials-node.d.ts +3 -3
- package/lib/asset-materials-node.js +29 -33
- package/lib/assets-node.d.ts +1 -1
- package/lib/assets-node.js +11 -4
- package/lib/assets.d.ts +4 -2
- package/lib/assets.js +61 -16
- package/lib/atlas-provenance.d.ts +86 -0
- package/lib/atlas-provenance.js +448 -0
- package/lib/character.d.ts +11 -2
- package/lib/character.js +90 -2
- package/lib/community-exporter.d.ts +3 -2
- package/lib/community-exporter.js +139 -3
- package/lib/discovery/helper-contracts.js +161 -7
- package/lib/discovery/helper-specs.js +49 -4
- package/lib/discovery/shapes.js +20 -7
- package/lib/engine-identity.d.ts +1 -1
- package/lib/engine-identity.js +1 -1
- package/lib/evaluator/authoring-diagnostic.d.ts +1 -1
- package/lib/evaluator/authoring-diagnostic.js +12 -0
- package/lib/evaluator/protocol.d.ts +2 -2
- package/lib/evaluator/protocol.js +16 -6
- package/lib/evaluator/subprocess.js +2 -2
- package/lib/exporter-node.js +9 -1
- package/lib/geometry-budget.d.ts +7 -0
- package/lib/geometry-budget.js +8 -0
- package/lib/gltf-texture-binding.d.ts +6 -0
- package/lib/gltf-texture-binding.js +54 -0
- package/lib/material-authoring-node.d.ts +6 -0
- package/lib/material-authoring-node.js +49 -0
- package/lib/material-authoring-schema.d.ts +871 -0
- package/lib/material-authoring-schema.js +310 -0
- package/lib/material-export.d.ts +5 -0
- package/lib/material-export.js +21 -0
- package/lib/material-library-node.d.ts +34 -8
- package/lib/material-library-node.js +691 -11
- package/lib/material-library.d.ts +3704 -4
- package/lib/material-library.js +272 -0
- package/lib/mcp-core.d.ts +1 -1
- package/lib/pinned-material-map.d.ts +33 -0
- package/lib/pinned-material-map.js +1 -0
- package/lib/portable-material-runtime.d.ts +5 -0
- package/lib/portable-material-runtime.js +160 -0
- package/lib/portable-sampler-runtime.d.ts +4 -0
- package/lib/portable-sampler-runtime.js +26 -0
- package/lib/portable-sampler.d.ts +11 -0
- package/lib/portable-sampler.js +42 -0
- package/lib/primitives.d.ts +15 -3
- package/lib/primitives.js +24 -4
- package/lib/procedural-material-v2.d.ts +2 -0
- package/lib/procedural-material-v2.js +2 -1
- package/lib/procedural-material-v3.d.ts +72 -0
- package/lib/procedural-material-v3.js +162 -0
- package/lib/procedural-texture-v2-compiler.d.ts +31 -0
- package/lib/procedural-texture-v2-compiler.js +172 -0
- package/lib/procedural-texture-v3-contract.d.ts +125 -0
- package/lib/procedural-texture-v3-contract.js +333 -0
- package/lib/procedural-texture-v3.d.ts +12 -0
- package/lib/procedural-texture-v3.js +165 -0
- package/lib/procedural-texture.d.ts +9 -31
- package/lib/procedural-texture.js +43 -170
- package/lib/project-bundle-node.d.ts +1 -1
- package/lib/project-bundle-node.js +70 -13
- package/lib/project-bundle.d.ts +91 -3
- package/lib/project-bundle.js +48 -7
- package/lib/qa/atlas.d.ts +21 -0
- package/lib/qa/atlas.js +474 -0
- package/lib/qa/index.d.ts +1 -0
- package/lib/qa/index.js +1 -0
- package/lib/qa/material.js +2 -0
- package/lib/qa/skin-integrity.d.ts +18 -0
- package/lib/qa/skin-integrity.js +221 -0
- package/lib/render-service-host.js +4 -7
- package/lib/render.d.ts +3 -3
- package/lib/render.js +108 -16
- package/lib/skin-binding.d.ts +20 -0
- package/lib/skin-binding.js +144 -0
- package/lib/texture-atlas-contract.d.ts +97 -0
- package/lib/texture-atlas-contract.js +414 -0
- package/lib/texture-atlas-pixels.d.ts +73 -0
- package/lib/texture-atlas-pixels.js +290 -0
- package/lib/texture-atlas.d.ts +10 -0
- package/lib/texture-atlas.js +143 -0
- package/lib/texture-bake.d.ts +3 -0
- package/lib/texture-bake.js +66 -11
- package/lib/texture-resolver.d.ts +22 -0
- package/lib/texture-resolver.js +24 -0
- package/lib/tools/materials.js +8 -7
- package/lib/tools/registry.js +5 -16
- package/lib/tools/review.js +1 -1
- package/lib/uv-data.d.ts +32 -0
- package/lib/uv-data.js +172 -0
- package/lib/uv-shapes.js +3 -0
- package/lib/uv.d.ts +9 -3
- package/lib/uv.js +53 -82
- package/lib/views/camera.d.ts +3 -3
- package/lib/views/camera.js +32 -28
- package/lib/views/face-policy.d.ts +9 -0
- package/lib/views/face-policy.js +16 -0
- package/lib/views/glb.js +218 -24
- package/lib/views/pose.d.ts +7 -1
- package/lib/views/pose.js +63 -12
- package/lib/views/raster.d.ts +6 -2
- package/lib/views/raster.js +49 -37
- package/lib/views/scene-copy.d.ts +6 -0
- package/lib/views/scene-copy.js +73 -0
- package/lib/views/scene-raster.js +2 -1
- package/lib/views/triangle-coverage.d.ts +8 -0
- package/lib/views/triangle-coverage.js +58 -0
- package/lib/workspace-node.js +7 -3
- package/lib/workspace.d.ts +2 -2
- package/package.json +3 -1
- package/plugin.json +1 -1
- package/plugins/kiln-engine/.claude-plugin/plugin.json +1 -1
- package/plugins/kiln-engine/README.md +3 -3
- package/plugins/kiln-engine/package-provenance.json +6 -6
- package/plugins/kiln-engine/plugin.json +1 -1
- package/plugins/kiln-engine/runtime.json +1 -1
- package/skills/kiln-author-asset/references/geometry-recipes.md +124 -0
- package/skills/kiln-author-asset/references/projects-and-materials.md +32 -2
- package/skills/kiln-compose-scene/references/runtime-scenes.md +19 -0
- package/skills/kiln-qa-asset/references/integration-checks.md +37 -2
- package/src/asset-materials-node.ts +38 -45
- package/src/assets-node.ts +22 -5
- package/src/assets.ts +61 -20
- package/src/atlas-provenance.ts +522 -0
- package/src/character.ts +107 -2
- package/src/community-exporter.ts +143 -3
- package/src/discovery/helper-contracts.ts +188 -7
- package/src/discovery/helper-specs.ts +69 -4
- package/src/discovery/shapes.ts +25 -9
- package/src/engine-identity.ts +1 -1
- package/src/evaluator/authoring-diagnostic.ts +19 -1
- package/src/evaluator/protocol.ts +19 -9
- package/src/evaluator/subprocess.ts +2 -2
- package/src/exporter-node.ts +12 -1
- package/src/generated/mcp-manifest.json +3 -3
- package/src/geometry-budget.ts +8 -0
- package/src/gltf-texture-binding.ts +60 -0
- package/src/material-authoring-node.ts +68 -0
- package/src/material-authoring-schema.ts +343 -0
- package/src/material-export.ts +25 -0
- package/src/material-library-node.ts +840 -16
- package/src/material-library.ts +348 -5
- package/src/pinned-material-map.ts +43 -0
- package/src/portable-material-runtime.ts +200 -0
- package/src/portable-sampler-runtime.ts +40 -0
- package/src/portable-sampler.ts +54 -0
- package/src/primitives.ts +98 -4
- package/src/procedural-material-v2.ts +2 -1
- package/src/procedural-material-v3.ts +224 -0
- package/src/procedural-texture-v2-compiler.ts +234 -0
- package/src/procedural-texture-v3-contract.ts +437 -0
- package/src/procedural-texture-v3.ts +220 -0
- package/src/procedural-texture.ts +61 -229
- package/src/project-bundle-node.ts +106 -14
- package/src/project-bundle.ts +61 -18
- package/src/project-cli.ts +29 -17
- package/src/qa/atlas.ts +597 -0
- package/src/qa/index.ts +1 -0
- package/src/qa/material.ts +2 -0
- package/src/qa/skin-integrity.ts +291 -0
- package/src/render-service-host.ts +4 -6
- package/src/render.ts +133 -15
- package/src/skin-binding.ts +225 -0
- package/src/texture-atlas-contract.ts +558 -0
- package/src/texture-atlas-pixels.ts +456 -0
- package/src/texture-atlas.ts +185 -0
- package/src/texture-bake.ts +88 -10
- package/src/texture-resolver.ts +55 -0
- package/src/tools/materials.ts +15 -11
- package/src/tools/registry.ts +5 -15
- package/src/tools/review.ts +1 -1
- package/src/uv-data.ts +238 -0
- package/src/uv-shapes.ts +3 -0
- package/src/uv.ts +73 -128
- package/src/viewer/app.ts +37 -0
- package/src/viewer/live-panel.ts +57 -1
- package/src/viewer/material-preview-panel.ts +140 -0
- package/src/viewer/material-preview.ts +138 -0
- package/src/viewer/materials-panel.ts +34 -5
- package/src/viewer/navigation.ts +2 -0
- package/src/views/camera.ts +37 -40
- package/src/views/face-policy.ts +20 -0
- package/src/views/glb.ts +294 -17
- package/src/views/pose.ts +71 -13
- package/src/views/raster.ts +73 -46
- package/src/views/scene-copy.ts +86 -0
- package/src/views/scene-raster.ts +2 -1
- package/src/views/triangle-coverage.ts +78 -0
- package/src/workspace-node.ts +11 -3
- package/src/workspace.ts +2 -2
package/src/assets.ts
CHANGED
|
@@ -5,10 +5,12 @@ import { zipSync, unzipSync } from 'three/addons/libs/fflate.module.js';
|
|
|
5
5
|
import {
|
|
6
6
|
assertMaterialJson,
|
|
7
7
|
canonicalMaterialJson,
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
materialLibraryPayloadAnySchema,
|
|
9
|
+
materialRecordDependencyPins,
|
|
10
|
+
validateMaterialManifestAny,
|
|
10
11
|
MATERIAL_LIBRARY_LIMITS,
|
|
11
|
-
type
|
|
12
|
+
type MaterialLibraryPayload,
|
|
13
|
+
type MaterialManifest,
|
|
12
14
|
} from './material-library';
|
|
13
15
|
|
|
14
16
|
export const ASSET_LIMIT = 64 * 1024 * 1024;
|
|
@@ -61,7 +63,7 @@ export interface AssetRecord {
|
|
|
61
63
|
manifest: AssetManifest;
|
|
62
64
|
files: Record<string, Uint8Array>;
|
|
63
65
|
/** Editable delivery closure; not part of the canonical asset manifest or revision identity. */
|
|
64
|
-
materialResources?:
|
|
66
|
+
materialResources?: MaterialLibraryPayload;
|
|
65
67
|
}
|
|
66
68
|
export interface AssetDraft {
|
|
67
69
|
name: string;
|
|
@@ -90,35 +92,59 @@ export interface AssetLibrary {
|
|
|
90
92
|
}
|
|
91
93
|
export const ASSET_MATERIAL_FILE = 'materials.kiln.json';
|
|
92
94
|
export const ASSET_MATERIAL_BYTES = 24 * 1024 * 1024;
|
|
95
|
+
/** Pure declaration boundary shared by portable delivery and host hash verification. */
|
|
96
|
+
export function assetMaterialRootManifests(record: AssetRecord): MaterialManifest[] {
|
|
97
|
+
const roots = new Map<string, MaterialManifest>();
|
|
98
|
+
for (const raw of record.manifest.build?.dependencies ?? []) {
|
|
99
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
100
|
+
throw new Error('Invalid saved asset dependency');
|
|
101
|
+
const dependency = raw as Record<string, unknown>;
|
|
102
|
+
if (dependency.kind !== 'kiln.material.v1' && dependency.kind !== 'kiln.material.v2') {
|
|
103
|
+
if (dependency.delivery === 'runtime') throw new Error('Unsupported runtime dependency');
|
|
104
|
+
if (dependency.delivery !== 'embedded') throw new Error('Unsupported saved asset dependency');
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (dependency.delivery !== 'runtime') throw new Error('Invalid saved material dependency');
|
|
108
|
+
const manifest = validateMaterialManifestAny(dependency.manifest);
|
|
109
|
+
if (dependency.kind !== `kiln.material.v${manifest.schemaVersion}`)
|
|
110
|
+
throw new Error('Saved material dependency kind/version mismatch');
|
|
111
|
+
const key = `${manifest.materialId}/${manifest.revisionId}`;
|
|
112
|
+
const existing = roots.get(key);
|
|
113
|
+
if (existing && canonicalMaterialJson(existing) !== canonicalMaterialJson(manifest))
|
|
114
|
+
throw new Error('Conflicting saved material dependency manifests');
|
|
115
|
+
roots.set(key, manifest);
|
|
116
|
+
if (roots.size > MATERIAL_LIBRARY_LIMITS.maxPayloadRecords)
|
|
117
|
+
throw new Error('Saved asset material dependency count exceeds limit');
|
|
118
|
+
}
|
|
119
|
+
return [...roots.values()];
|
|
120
|
+
}
|
|
93
121
|
/** Shape and closure checks are portable; hosts verify hashes and PNG/recipe bytes before import. */
|
|
94
122
|
export function validateAssetMaterialClosure(record: AssetRecord, required = false): void {
|
|
95
|
-
const dependencies = (record
|
|
96
|
-
(raw) =>
|
|
97
|
-
raw &&
|
|
98
|
-
typeof raw === 'object' &&
|
|
99
|
-
(raw as Record<string, unknown>).kind === 'kiln.material.v1',
|
|
100
|
-
) as { manifest: unknown; delivery: unknown }[];
|
|
123
|
+
const dependencies = assetMaterialRootManifests(record);
|
|
101
124
|
const expected = new Map(
|
|
102
|
-
dependencies.map((
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}),
|
|
125
|
+
dependencies.map((manifest) => [
|
|
126
|
+
`${manifest.materialId}/${manifest.revisionId}`,
|
|
127
|
+
canonicalMaterialJson(manifest),
|
|
128
|
+
]),
|
|
107
129
|
);
|
|
108
130
|
if (!record.materialResources) {
|
|
109
131
|
if (required && expected.size) throw new Error('Editable asset material closure unavailable');
|
|
110
132
|
return;
|
|
111
133
|
}
|
|
112
134
|
assertMaterialJson(record.materialResources);
|
|
113
|
-
const payload =
|
|
135
|
+
const payload = materialLibraryPayloadAnySchema.parse(record.materialResources);
|
|
114
136
|
let bytes = 0;
|
|
115
137
|
let pixels = 0;
|
|
116
|
-
const
|
|
138
|
+
const available = new Map<string, MaterialManifest>();
|
|
117
139
|
for (const wire of payload.records) {
|
|
140
|
+
const manifest = validateMaterialManifestAny(wire.manifest);
|
|
118
141
|
const key = `${wire.manifest.materialId}/${wire.manifest.revisionId}`;
|
|
119
|
-
if (
|
|
142
|
+
if (
|
|
143
|
+
available.has(key) ||
|
|
144
|
+
(expected.has(key) && expected.get(key) !== canonicalMaterialJson(manifest))
|
|
145
|
+
)
|
|
120
146
|
throw new Error('Asset material closure mismatch');
|
|
121
|
-
|
|
147
|
+
available.set(key, manifest);
|
|
122
148
|
if (Object.keys(wire.files).length !== wire.manifest.maps.length)
|
|
123
149
|
throw new Error('Asset material map inventory mismatch');
|
|
124
150
|
for (const map of wire.manifest.maps) {
|
|
@@ -128,12 +154,27 @@ export function validateAssetMaterialClosure(record: AssetRecord, required = fal
|
|
|
128
154
|
pixels += map.width * map.height;
|
|
129
155
|
}
|
|
130
156
|
}
|
|
131
|
-
if (seen.size !== expected.size) throw new Error('Asset material closure incomplete');
|
|
132
157
|
if (
|
|
133
158
|
bytes > MATERIAL_LIBRARY_LIMITS.maxPayloadBytes ||
|
|
134
159
|
pixels > MATERIAL_LIBRARY_LIMITS.maxPayloadPixels
|
|
135
160
|
)
|
|
136
161
|
throw new Error('Asset material closure exceeds allocation limits');
|
|
162
|
+
const visited = new Set<string>();
|
|
163
|
+
const active = new Set<string>();
|
|
164
|
+
const visit = (key: string): void => {
|
|
165
|
+
if (active.has(key)) throw new Error('Asset material closure contains a cycle');
|
|
166
|
+
if (visited.has(key)) return;
|
|
167
|
+
const manifest = available.get(key);
|
|
168
|
+
if (!manifest) throw new Error('Asset material closure incomplete');
|
|
169
|
+
active.add(key);
|
|
170
|
+
for (const pin of materialRecordDependencyPins(manifest))
|
|
171
|
+
visit(`${pin.resourceId}/${pin.revisionId}`);
|
|
172
|
+
active.delete(key);
|
|
173
|
+
visited.add(key);
|
|
174
|
+
};
|
|
175
|
+
for (const key of expected.keys()) visit(key);
|
|
176
|
+
if (visited.size !== available.size)
|
|
177
|
+
throw new Error('Asset material closure has unrelated records');
|
|
137
178
|
}
|
|
138
179
|
const allowedFiles = new Set(['asset.glb', 'source.kiln.js', 'preview.png']);
|
|
139
180
|
export function validateRecordShape(record: AssetRecord): void {
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/** Internal atlas metadata owner. TextureInfo, rather than an image, owns each sampler use. */
|
|
2
|
+
import type { Document, Texture as GltfTexture, TextureInfo } from '@gltf-transform/core';
|
|
3
|
+
import * as THREE from 'three';
|
|
4
|
+
import { restoreGltfTextureBinding } from './gltf-texture-binding';
|
|
5
|
+
import {
|
|
6
|
+
canonicalAtlasMetadataJsonV1,
|
|
7
|
+
canonicalizeTextureAtlasSpecV1,
|
|
8
|
+
validateTextureAtlasManifestV1,
|
|
9
|
+
type CanonicalTextureAtlasSpecV1,
|
|
10
|
+
} from './texture-atlas-contract';
|
|
11
|
+
import type { TextureAtlasManifestV1 } from './texture-atlas-pixels';
|
|
12
|
+
|
|
13
|
+
export const ATLAS_PROVENANCE_VERSION = 'kiln.atlas-provenance.v1' as const;
|
|
14
|
+
const TABLE_ENVELOPE_BYTES = new TextEncoder().encode(
|
|
15
|
+
JSON.stringify({ version: ATLAS_PROVENANCE_VERSION, atlases: [] }),
|
|
16
|
+
).length;
|
|
17
|
+
export const ATLAS_PROVENANCE_LIMITS = Object.freeze({
|
|
18
|
+
atlases: 128,
|
|
19
|
+
bytes: 4_194_304,
|
|
20
|
+
bindings: 1024,
|
|
21
|
+
entryBytes: 262_144,
|
|
22
|
+
encodedBytes: 16_777_216,
|
|
23
|
+
aggregateEncodedBytes: 67_108_864,
|
|
24
|
+
bakeDecodedBytes: 67_108_864,
|
|
25
|
+
});
|
|
26
|
+
export interface AtlasProvenanceV1 {
|
|
27
|
+
spec: CanonicalTextureAtlasSpecV1;
|
|
28
|
+
manifest: TextureAtlasManifestV1;
|
|
29
|
+
}
|
|
30
|
+
export interface AtlasProvenanceTableV1 {
|
|
31
|
+
version: typeof ATLAS_PROVENANCE_VERSION;
|
|
32
|
+
atlases: (AtlasProvenanceV1 & { id: string })[];
|
|
33
|
+
}
|
|
34
|
+
export interface AtlasReferenceV1 {
|
|
35
|
+
version: typeof ATLAS_PROVENANCE_VERSION;
|
|
36
|
+
id: string;
|
|
37
|
+
effectiveSampler?: {
|
|
38
|
+
minFilter: number | null;
|
|
39
|
+
magFilter: number | null;
|
|
40
|
+
wrapS: number;
|
|
41
|
+
wrapT: number;
|
|
42
|
+
};
|
|
43
|
+
finalMime?: string;
|
|
44
|
+
finalImageSha256?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface AtlasBindingV1 {
|
|
47
|
+
version: typeof ATLAS_PROVENANCE_VERSION;
|
|
48
|
+
atlasId: string;
|
|
49
|
+
region: string;
|
|
50
|
+
inputOrigin: 'top-left' | 'bottom-left';
|
|
51
|
+
mode: 'content-boundary' | 'texel-center';
|
|
52
|
+
}
|
|
53
|
+
function fail(message: string): never {
|
|
54
|
+
throw new TypeError(`atlas provenance: ${message}`);
|
|
55
|
+
}
|
|
56
|
+
function field(input: unknown, key: string): unknown {
|
|
57
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
58
|
+
fail('plain metadata object required.');
|
|
59
|
+
const proto = Object.getPrototypeOf(input);
|
|
60
|
+
if (proto !== null && proto !== Object.prototype) fail('plain metadata prototype required.');
|
|
61
|
+
const d = Object.getOwnPropertyDescriptor(input, key);
|
|
62
|
+
if (!d) return undefined;
|
|
63
|
+
if (!d.enumerable || !('value' in d)) fail('metadata accessors/hidden fields are unsupported.');
|
|
64
|
+
return d.value;
|
|
65
|
+
}
|
|
66
|
+
function closed(input: unknown, keys: readonly string[]): Record<string, unknown> {
|
|
67
|
+
for (const key of Reflect.ownKeys(input as object))
|
|
68
|
+
if (typeof key !== 'string' || !keys.includes(key)) fail('unsupported metadata field.');
|
|
69
|
+
for (const key of keys) field(input, key);
|
|
70
|
+
return input as Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
function normalize(input: unknown): AtlasProvenanceV1 {
|
|
73
|
+
canonicalAtlasMetadataJsonV1(input);
|
|
74
|
+
const r = closed(input, ['spec', 'manifest']);
|
|
75
|
+
const spec = canonicalizeTextureAtlasSpecV1(r['spec']);
|
|
76
|
+
return { spec, manifest: validateTextureAtlasManifestV1(r['manifest'], spec) };
|
|
77
|
+
}
|
|
78
|
+
function textureCandidates(texture: THREE.Texture): unknown[] {
|
|
79
|
+
const direct = field(texture.userData, 'kilnAtlas'),
|
|
80
|
+
resource = field(texture.userData, 'kilnMaterialResource');
|
|
81
|
+
const map = resource === undefined ? undefined : field(resource, 'map'),
|
|
82
|
+
recipe = map === undefined ? undefined : field(map, 'recipe');
|
|
83
|
+
const candidates: unknown[] = [];
|
|
84
|
+
if (direct !== undefined) candidates.push(direct);
|
|
85
|
+
if (recipe !== undefined && field(recipe, 'kind') === 'atlas')
|
|
86
|
+
candidates.push({ spec: field(recipe, 'spec'), manifest: field(recipe, 'manifest') });
|
|
87
|
+
return candidates;
|
|
88
|
+
}
|
|
89
|
+
function normalizeCandidates(candidates: unknown[]): AtlasProvenanceV1 | undefined {
|
|
90
|
+
let result: AtlasProvenanceV1 | undefined;
|
|
91
|
+
for (const candidate of candidates) {
|
|
92
|
+
const p = normalize(candidate);
|
|
93
|
+
if (result && canonicalAtlasMetadataJsonV1(result) !== canonicalAtlasMetadataJsonV1(p))
|
|
94
|
+
fail('direct and saved atlas identities disagree.');
|
|
95
|
+
result = p;
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
/** Reads only engine metadata data descriptors; author metadata and encoded arrays are not serialized. */
|
|
100
|
+
export function readAtlasTextureProvenance(texture: THREE.Texture): AtlasProvenanceV1 | undefined {
|
|
101
|
+
return normalizeCandidates(textureCandidates(texture));
|
|
102
|
+
}
|
|
103
|
+
function admitTable(input: unknown): Record<string, unknown>[] {
|
|
104
|
+
const r = closed(input, ['version', 'atlases']);
|
|
105
|
+
if (r['version'] !== ATLAS_PROVENANCE_VERSION) fail('unsupported version.');
|
|
106
|
+
const entries = r['atlases'];
|
|
107
|
+
if (!Array.isArray(entries) || Object.getPrototypeOf(entries) !== Array.prototype)
|
|
108
|
+
fail('dense atlas array required.');
|
|
109
|
+
const n = Object.getOwnPropertyDescriptor(entries, 'length')!.value;
|
|
110
|
+
if (n > ATLAS_PROVENANCE_LIMITS.atlases || Reflect.ownKeys(entries).length !== n + 1)
|
|
111
|
+
fail('atlas count/density budget exceeded.');
|
|
112
|
+
let bytes = TABLE_ENVELOPE_BYTES;
|
|
113
|
+
const admitted: Record<string, unknown>[] = [];
|
|
114
|
+
for (let i = 0; i < n; i++) {
|
|
115
|
+
const d = Object.getOwnPropertyDescriptor(entries, String(i));
|
|
116
|
+
if (!d?.enumerable || !('value' in d)) fail('atlas array accessors are unsupported.');
|
|
117
|
+
const json = canonicalAtlasMetadataJsonV1(d.value);
|
|
118
|
+
bytes += new TextEncoder().encode(json).length + (i > 0 ? 1 : 0);
|
|
119
|
+
if (bytes > ATLAS_PROVENANCE_LIMITS.bytes) fail('aggregate metadata byte budget exceeded.');
|
|
120
|
+
admitted.push(closed(d.value, ['id', 'spec', 'manifest']));
|
|
121
|
+
}
|
|
122
|
+
return admitted;
|
|
123
|
+
}
|
|
124
|
+
export function validateAtlasProvenanceTable(input: unknown): AtlasProvenanceTableV1 {
|
|
125
|
+
const admitted = admitTable(input),
|
|
126
|
+
ids = new Set<string>(),
|
|
127
|
+
atlases: AtlasProvenanceTableV1['atlases'] = [];
|
|
128
|
+
for (const e of admitted) {
|
|
129
|
+
const p = normalize({ spec: e['spec'], manifest: e['manifest'] });
|
|
130
|
+
if (e['id'] !== p.manifest.manifestHash || ids.has(e['id'] as string))
|
|
131
|
+
fail('duplicate or mismatched atlas identity.');
|
|
132
|
+
ids.add(e['id'] as string);
|
|
133
|
+
atlases.push({ id: e['id'] as string, ...p });
|
|
134
|
+
}
|
|
135
|
+
return { version: ATLAS_PROVENANCE_VERSION, atlases };
|
|
136
|
+
}
|
|
137
|
+
function lookup(table: AtlasProvenanceTableV1, id: unknown): AtlasProvenanceV1 {
|
|
138
|
+
const found = table.atlases.find((a) => a.id === id);
|
|
139
|
+
if (!found) fail('unknown atlas reference.');
|
|
140
|
+
return found;
|
|
141
|
+
}
|
|
142
|
+
function derivedBinding(p: AtlasProvenanceV1, regionName: unknown, origin: unknown, mode: unknown) {
|
|
143
|
+
if (
|
|
144
|
+
!['top-left', 'bottom-left'].includes(origin as string) ||
|
|
145
|
+
!['content-boundary', 'texel-center'].includes(mode as string)
|
|
146
|
+
)
|
|
147
|
+
fail('explicit binding origin/mode required.');
|
|
148
|
+
const r = p.manifest.regions.find((r) => r.name === regionName);
|
|
149
|
+
if (!r) fail('unknown named region.');
|
|
150
|
+
const { x, y, width: w, height: h } = r.content,
|
|
151
|
+
center = mode === 'texel-center',
|
|
152
|
+
bottom = origin === 'bottom-left';
|
|
153
|
+
return {
|
|
154
|
+
manifestHash: p.manifest.manifestHash,
|
|
155
|
+
specHash: p.manifest.specHash,
|
|
156
|
+
layoutHash: p.manifest.layoutHash,
|
|
157
|
+
region: r.name,
|
|
158
|
+
content: structuredClone(r.content),
|
|
159
|
+
allocation: structuredClone(r.allocation),
|
|
160
|
+
inputOrigin: origin,
|
|
161
|
+
mode,
|
|
162
|
+
scale: [
|
|
163
|
+
(w - (center ? 1 : 0)) / p.manifest.width,
|
|
164
|
+
((bottom ? -1 : 1) * (h - (center ? 1 : 0))) / p.manifest.height,
|
|
165
|
+
],
|
|
166
|
+
offset: [
|
|
167
|
+
(x + (center ? 0.5 : 0)) / p.manifest.width,
|
|
168
|
+
(y + (bottom ? h - (center ? 0.5 : 0) : center ? 0.5 : 0)) / p.manifest.height,
|
|
169
|
+
],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
export function expandAtlasBinding(input: unknown, table: AtlasProvenanceTableV1) {
|
|
173
|
+
canonicalAtlasMetadataJsonV1(input);
|
|
174
|
+
const r = closed(input, ['version', 'atlasId', 'region', 'inputOrigin', 'mode']);
|
|
175
|
+
if (r['version'] !== ATLAS_PROVENANCE_VERSION) fail('unsupported binding version.');
|
|
176
|
+
return derivedBinding(lookup(table, r['atlasId']), r['region'], r['inputOrigin'], r['mode']);
|
|
177
|
+
}
|
|
178
|
+
/** A geometry-wide stamp can be associated only with the exact primary atlas supplied here. */
|
|
179
|
+
export function readAtlasGeometryBinding(
|
|
180
|
+
geometry: THREE.BufferGeometry,
|
|
181
|
+
provenance: AtlasProvenanceV1,
|
|
182
|
+
): AtlasBindingV1 | undefined {
|
|
183
|
+
const stamp = field(geometry.userData, 'kilnAtlasBinding');
|
|
184
|
+
if (stamp === undefined) return undefined;
|
|
185
|
+
canonicalAtlasMetadataJsonV1(stamp);
|
|
186
|
+
const r = stamp as Record<string, unknown>,
|
|
187
|
+
derived = derivedBinding(provenance, r['region'], r['inputOrigin'], r['mode']);
|
|
188
|
+
if (canonicalAtlasMetadataJsonV1(stamp) !== canonicalAtlasMetadataJsonV1(derived))
|
|
189
|
+
fail('named binding identity or remap formula mismatch.');
|
|
190
|
+
return {
|
|
191
|
+
version: ATLAS_PROVENANCE_VERSION,
|
|
192
|
+
atlasId: provenance.manifest.manifestHash,
|
|
193
|
+
region: r['region'] as string,
|
|
194
|
+
inputOrigin: r['inputOrigin'] as AtlasBindingV1['inputOrigin'],
|
|
195
|
+
mode: r['mode'] as AtlasBindingV1['mode'],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export interface AtlasProvenanceCollection {
|
|
199
|
+
table?: AtlasProvenanceTableV1;
|
|
200
|
+
texture(texture: THREE.Texture): AtlasReferenceV1 | undefined;
|
|
201
|
+
binding(geometry: THREE.BufferGeometry): AtlasBindingV1 | undefined;
|
|
202
|
+
}
|
|
203
|
+
export function collectAtlasProvenance(root: THREE.Object3D): AtlasProvenanceCollection {
|
|
204
|
+
const textures = new Map<THREE.Texture, AtlasReferenceV1>(),
|
|
205
|
+
geometries = new Map<THREE.BufferGeometry, AtlasBindingV1>(),
|
|
206
|
+
candidates = new Map<THREE.Texture, unknown[]>(),
|
|
207
|
+
sizes = new Map<string, number>(),
|
|
208
|
+
pendingGeometry = new Map<THREE.BufferGeometry, string>();
|
|
209
|
+
let total = TABLE_ENVELOPE_BYTES,
|
|
210
|
+
pixelBytes = 0,
|
|
211
|
+
bindings = 0;
|
|
212
|
+
// Entire metadata graph admission comes before canonicalizers clone manifests or exporters clone scene state.
|
|
213
|
+
root.traverse((node) => {
|
|
214
|
+
const mesh = node as THREE.Mesh;
|
|
215
|
+
if (!mesh.isMesh && !(node as THREE.Sprite).isSprite) return;
|
|
216
|
+
const meshIds = new Set<string>(),
|
|
217
|
+
mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
218
|
+
for (const mat of mats)
|
|
219
|
+
for (const slot of [
|
|
220
|
+
'map',
|
|
221
|
+
'normalMap',
|
|
222
|
+
'roughnessMap',
|
|
223
|
+
'metalnessMap',
|
|
224
|
+
'emissiveMap',
|
|
225
|
+
'aoMap',
|
|
226
|
+
]) {
|
|
227
|
+
const texture = (mat as unknown as Record<string, unknown>)[slot];
|
|
228
|
+
if (!(texture instanceof THREE.Texture)) continue;
|
|
229
|
+
let ref = textures.get(texture);
|
|
230
|
+
if (!ref) {
|
|
231
|
+
const raw = textureCandidates(texture);
|
|
232
|
+
if (!raw.length) continue;
|
|
233
|
+
for (const candidate of raw) {
|
|
234
|
+
canonicalAtlasMetadataJsonV1(candidate);
|
|
235
|
+
const r = closed(candidate, ['spec', 'manifest']),
|
|
236
|
+
id = field(r['manifest'], 'manifestHash');
|
|
237
|
+
if (typeof id !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(id))
|
|
238
|
+
fail('invalid atlas identity.');
|
|
239
|
+
const bytes = new TextEncoder().encode(
|
|
240
|
+
canonicalAtlasMetadataJsonV1({ ...r, id }),
|
|
241
|
+
).length;
|
|
242
|
+
const prior = sizes.get(id) ?? 0;
|
|
243
|
+
total += Math.max(0, bytes - prior) + (prior === 0 && sizes.size > 0 ? 1 : 0);
|
|
244
|
+
sizes.set(id, Math.max(prior, bytes));
|
|
245
|
+
if (
|
|
246
|
+
total > ATLAS_PROVENANCE_LIMITS.bytes ||
|
|
247
|
+
sizes.size > ATLAS_PROVENANCE_LIMITS.atlases
|
|
248
|
+
)
|
|
249
|
+
fail('aggregate metadata budget exceeded.');
|
|
250
|
+
ref ??= { version: ATLAS_PROVENANCE_VERSION, id };
|
|
251
|
+
}
|
|
252
|
+
const spec = field(raw[0], 'spec'),
|
|
253
|
+
width = field(spec, 'width'),
|
|
254
|
+
height = field(spec, 'height');
|
|
255
|
+
if (
|
|
256
|
+
!Number.isSafeInteger(width) ||
|
|
257
|
+
!Number.isSafeInteger(height) ||
|
|
258
|
+
(width as number) < 1 ||
|
|
259
|
+
(height as number) < 1
|
|
260
|
+
)
|
|
261
|
+
fail('invalid atlas image dimensions.');
|
|
262
|
+
pixelBytes += (width as number) * (height as number) * 4;
|
|
263
|
+
if (pixelBytes > ATLAS_PROVENANCE_LIMITS.bakeDecodedBytes)
|
|
264
|
+
fail('aggregate atlas bake pixel budget exceeded.');
|
|
265
|
+
candidates.set(texture, raw);
|
|
266
|
+
textures.set(texture, ref!);
|
|
267
|
+
}
|
|
268
|
+
if (++bindings > ATLAS_PROVENANCE_LIMITS.bindings) fail('texture binding budget exceeded.');
|
|
269
|
+
meshIds.add(ref!.id);
|
|
270
|
+
}
|
|
271
|
+
if (!mesh.isMesh) return;
|
|
272
|
+
const stamp = field(mesh.geometry.userData, 'kilnAtlasBinding');
|
|
273
|
+
if (stamp === undefined) return;
|
|
274
|
+
if (++bindings > ATLAS_PROVENANCE_LIMITS.bindings) fail('primitive binding budget exceeded.');
|
|
275
|
+
canonicalAtlasMetadataJsonV1(stamp);
|
|
276
|
+
const id = field(stamp, 'manifestHash');
|
|
277
|
+
if (typeof id !== 'string' || !meshIds.has(id))
|
|
278
|
+
fail('named binding has no matching primary atlas texture on this mesh.');
|
|
279
|
+
pendingGeometry.set(mesh.geometry, id);
|
|
280
|
+
});
|
|
281
|
+
const identities = new Map<string, AtlasProvenanceV1>();
|
|
282
|
+
for (const [texture, raw] of candidates) {
|
|
283
|
+
const p = normalizeCandidates(raw)!,
|
|
284
|
+
id = p.manifest.manifestHash,
|
|
285
|
+
prior = identities.get(id);
|
|
286
|
+
if (prior && canonicalAtlasMetadataJsonV1(prior) !== canonicalAtlasMetadataJsonV1(p))
|
|
287
|
+
fail('conflicting atlas identity.');
|
|
288
|
+
identities.set(id, p);
|
|
289
|
+
textures.set(texture, { version: ATLAS_PROVENANCE_VERSION, id });
|
|
290
|
+
}
|
|
291
|
+
for (const [geometry, id] of pendingGeometry)
|
|
292
|
+
geometries.set(geometry, readAtlasGeometryBinding(geometry, identities.get(id)!)!);
|
|
293
|
+
const table = identities.size
|
|
294
|
+
? {
|
|
295
|
+
version: ATLAS_PROVENANCE_VERSION,
|
|
296
|
+
atlases: [...identities].map(([id, p]) => ({ id, ...p })),
|
|
297
|
+
}
|
|
298
|
+
: undefined;
|
|
299
|
+
return { table, texture: (t) => textures.get(t), binding: (g) => geometries.get(g) };
|
|
300
|
+
}
|
|
301
|
+
function rawDocumentTable(doc: Document): unknown {
|
|
302
|
+
const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0];
|
|
303
|
+
return scene ? field(scene.getExtras(), 'kilnAtlasProvenance') : undefined;
|
|
304
|
+
}
|
|
305
|
+
export function atlasDocumentTable(doc: Document): AtlasProvenanceTableV1 | undefined {
|
|
306
|
+
const raw = rawDocumentTable(doc);
|
|
307
|
+
return raw === undefined ? undefined : validateAtlasProvenanceTable(raw);
|
|
308
|
+
}
|
|
309
|
+
export function atlasMaterialUses(doc: Document): { texture: GltfTexture; info: TextureInfo }[] {
|
|
310
|
+
const uses: { texture: GltfTexture; info: TextureInfo }[] = [];
|
|
311
|
+
for (const mat of doc.getRoot().listMaterials())
|
|
312
|
+
for (const [texture, info] of [
|
|
313
|
+
[mat.getBaseColorTexture(), mat.getBaseColorTextureInfo()],
|
|
314
|
+
[mat.getNormalTexture(), mat.getNormalTextureInfo()],
|
|
315
|
+
[mat.getMetallicRoughnessTexture(), mat.getMetallicRoughnessTextureInfo()],
|
|
316
|
+
[mat.getEmissiveTexture(), mat.getEmissiveTextureInfo()],
|
|
317
|
+
[mat.getOcclusionTexture(), mat.getOcclusionTextureInfo()],
|
|
318
|
+
] as const)
|
|
319
|
+
if (texture && info && field(info.getExtras(), 'kilnAtlasReference') !== undefined) {
|
|
320
|
+
if (uses.length >= ATLAS_PROVENANCE_LIMITS.bindings)
|
|
321
|
+
fail('document binding budget exceeded.');
|
|
322
|
+
uses.push({ texture, info });
|
|
323
|
+
}
|
|
324
|
+
return uses;
|
|
325
|
+
}
|
|
326
|
+
async function imageHash(texture: GltfTexture): Promise<string> {
|
|
327
|
+
const bytes = texture.getImage();
|
|
328
|
+
if (!bytes || bytes.byteLength > ATLAS_PROVENANCE_LIMITS.encodedBytes)
|
|
329
|
+
fail('encoded image missing or over budget.');
|
|
330
|
+
const result = await crypto.subtle.digest('SHA-256', bytes as Uint8Array<ArrayBuffer>);
|
|
331
|
+
return `sha256:${[...new Uint8Array(result)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
332
|
+
}
|
|
333
|
+
function sampler(info: TextureInfo) {
|
|
334
|
+
const value = {
|
|
335
|
+
minFilter: info.getMinFilter(),
|
|
336
|
+
magFilter: info.getMagFilter(),
|
|
337
|
+
wrapS: info.getWrapS(),
|
|
338
|
+
wrapT: info.getWrapT(),
|
|
339
|
+
};
|
|
340
|
+
if (
|
|
341
|
+
![null, 9728, 9729, 9984, 9985, 9986, 9987].includes(value.minFilter) ||
|
|
342
|
+
![null, 9728, 9729].includes(value.magFilter) ||
|
|
343
|
+
![33071, 33648, 10497].includes(value.wrapS) ||
|
|
344
|
+
![33071, 33648, 10497].includes(value.wrapT)
|
|
345
|
+
)
|
|
346
|
+
fail('unsupported effective sampler.');
|
|
347
|
+
return value;
|
|
348
|
+
}
|
|
349
|
+
function reference(input: unknown, table: AtlasProvenanceTableV1): AtlasReferenceV1 {
|
|
350
|
+
canonicalAtlasMetadataJsonV1(input);
|
|
351
|
+
const r = closed(input, ['version', 'id', 'effectiveSampler', 'finalMime', 'finalImageSha256']);
|
|
352
|
+
if (r['version'] !== ATLAS_PROVENANCE_VERSION) fail('unsupported texture reference version.');
|
|
353
|
+
lookup(table, r['id']);
|
|
354
|
+
return r as unknown as AtlasReferenceV1;
|
|
355
|
+
}
|
|
356
|
+
function admitEncodedImages(uses: readonly { texture: GltfTexture; info: TextureInfo }[]): void {
|
|
357
|
+
const seen = new Set<GltfTexture>();
|
|
358
|
+
let total = 0;
|
|
359
|
+
for (const { texture } of uses) {
|
|
360
|
+
if (seen.has(texture)) continue;
|
|
361
|
+
seen.add(texture);
|
|
362
|
+
const bytes = texture.getImage();
|
|
363
|
+
if (!bytes || bytes.byteLength > ATLAS_PROVENANCE_LIMITS.encodedBytes)
|
|
364
|
+
fail('encoded image missing or over budget.');
|
|
365
|
+
total += bytes.byteLength;
|
|
366
|
+
if (total > ATLAS_PROVENANCE_LIMITS.aggregateEncodedBytes)
|
|
367
|
+
fail('aggregate encoded image hash budget exceeded.');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/** Export/optimization only: bind source identity to the final encoded bytes and actual per-use sampler. */
|
|
371
|
+
export async function refreshAtlasDocumentProvenance(doc: Document): Promise<void> {
|
|
372
|
+
const table = atlasDocumentTable(doc),
|
|
373
|
+
uses = atlasMaterialUses(doc);
|
|
374
|
+
if (!uses.length) return;
|
|
375
|
+
if (!table) fail('atlas table missing.');
|
|
376
|
+
admitEncodedImages(uses);
|
|
377
|
+
const hashes = new Map<GltfTexture, string>();
|
|
378
|
+
for (const { texture, info } of uses) {
|
|
379
|
+
const r = reference(field(info.getExtras(), 'kilnAtlasReference'), table);
|
|
380
|
+
let hash = hashes.get(texture);
|
|
381
|
+
if (!hash) {
|
|
382
|
+
hash = await imageHash(texture);
|
|
383
|
+
hashes.set(texture, hash);
|
|
384
|
+
}
|
|
385
|
+
info.setExtras({
|
|
386
|
+
...info.getExtras(),
|
|
387
|
+
kilnAtlasReference: {
|
|
388
|
+
version: ATLAS_PROVENANCE_VERSION,
|
|
389
|
+
id: r.id,
|
|
390
|
+
effectiveSampler: sampler(info),
|
|
391
|
+
finalMime: texture.getMimeType(),
|
|
392
|
+
finalImageSha256: hash,
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/** Import is validation, never a repair or identity refresh. */
|
|
398
|
+
export async function validateAtlasDocumentProvenance(doc: Document): Promise<void> {
|
|
399
|
+
const table = atlasDocumentTable(doc),
|
|
400
|
+
uses = atlasMaterialUses(doc);
|
|
401
|
+
if (uses.length && !table) fail('atlas table missing.');
|
|
402
|
+
if (!table) {
|
|
403
|
+
for (const mesh of doc.getRoot().listMeshes())
|
|
404
|
+
for (const primitive of mesh.listPrimitives())
|
|
405
|
+
if (field(primitive.getExtras(), 'kilnAtlasBinding') !== undefined)
|
|
406
|
+
fail('atlas table missing.');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
admitEncodedImages(uses);
|
|
410
|
+
const hashes = new Map<GltfTexture, string>();
|
|
411
|
+
for (const { texture, info } of uses) {
|
|
412
|
+
const r = reference(field(info.getExtras(), 'kilnAtlasReference'), table);
|
|
413
|
+
let hash = hashes.get(texture);
|
|
414
|
+
if (!hash) {
|
|
415
|
+
hash = await imageHash(texture);
|
|
416
|
+
hashes.set(texture, hash);
|
|
417
|
+
}
|
|
418
|
+
if (
|
|
419
|
+
r.finalImageSha256 !== hash ||
|
|
420
|
+
r.finalMime !== texture.getMimeType() ||
|
|
421
|
+
canonicalAtlasMetadataJsonV1(r.effectiveSampler) !==
|
|
422
|
+
canonicalAtlasMetadataJsonV1(sampler(info))
|
|
423
|
+
)
|
|
424
|
+
fail('final image or effective sampler identity mismatch.');
|
|
425
|
+
}
|
|
426
|
+
let bindings = 0;
|
|
427
|
+
for (const mesh of doc.getRoot().listMeshes())
|
|
428
|
+
for (const prim of mesh.listPrimitives()) {
|
|
429
|
+
const binding = field(prim.getExtras(), 'kilnAtlasBinding');
|
|
430
|
+
if (binding !== undefined) {
|
|
431
|
+
if (++bindings > ATLAS_PROVENANCE_LIMITS.bindings) fail('primitive budget exceeded.');
|
|
432
|
+
expandAtlasBinding(binding, table);
|
|
433
|
+
const material = prim.getMaterial(),
|
|
434
|
+
id = field(binding, 'atlasId');
|
|
435
|
+
const infos = material
|
|
436
|
+
? [
|
|
437
|
+
material.getBaseColorTextureInfo(),
|
|
438
|
+
material.getNormalTextureInfo(),
|
|
439
|
+
material.getMetallicRoughnessTextureInfo(),
|
|
440
|
+
material.getEmissiveTextureInfo(),
|
|
441
|
+
material.getOcclusionTextureInfo(),
|
|
442
|
+
]
|
|
443
|
+
: [];
|
|
444
|
+
if (
|
|
445
|
+
!infos.some(
|
|
446
|
+
(info) =>
|
|
447
|
+
info &&
|
|
448
|
+
field(info.getExtras(), 'kilnAtlasReference') !== undefined &&
|
|
449
|
+
field(field(info.getExtras(), 'kilnAtlasReference'), 'id') === id,
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
fail('named binding has no matching material atlas reference.');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const validatedDeliveries = new WeakMap<THREE.Texture, Readonly<AtlasReferenceV1>>();
|
|
458
|
+
/** Host-only owned identity established by the validated GLB loader; no serialized skip flag. */
|
|
459
|
+
export function readValidatedAtlasDelivery(
|
|
460
|
+
texture: THREE.Texture,
|
|
461
|
+
): Readonly<AtlasReferenceV1> | undefined {
|
|
462
|
+
const ref = validatedDeliveries.get(texture);
|
|
463
|
+
return ref ? structuredClone(ref) : undefined;
|
|
464
|
+
}
|
|
465
|
+
export function readAtlasEncodedTexture(
|
|
466
|
+
texture: THREE.Texture,
|
|
467
|
+
): { mime: 'image/png'; bytes: Uint8Array } | undefined {
|
|
468
|
+
const encoded = field(texture.userData, 'encoded');
|
|
469
|
+
if (encoded === undefined) return undefined;
|
|
470
|
+
const r = closed(encoded, ['mime', 'bytes']);
|
|
471
|
+
if (
|
|
472
|
+
r['mime'] !== 'image/png' ||
|
|
473
|
+
!(r['bytes'] instanceof Uint8Array) ||
|
|
474
|
+
r['bytes'].byteLength > ATLAS_PROVENANCE_LIMITS.encodedBytes
|
|
475
|
+
)
|
|
476
|
+
fail('bounded PNG encoding required.');
|
|
477
|
+
return { mime: 'image/png', bytes: r['bytes'] };
|
|
478
|
+
}
|
|
479
|
+
/** Called only after validateAtlasDocumentProvenance; each sampler use gets owned Three state. */
|
|
480
|
+
export function restoreAtlasTextureProvenance(
|
|
481
|
+
texture: THREE.Texture,
|
|
482
|
+
info: TextureInfo,
|
|
483
|
+
table: AtlasProvenanceTableV1,
|
|
484
|
+
): void {
|
|
485
|
+
const r = reference(field(info.getExtras(), 'kilnAtlasReference'), table),
|
|
486
|
+
p = lookup(table, r.id);
|
|
487
|
+
texture.userData.kilnAtlas = structuredClone({ spec: p.spec, manifest: p.manifest });
|
|
488
|
+
validatedDeliveries.set(texture, Object.freeze(structuredClone(r)));
|
|
489
|
+
restoreGltfTextureBinding(texture, info);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Compose before mergeDocuments copies image/geometry buffers or deletes the source scene. */
|
|
493
|
+
export async function mergeAtlasDocumentProvenance(
|
|
494
|
+
target: Document,
|
|
495
|
+
source: Document,
|
|
496
|
+
): Promise<void> {
|
|
497
|
+
const sourceRaw = rawDocumentTable(source),
|
|
498
|
+
targetRaw = rawDocumentTable(target);
|
|
499
|
+
const union = new Map<unknown, Record<string, unknown>>();
|
|
500
|
+
for (const raw of [targetRaw, sourceRaw])
|
|
501
|
+
if (raw !== undefined)
|
|
502
|
+
for (const entry of admitTable(raw)) {
|
|
503
|
+
const prior = union.get(entry['id']);
|
|
504
|
+
if (prior && canonicalAtlasMetadataJsonV1(prior) !== canonicalAtlasMetadataJsonV1(entry))
|
|
505
|
+
fail('conflicting atlas identity during composition.');
|
|
506
|
+
union.set(entry['id'], entry);
|
|
507
|
+
}
|
|
508
|
+
// The combined graph is admitted before any recipe/manifest cloning or source graph merge.
|
|
509
|
+
const combined = union.size
|
|
510
|
+
? { version: ATLAS_PROVENANCE_VERSION, atlases: [...union.values()] }
|
|
511
|
+
: undefined;
|
|
512
|
+
if (combined) admitTable(combined);
|
|
513
|
+
admitEncodedImages([...atlasMaterialUses(target), ...atlasMaterialUses(source)]);
|
|
514
|
+
await validateAtlasDocumentProvenance(source);
|
|
515
|
+
await validateAtlasDocumentProvenance(target);
|
|
516
|
+
if (combined) {
|
|
517
|
+
const table = validateAtlasProvenanceTable(combined),
|
|
518
|
+
scene = target.getRoot().getDefaultScene() ?? target.getRoot().listScenes()[0];
|
|
519
|
+
if (!scene) fail('composition target scene missing.');
|
|
520
|
+
scene.setExtras({ ...scene.getExtras(), kilnAtlasProvenance: table });
|
|
521
|
+
}
|
|
522
|
+
}
|