@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.
Files changed (214) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +74 -0
  4. package/README.md +7 -0
  5. package/dist/agent-run.mjs +6392 -1477
  6. package/dist/build.json +23 -23
  7. package/dist/cli.mjs +8453 -3407
  8. package/dist/evaluator-worker.mjs +16608 -12387
  9. package/dist/mcp-engine.mjs +6746 -1725
  10. package/dist/mcp-server.mjs +4 -4
  11. package/dist/viewer/app.js +203 -199
  12. package/dist/viewer/chat.html +209 -205
  13. package/dist/viewer/style.css +10 -0
  14. package/docs/atlas-provenance.md +23 -0
  15. package/docs/community-exporter.md +27 -0
  16. package/docs/export-profiles.md +20 -0
  17. package/docs/material-library.md +9 -5
  18. package/docs/procedural-surfaces-v3.md +276 -0
  19. package/docs/projects-and-live-review.md +1 -1
  20. package/docs/rendering.md +41 -0
  21. package/docs/sdk.md +53 -0
  22. package/docs/tools-operations.md +2 -2
  23. package/docs/tools.md +3 -3
  24. package/lib/asset-materials-node.d.ts +3 -3
  25. package/lib/asset-materials-node.js +29 -33
  26. package/lib/assets-node.d.ts +1 -1
  27. package/lib/assets-node.js +11 -4
  28. package/lib/assets.d.ts +4 -2
  29. package/lib/assets.js +61 -16
  30. package/lib/atlas-provenance.d.ts +86 -0
  31. package/lib/atlas-provenance.js +448 -0
  32. package/lib/character.d.ts +11 -2
  33. package/lib/character.js +90 -2
  34. package/lib/community-exporter.d.ts +3 -2
  35. package/lib/community-exporter.js +139 -3
  36. package/lib/discovery/helper-contracts.js +161 -7
  37. package/lib/discovery/helper-specs.js +49 -4
  38. package/lib/discovery/shapes.js +20 -7
  39. package/lib/engine-identity.d.ts +1 -1
  40. package/lib/engine-identity.js +1 -1
  41. package/lib/evaluator/authoring-diagnostic.d.ts +1 -1
  42. package/lib/evaluator/authoring-diagnostic.js +12 -0
  43. package/lib/evaluator/protocol.d.ts +2 -2
  44. package/lib/evaluator/protocol.js +16 -6
  45. package/lib/evaluator/subprocess.js +2 -2
  46. package/lib/exporter-node.js +9 -1
  47. package/lib/geometry-budget.d.ts +7 -0
  48. package/lib/geometry-budget.js +8 -0
  49. package/lib/gltf-texture-binding.d.ts +6 -0
  50. package/lib/gltf-texture-binding.js +54 -0
  51. package/lib/material-authoring-node.d.ts +6 -0
  52. package/lib/material-authoring-node.js +49 -0
  53. package/lib/material-authoring-schema.d.ts +871 -0
  54. package/lib/material-authoring-schema.js +310 -0
  55. package/lib/material-export.d.ts +5 -0
  56. package/lib/material-export.js +21 -0
  57. package/lib/material-library-node.d.ts +34 -8
  58. package/lib/material-library-node.js +691 -11
  59. package/lib/material-library.d.ts +3704 -4
  60. package/lib/material-library.js +272 -0
  61. package/lib/mcp-core.d.ts +1 -1
  62. package/lib/pinned-material-map.d.ts +33 -0
  63. package/lib/pinned-material-map.js +1 -0
  64. package/lib/portable-material-runtime.d.ts +5 -0
  65. package/lib/portable-material-runtime.js +160 -0
  66. package/lib/portable-sampler-runtime.d.ts +4 -0
  67. package/lib/portable-sampler-runtime.js +26 -0
  68. package/lib/portable-sampler.d.ts +11 -0
  69. package/lib/portable-sampler.js +42 -0
  70. package/lib/primitives.d.ts +15 -3
  71. package/lib/primitives.js +24 -4
  72. package/lib/procedural-material-v2.d.ts +2 -0
  73. package/lib/procedural-material-v2.js +2 -1
  74. package/lib/procedural-material-v3.d.ts +72 -0
  75. package/lib/procedural-material-v3.js +162 -0
  76. package/lib/procedural-texture-v2-compiler.d.ts +31 -0
  77. package/lib/procedural-texture-v2-compiler.js +172 -0
  78. package/lib/procedural-texture-v3-contract.d.ts +125 -0
  79. package/lib/procedural-texture-v3-contract.js +333 -0
  80. package/lib/procedural-texture-v3.d.ts +12 -0
  81. package/lib/procedural-texture-v3.js +165 -0
  82. package/lib/procedural-texture.d.ts +9 -31
  83. package/lib/procedural-texture.js +43 -170
  84. package/lib/project-bundle-node.d.ts +1 -1
  85. package/lib/project-bundle-node.js +70 -13
  86. package/lib/project-bundle.d.ts +91 -3
  87. package/lib/project-bundle.js +48 -7
  88. package/lib/qa/atlas.d.ts +21 -0
  89. package/lib/qa/atlas.js +474 -0
  90. package/lib/qa/index.d.ts +1 -0
  91. package/lib/qa/index.js +1 -0
  92. package/lib/qa/material.js +2 -0
  93. package/lib/qa/skin-integrity.d.ts +18 -0
  94. package/lib/qa/skin-integrity.js +221 -0
  95. package/lib/render-service-host.js +4 -7
  96. package/lib/render.d.ts +3 -3
  97. package/lib/render.js +108 -16
  98. package/lib/skin-binding.d.ts +20 -0
  99. package/lib/skin-binding.js +144 -0
  100. package/lib/texture-atlas-contract.d.ts +97 -0
  101. package/lib/texture-atlas-contract.js +414 -0
  102. package/lib/texture-atlas-pixels.d.ts +73 -0
  103. package/lib/texture-atlas-pixels.js +290 -0
  104. package/lib/texture-atlas.d.ts +10 -0
  105. package/lib/texture-atlas.js +143 -0
  106. package/lib/texture-bake.d.ts +3 -0
  107. package/lib/texture-bake.js +66 -11
  108. package/lib/texture-resolver.d.ts +22 -0
  109. package/lib/texture-resolver.js +24 -0
  110. package/lib/tools/materials.js +8 -7
  111. package/lib/tools/registry.js +5 -16
  112. package/lib/tools/review.js +1 -1
  113. package/lib/uv-data.d.ts +32 -0
  114. package/lib/uv-data.js +172 -0
  115. package/lib/uv-shapes.js +3 -0
  116. package/lib/uv.d.ts +9 -3
  117. package/lib/uv.js +53 -82
  118. package/lib/views/camera.d.ts +3 -3
  119. package/lib/views/camera.js +32 -28
  120. package/lib/views/face-policy.d.ts +9 -0
  121. package/lib/views/face-policy.js +16 -0
  122. package/lib/views/glb.js +218 -24
  123. package/lib/views/pose.d.ts +7 -1
  124. package/lib/views/pose.js +63 -12
  125. package/lib/views/raster.d.ts +6 -2
  126. package/lib/views/raster.js +49 -37
  127. package/lib/views/scene-copy.d.ts +6 -0
  128. package/lib/views/scene-copy.js +73 -0
  129. package/lib/views/scene-raster.js +2 -1
  130. package/lib/views/triangle-coverage.d.ts +8 -0
  131. package/lib/views/triangle-coverage.js +58 -0
  132. package/lib/workspace-node.js +7 -3
  133. package/lib/workspace.d.ts +2 -2
  134. package/package.json +3 -1
  135. package/plugin.json +1 -1
  136. package/plugins/kiln-engine/.claude-plugin/plugin.json +1 -1
  137. package/plugins/kiln-engine/README.md +3 -3
  138. package/plugins/kiln-engine/package-provenance.json +6 -6
  139. package/plugins/kiln-engine/plugin.json +1 -1
  140. package/plugins/kiln-engine/runtime.json +1 -1
  141. package/skills/kiln-author-asset/references/geometry-recipes.md +124 -0
  142. package/skills/kiln-author-asset/references/projects-and-materials.md +32 -2
  143. package/skills/kiln-compose-scene/references/runtime-scenes.md +19 -0
  144. package/skills/kiln-qa-asset/references/integration-checks.md +37 -2
  145. package/src/asset-materials-node.ts +38 -45
  146. package/src/assets-node.ts +22 -5
  147. package/src/assets.ts +61 -20
  148. package/src/atlas-provenance.ts +522 -0
  149. package/src/character.ts +107 -2
  150. package/src/community-exporter.ts +143 -3
  151. package/src/discovery/helper-contracts.ts +188 -7
  152. package/src/discovery/helper-specs.ts +69 -4
  153. package/src/discovery/shapes.ts +25 -9
  154. package/src/engine-identity.ts +1 -1
  155. package/src/evaluator/authoring-diagnostic.ts +19 -1
  156. package/src/evaluator/protocol.ts +19 -9
  157. package/src/evaluator/subprocess.ts +2 -2
  158. package/src/exporter-node.ts +12 -1
  159. package/src/generated/mcp-manifest.json +3 -3
  160. package/src/geometry-budget.ts +8 -0
  161. package/src/gltf-texture-binding.ts +60 -0
  162. package/src/material-authoring-node.ts +68 -0
  163. package/src/material-authoring-schema.ts +343 -0
  164. package/src/material-export.ts +25 -0
  165. package/src/material-library-node.ts +840 -16
  166. package/src/material-library.ts +348 -5
  167. package/src/pinned-material-map.ts +43 -0
  168. package/src/portable-material-runtime.ts +200 -0
  169. package/src/portable-sampler-runtime.ts +40 -0
  170. package/src/portable-sampler.ts +54 -0
  171. package/src/primitives.ts +98 -4
  172. package/src/procedural-material-v2.ts +2 -1
  173. package/src/procedural-material-v3.ts +224 -0
  174. package/src/procedural-texture-v2-compiler.ts +234 -0
  175. package/src/procedural-texture-v3-contract.ts +437 -0
  176. package/src/procedural-texture-v3.ts +220 -0
  177. package/src/procedural-texture.ts +61 -229
  178. package/src/project-bundle-node.ts +106 -14
  179. package/src/project-bundle.ts +61 -18
  180. package/src/project-cli.ts +29 -17
  181. package/src/qa/atlas.ts +597 -0
  182. package/src/qa/index.ts +1 -0
  183. package/src/qa/material.ts +2 -0
  184. package/src/qa/skin-integrity.ts +291 -0
  185. package/src/render-service-host.ts +4 -6
  186. package/src/render.ts +133 -15
  187. package/src/skin-binding.ts +225 -0
  188. package/src/texture-atlas-contract.ts +558 -0
  189. package/src/texture-atlas-pixels.ts +456 -0
  190. package/src/texture-atlas.ts +185 -0
  191. package/src/texture-bake.ts +88 -10
  192. package/src/texture-resolver.ts +55 -0
  193. package/src/tools/materials.ts +15 -11
  194. package/src/tools/registry.ts +5 -15
  195. package/src/tools/review.ts +1 -1
  196. package/src/uv-data.ts +238 -0
  197. package/src/uv-shapes.ts +3 -0
  198. package/src/uv.ts +73 -128
  199. package/src/viewer/app.ts +37 -0
  200. package/src/viewer/live-panel.ts +57 -1
  201. package/src/viewer/material-preview-panel.ts +140 -0
  202. package/src/viewer/material-preview.ts +138 -0
  203. package/src/viewer/materials-panel.ts +34 -5
  204. package/src/viewer/navigation.ts +2 -0
  205. package/src/views/camera.ts +37 -40
  206. package/src/views/face-policy.ts +20 -0
  207. package/src/views/glb.ts +294 -17
  208. package/src/views/pose.ts +71 -13
  209. package/src/views/raster.ts +73 -46
  210. package/src/views/scene-copy.ts +86 -0
  211. package/src/views/scene-raster.ts +2 -1
  212. package/src/views/triangle-coverage.ts +78 -0
  213. package/src/workspace-node.ts +11 -3
  214. 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
- materialLibraryPayloadSchema,
9
- validateMaterialManifest,
8
+ materialLibraryPayloadAnySchema,
9
+ materialRecordDependencyPins,
10
+ validateMaterialManifestAny,
10
11
  MATERIAL_LIBRARY_LIMITS,
11
- type MaterialLibraryPayloadV1,
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?: MaterialLibraryPayloadV1;
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.manifest.build?.dependencies ?? []).filter(
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((dependency) => {
103
- if (dependency.delivery !== 'runtime') throw new Error('Invalid saved material dependency');
104
- const manifest = validateMaterialManifest(dependency.manifest);
105
- return [`${manifest.materialId}/${manifest.revisionId}`, canonicalMaterialJson(manifest)];
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 = materialLibraryPayloadSchema.parse(record.materialResources);
135
+ const payload = materialLibraryPayloadAnySchema.parse(record.materialResources);
114
136
  let bytes = 0;
115
137
  let pixels = 0;
116
- const seen = new Set<string>();
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 (seen.has(key) || expected.get(key) !== canonicalMaterialJson(wire.manifest))
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
- seen.add(key);
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
+ }