@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/uv.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import * as THREE from 'three';
18
+ import { prepareUnwrap, remapUnwrapAttribute, validateAtlasRemap, type AtlasMesh } from './uv-data';
18
19
 
19
20
  // Lazy xatlas init — pays ~100-200ms on first call, then free.
20
21
  let _xatlasReady: Promise<unknown> | null = null;
@@ -41,17 +42,7 @@ type XatlasApi = {
41
42
  height: number;
42
43
  atlasCount: number;
43
44
  meshCount: number;
44
- meshes: Array<{
45
- mesh: string;
46
- vertex: {
47
- vertices: Float32Array | number[];
48
- normals?: Float32Array | number[];
49
- coords?: Float32Array | number[];
50
- coords1?: Float32Array | number[];
51
- };
52
- index?: Uint32Array | number[];
53
- oldIndexes: Uint32Array | number[];
54
- }>;
45
+ meshes: AtlasMesh[];
55
46
  };
56
47
  destroyAtlas(): void;
57
48
  };
@@ -85,11 +76,11 @@ async function getXatlas(): Promise<XatlasApi> {
85
76
  }
86
77
 
87
78
  export interface AutoUnwrapOptions {
88
- /** Atlas texture resolution (power-of-2). Default 1024. */
79
+ /** Target atlas resolution, integer 1..4096. Actual packed dimensions may differ. Default 1024. */
89
80
  resolution?: number;
90
81
  /** Padding between charts in texels. Default 2. */
91
82
  padding?: number;
92
- /** Bake surface normals when determining seams. Default false (faster). */
83
+ /** Use and preserve source normals; otherwise recompute. Required for normal morphs. Default false. */
93
84
  useNormals?: boolean;
94
85
  }
95
86
 
@@ -100,7 +91,13 @@ export interface AutoUnwrapOptions {
100
91
  * sampling, and (usually) a different vertex count than the input: xatlas
101
92
  * duplicates vertices along UV seams so each shell gets its own chart.
102
93
  *
103
- * Returns a *new* BufferGeometry; the input is left untouched.
94
+ * Returns a *new* BufferGeometry; the input is left untouched. Seam remapping
95
+ * preserves per-vertex attributes, position/normal/color morphs, groups and JSON
96
+ * userData. Replaces UV0 and removes stale tangents. Normal behavior is unchanged:
97
+ * recompute by default, preserve source normals with useNormals: true.
98
+ * The current xatlasjs adapter supports at most 65,536 input AND output vertices;
99
+ * split larger meshes. Instancing, multi-page atlases and changed face order are
100
+ * rejected rather than returning corrupt attribute correspondence.
104
101
  *
105
102
  * @example
106
103
  * const raw = boxGeo(1, 2, 1);
@@ -111,125 +108,73 @@ export async function autoUnwrap(
111
108
  geometry: THREE.BufferGeometry,
112
109
  opts: AutoUnwrapOptions = {},
113
110
  ): Promise<THREE.BufferGeometry> {
111
+ const prepared = prepareUnwrap(geometry, opts);
114
112
  const xa = await getXatlas();
115
-
116
- // xatlas needs an indexed BufferGeometry. Convert if necessary.
117
- const src = geometry.index ? geometry : toIndexed(geometry);
118
- const posAttr = src.getAttribute('position') as THREE.BufferAttribute | undefined;
119
- const normAttr = src.getAttribute('normal') as THREE.BufferAttribute | undefined;
120
- const idxAttr = src.getIndex();
121
- if (!posAttr || !idxAttr) {
122
- throw new Error('autoUnwrap: geometry requires position attribute and an index');
123
- }
124
- // T2.4: measured — xatlas accepts an empty mesh and returns an empty atlas
125
- // without complaint, so a zero-triangle geometry used to come back with zero
126
- // vertices and NO uv attribute, and the failure only surfaced later as a
127
- // texture that would not appear. The realistic upstream cause is a CSG result
128
- // that collapsed to nothing.
129
- if (idxAttr.count === 0 || posAttr.count === 0) {
130
- throw new Error(
131
- 'autoUnwrap: geometry has no triangles. Nothing can be unwrapped — check the operation that produced it (an empty CSG result is the usual cause).',
113
+ xa.createAtlas();
114
+ try {
115
+ const added = xa.addMesh(
116
+ prepared.index,
117
+ prepared.positions,
118
+ prepared.normals,
119
+ null,
120
+ 'kiln-mesh',
121
+ prepared.useNormals,
122
+ false,
123
+ 1,
132
124
  );
133
- }
134
- if (idxAttr.count % 3 !== 0) {
135
- throw new Error(
136
- `autoUnwrap: geometry has ${idxAttr.count} indices, which is not a whole number of triangles.`,
125
+ if (!added)
126
+ throw new Error('autoUnwrap: xatlas.addMesh failed (non-manifold or degenerate geometry?).');
127
+ const result = xa.generateAtlas(
128
+ {},
129
+ { resolution: prepared.resolution, padding: prepared.padding },
130
+ true,
137
131
  );
138
- }
139
-
140
- xa.createAtlas();
141
-
142
- const useNormals = Boolean(opts.useNormals && normAttr);
143
- const indexArray =
144
- idxAttr.array instanceof Uint32Array
145
- ? idxAttr.array
146
- : new Uint32Array(idxAttr.array as ArrayLike<number>);
147
-
148
- const addRes = xa.addMesh(
149
- indexArray,
150
- new Float32Array(posAttr.array),
151
- useNormals && normAttr ? new Float32Array(normAttr.array) : null,
152
- null,
153
- 'kiln-mesh',
154
- useNormals,
155
- false,
156
- 1,
157
- );
158
- if (!addRes) {
159
- xa.destroyAtlas();
160
- throw new Error('autoUnwrap: xatlas.addMesh failed (non-manifold or degenerate geometry?)');
161
- }
162
-
163
- const result = xa.generateAtlas(
164
- {},
165
- { resolution: opts.resolution ?? 1024, padding: opts.padding ?? 2 },
166
- true,
167
- );
168
-
169
- const mesh = result.meshes[0];
170
- if (!mesh) {
171
- xa.destroyAtlas();
172
- throw new Error('autoUnwrap: xatlas returned no meshes');
173
- }
174
-
175
- const out = new THREE.BufferGeometry();
176
- out.setAttribute(
177
- 'position',
178
- new THREE.BufferAttribute(new Float32Array(mesh.vertex.vertices), 3),
179
- );
180
- if (mesh.vertex.normals) {
181
- out.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(mesh.vertex.normals), 3));
182
- }
183
- if (mesh.vertex.coords1) {
184
- out.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(mesh.vertex.coords1), 2));
185
- }
186
- if (mesh.index) {
187
- out.setIndex(new THREE.BufferAttribute(new Uint32Array(mesh.index), 1));
188
- }
189
-
190
- // Atlas metadata lives on userData so downstream tools (texture baking,
191
- // projection painting) can sample the atlas resolution.
192
- //
193
- // T2.4 — read `width`/`height`, do NOT assume `resolution` squared. `resolution`
194
- // is an upper bound xatlas packs within, not the atlas it returns: a CSG'd box
195
- // at resolution 1024 measured 917x1085. A baker that allocated a square
196
- // `resolution x resolution` target would sample the wrong texels along one
197
- // axis, which reads as a subtle uniform stretch rather than an obvious break.
198
- out.userData['atlas'] = {
199
- width: result.width,
200
- height: result.height,
201
- atlasCount: result.atlasCount,
202
- };
203
-
204
- if (!out.getAttribute('normal')) out.computeVertexNormals();
205
- repairZeroNormals(out);
206
-
207
- // xatlas can pack every chart and still hand back a mesh with no coords1 when
208
- // the input degenerates. Better to say so than to return a geometry whose
209
- // missing `uv` only shows up as an untextured surface much later.
210
- if (!out.getAttribute('uv')) {
132
+ const mesh = result.meshes[0];
133
+ if (!mesh || result.meshCount !== 1)
134
+ throw new Error('autoUnwrap: xatlas returned no single mesh.');
135
+ if (
136
+ result.atlasCount !== 1 ||
137
+ !Number.isSafeInteger(result.width) ||
138
+ result.width <= 0 ||
139
+ !Number.isSafeInteger(result.height) ||
140
+ result.height <= 0
141
+ )
142
+ throw new Error(
143
+ 'autoUnwrap: xatlas must return one valid atlas; multiple atlas pages are unsupported.',
144
+ );
145
+ const remap = validateAtlasRemap(mesh, prepared.index, geometry.getAttribute('position').count);
146
+ const out = new THREE.BufferGeometry();
147
+ for (const [name, attribute] of Object.entries(geometry.attributes)) {
148
+ if (name === 'uv' || name === 'tangent' || (name === 'normal' && !prepared.useNormals))
149
+ continue;
150
+ out.setAttribute(name, remapUnwrapAttribute(attribute, remap.oldIndexes));
151
+ }
152
+ for (const [name, targets] of Object.entries(geometry.morphAttributes)) {
153
+ // Three's declaration lists standard keys, although its runtime uses a dictionary.
154
+ (out.morphAttributes as Record<string, THREE.BufferAttribute[]>)[name] = targets.map(
155
+ (attribute) => remapUnwrapAttribute(attribute, remap.oldIndexes),
156
+ );
157
+ }
158
+ out.morphTargetsRelative = geometry.morphTargetsRelative;
159
+ out.setIndex(new THREE.BufferAttribute(new Uint32Array(remap.index), 1));
160
+ out.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(remap.uv), 2));
161
+ out.name = geometry.name;
162
+ // Triangle correspondence was checked before retaining material groups and draw range.
163
+ out.groups = geometry.groups.map((group) => ({ ...group }));
164
+ out.setDrawRange(geometry.drawRange.start, geometry.drawRange.count);
165
+ out.userData = JSON.parse(JSON.stringify(geometry.userData));
166
+ // xatlas's requested resolution is not the returned width/height (often rectangular).
167
+ out.userData['atlas'] = {
168
+ width: result.width,
169
+ height: result.height,
170
+ atlasCount: result.atlasCount,
171
+ };
172
+ if (!out.getAttribute('normal')) out.computeVertexNormals();
173
+ repairZeroNormals(out);
174
+ return out;
175
+ } finally {
211
176
  xa.destroyAtlas();
212
- throw new Error(
213
- 'autoUnwrap: xatlas produced no UV coordinates for this geometry (degenerate or zero-area triangles?).',
214
- );
215
177
  }
216
-
217
- xa.destroyAtlas();
218
- return out;
219
- }
220
-
221
- /**
222
- * Convert a non-indexed BufferGeometry to an indexed one via naive
223
- * sequential indexing. Sufficient for xatlas input (it re-indexes anyway
224
- * during chart generation).
225
- */
226
- function toIndexed(geo: THREE.BufferGeometry): THREE.BufferGeometry {
227
- const posAttr = geo.getAttribute('position') as THREE.BufferAttribute;
228
- const indices = new Uint32Array(posAttr.count);
229
- for (let i = 0; i < posAttr.count; i++) indices[i] = i;
230
- const out = geo.clone();
231
- out.setIndex(new THREE.BufferAttribute(indices, 1));
232
- return out;
233
178
  }
234
179
 
235
180
  /**
package/src/viewer/app.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  encodeAssetBundle,
6
6
  type AssetManifest,
7
7
  type AssetRecord,
8
+ assetMaterialRootManifests,
8
9
  } from '../assets';
9
10
  import { assetAttributionRows } from './attribution';
10
11
  import { createAssetStage } from './scene';
@@ -16,6 +17,7 @@ import { mountPerformanceControl } from './performance-control';
16
17
  import type { ProjectRevision } from '../projects';
17
18
  import { assetIdentity, entriesForProject } from './library-state';
18
19
  import { mountMembership } from './membership-panel';
20
+ import { materialViewerHref } from './material-preview';
19
21
 
20
22
  const el = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id)! as T;
21
23
  const node = <K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string) => {
@@ -347,6 +349,41 @@ async function openDetail(entry: Entry) {
347
349
  attribution.append(item);
348
350
  }
349
351
  attribution.hidden = !attribution.childElementCount;
352
+ let materialInputs = document.getElementById('asset-material-inputs');
353
+ if (!materialInputs) {
354
+ materialInputs = node('section', undefined, 'asset-material-inputs');
355
+ materialInputs.id = 'asset-material-inputs';
356
+ attribution.after(materialInputs);
357
+ }
358
+ materialInputs.replaceChildren();
359
+ try {
360
+ const dependencies = assetMaterialRootManifests({ manifest: entry.manifest, files: {} });
361
+ if (dependencies.length) {
362
+ materialInputs.append(
363
+ node('h3', 'Exact material build inputs'),
364
+ node(
365
+ 'p',
366
+ 'Open the saved revision to inspect its raw maps, sampling declaration and atlas regions. Declared inputs do not prove every map was used.',
367
+ 'subtle',
368
+ ),
369
+ );
370
+ for (const material of dependencies) {
371
+ const link = node('a', `${material.name} · ${material.materialId}`);
372
+ link.href = materialViewerHref(material);
373
+ link.target = '_blank';
374
+ link.rel = 'noopener';
375
+ materialInputs.append(link, node('p', material.revisionId, 'mono'));
376
+ }
377
+ }
378
+ } catch (error) {
379
+ materialInputs.append(
380
+ node(
381
+ 'p',
382
+ `Material revision links unavailable: ${error instanceof Error ? error.message : String(error)}`,
383
+ 'subtle',
384
+ ),
385
+ );
386
+ }
350
387
  el('provenance').textContent = entry.loose
351
388
  ? 'Standalone GLB. No source or build provenance supplied.'
352
389
  : JSON.stringify(entry.manifest, null, 2);
@@ -15,6 +15,8 @@ import type { AssetManifest } from '../assets';
15
15
  import { mountPerformanceControl } from './performance-control';
16
16
  import { mountMembership } from './membership-panel';
17
17
  import { el, node, request, empty, notice, remember, recall, shortId } from './dom';
18
+ import { materialEvaluationDependencies } from './material-preview';
19
+ import { materialPreviewPanel } from './material-preview-panel';
18
20
 
19
21
  export function mountLiveReview() {
20
22
  let state: ReviewState = { following: true, crossRunContinuity: true, operations: [] };
@@ -35,6 +37,7 @@ export function mountLiveReview() {
35
37
  let compareGeneration = 0;
36
38
  let wire = false;
37
39
  let recoveredPreference = false;
40
+ let materialInspection: { key: string; card: HTMLElement } | undefined;
38
41
  const projectSelect = el<HTMLSelectElement>('live-project');
39
42
  const sourceSelect = el<HTMLSelectElement>('live-source');
40
43
  const livePath = (path: string) =>
@@ -179,7 +182,10 @@ export function mountLiveReview() {
179
182
  target.replaceChildren();
180
183
  downloads.replaceChildren();
181
184
  const operation = state.displayed;
182
- if (!operation?.artifact) return;
185
+ if (!operation?.artifact) {
186
+ materialInspection = undefined;
187
+ return;
188
+ }
183
189
  const identity = node('div', undefined, 'evidence-card');
184
190
  identity.append(
185
191
  node('h3', 'Selected observed artifact'),
@@ -188,6 +194,56 @@ export function mountLiveReview() {
188
194
  node('p', `Source: ${operation.programRef ?? 'See authored source download'}`, 'mono'),
189
195
  );
190
196
  target.append(identity, fidelityCard(operation));
197
+ const materialKey = `${sourceSelect.value}/${operation.operationId}/${operation.artifact.sha256}`;
198
+ if (materialInspection?.key !== materialKey) {
199
+ const card = node('div', undefined, 'evidence-card live-material-inspection'),
200
+ button = node('button', 'Inspect exact material inputs', 'secondary');
201
+ card.append(
202
+ node('h3', 'Materials and atlas sheets'),
203
+ node(
204
+ 'p',
205
+ 'Declared inputs for this displayed artifact. Raw sheets are unshaded; capture fidelity is reported separately.',
206
+ ),
207
+ button,
208
+ );
209
+ materialInspection = { key: materialKey, card };
210
+ button.onclick = async () => {
211
+ button.disabled = true;
212
+ const status = node('p', 'Reading the displayed artifact evaluation…');
213
+ status.setAttribute('role', 'status');
214
+ card.append(status);
215
+ try {
216
+ const evaluation = await request<unknown>(
217
+ livePath(`/api/live/${encodeURIComponent(operation.operationId)}/evaluation.json`),
218
+ );
219
+ if (materialInspection?.key !== materialKey) return;
220
+ const dependencies = materialEvaluationDependencies(
221
+ evaluation,
222
+ operation.artifact!.sha256,
223
+ );
224
+ status.textContent = dependencies.length
225
+ ? 'Exact declared revisions. Map images require the matching record in this local material library.'
226
+ : 'No saved material resource declarations in this artifact. Embedded textures can still be viewed in the viewport.';
227
+ for (const material of dependencies) {
228
+ const details = node('details');
229
+ details.append(node('summary', `${material.name} · ${material.revisionId}`));
230
+ let loaded = false;
231
+ details.ontoggle = () => {
232
+ if (details.open && !loaded) {
233
+ loaded = true;
234
+ details.append(materialPreviewPanel(material));
235
+ }
236
+ };
237
+ card.append(details);
238
+ }
239
+ } catch (error) {
240
+ if (materialInspection?.key !== materialKey) return;
241
+ status.textContent = `Material inspection unavailable for this exact artifact: ${error instanceof Error ? error.message : String(error)}`;
242
+ button.disabled = false;
243
+ }
244
+ };
245
+ }
246
+ target.append(materialInspection.card);
191
247
  if (operation.captures.length) {
192
248
  for (const capture of operation.captures) {
193
249
  const card = node('div', undefined, 'evidence-card');
@@ -0,0 +1,140 @@
1
+ /// <reference lib="dom" />
2
+ import type { MaterialManifest } from '../material-library';
3
+ import { node } from './dom';
4
+ import {
5
+ materialPreviewModel,
6
+ materialViewerHref,
7
+ type MaterialPreviewMap,
8
+ } from './material-preview';
9
+
10
+ /** Raw PNG inspection only. Recorded capture fidelity belongs to the asset/operation. */
11
+ export function materialPreviewPanel(manifest: MaterialManifest) {
12
+ const model = materialPreviewModel(manifest),
13
+ panel = node('section', undefined, 'material-inspection');
14
+ panel.setAttribute('aria-label', `${model.name}: exact material maps`);
15
+ panel.append(
16
+ node('p', `${model.materialId} · ${model.revisionId}`, 'mono'),
17
+ node('p', model.fidelity, 'subtle'),
18
+ );
19
+ const controls = node('div', undefined, 'material-inspection-controls');
20
+ const field = (label: string, select: HTMLSelectElement) => {
21
+ const wrapper = node('label', label, 'inline-field');
22
+ wrapper.append(select);
23
+ controls.append(wrapper);
24
+ return select;
25
+ };
26
+ const mapSelect = field('Map', node('select'));
27
+ for (const map of model.maps)
28
+ mapSelect.append(new Option(`${map.slot}${map.atlas ? ' · atlas' : ''}`, map.slot));
29
+ const backdrop = field('Backdrop', node('select'));
30
+ for (const value of ['checker', 'light', 'dark'])
31
+ backdrop.append(new Option(value[0]!.toUpperCase() + value.slice(1), value));
32
+ const zoom = field('Pixel zoom', node('select'));
33
+ for (const scale of [1, 2, 4]) zoom.append(new Option(`${scale}×`, String(scale)));
34
+ const frame = node('div', undefined, 'material-pixel-frame'),
35
+ sheet = node('div', undefined, 'material-pixel-sheet');
36
+ frame.dataset.backdrop = 'checker';
37
+ frame.tabIndex = 0;
38
+ const image = node('img');
39
+ image.className = 'material-pixel-image';
40
+ image.loading = 'lazy';
41
+ image.draggable = false;
42
+ const overlay = node('div', undefined, 'material-region-overlay');
43
+ const overlayControl = node('label', 'Region overlays', 'inline-field');
44
+ const overlayToggle = node('input');
45
+ overlayToggle.type = 'checkbox';
46
+ overlayToggle.checked = false;
47
+ overlayControl.append(overlayToggle);
48
+ controls.append(overlayControl);
49
+ overlayToggle.onchange = () => {
50
+ overlay.hidden = !overlayToggle.checked;
51
+ };
52
+ sheet.append(image, overlay);
53
+ frame.append(sheet);
54
+ const status = node('p', undefined, 'subtle');
55
+ status.setAttribute('role', 'status');
56
+ const facts = node('dl'),
57
+ regions = node('div', undefined, 'material-region-list');
58
+ const exact = node('a', 'Open this saved material revision');
59
+ exact.href = materialViewerHref(model);
60
+ exact.target = '_blank';
61
+ exact.rel = 'noopener';
62
+ panel.append(controls, frame, status, facts, regions, exact);
63
+ let current: MaterialPreviewMap = model.maps[0]!;
64
+ const size = () => {
65
+ sheet.style.width = `${current.width * Number(zoom.value)}px`;
66
+ sheet.style.height = `${current.height * Number(zoom.value)}px`;
67
+ };
68
+ const show = () => {
69
+ current = model.maps.find((map) => map.slot === mapSelect.value) ?? model.maps[0]!;
70
+ overlayControl.hidden = !current.atlas;
71
+ overlay.hidden = !current.atlas || !overlayToggle.checked;
72
+ status.textContent = 'Loading exact saved map…';
73
+ image.alt = `${model.name}: ${current.slot}${current.atlas ? ' atlas sheet' : ''}, raw unshaded pixels`;
74
+ image.width = current.width;
75
+ image.height = current.height;
76
+ image.src = current.url;
77
+ size();
78
+ facts.replaceChildren();
79
+ const fact = (label: string, value: string) =>
80
+ facts.append(node('dt', label), node('dd', value));
81
+ fact(
82
+ 'Image',
83
+ `${current.width} × ${current.height} · ${current.colorSpace} · nearest pixel display`,
84
+ );
85
+ fact(
86
+ 'Sampling declaration',
87
+ current.sampler
88
+ ? `${current.sampler.minFilter} / ${current.sampler.magFilter} · ${current.sampler.wrapS} / ${current.sampler.wrapT}`
89
+ : 'Not recorded in V1',
90
+ );
91
+ fact('PNG hash', current.imageSha256);
92
+ if (current.pixelHash) fact('Decoded RGBA hash', current.pixelHash);
93
+ if (current.compilerId) fact('Compiler', current.compilerId);
94
+ if (current.manifestHash) fact('Atlas manifest hash', current.manifestHash);
95
+ overlay.replaceChildren();
96
+ regions.replaceChildren();
97
+ if (current.atlas) {
98
+ regions.append(node('h4', 'Atlas regions · top-left pixel coordinates'));
99
+ for (const region of current.regions) {
100
+ const box = node('span', undefined, 'material-region-box');
101
+ box.style.left = `${region.leftPercent}%`;
102
+ box.style.top = `${region.topPercent}%`;
103
+ box.style.width = `${region.widthPercent}%`;
104
+ box.style.height = `${region.heightPercent}%`;
105
+ box.append(node('span', region.name));
106
+ box.setAttribute('aria-hidden', 'true');
107
+ overlay.append(box);
108
+ const item = node(
109
+ 'button',
110
+ `${region.name}: content ${region.content.x},${region.content.y} ${region.content.width}×${region.content.height}; allocation ${region.allocation.x},${region.allocation.y} ${region.allocation.width}×${region.allocation.height}`,
111
+ 'secondary',
112
+ );
113
+ item.type = 'button';
114
+ item.setAttribute('aria-pressed', 'false');
115
+ item.onclick = () => {
116
+ for (const candidate of overlay.children) candidate.classList.remove('selected');
117
+ for (const button of regions.querySelectorAll('button'))
118
+ button.setAttribute('aria-pressed', 'false');
119
+ box.classList.add('selected');
120
+ item.setAttribute('aria-pressed', 'true');
121
+ if (!overlay.hidden) box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
122
+ };
123
+ regions.append(item);
124
+ }
125
+ }
126
+ };
127
+ image.onload = () => {
128
+ status.textContent = 'Raw map loaded. These pixels are not a shaded capture.';
129
+ };
130
+ image.onerror = () => {
131
+ status.textContent = `Exact ${model.materialId} revision map unavailable. No newer revision was substituted.`;
132
+ };
133
+ mapSelect.onchange = show;
134
+ backdrop.onchange = () => {
135
+ frame.dataset.backdrop = backdrop.value;
136
+ };
137
+ zoom.onchange = size;
138
+ show();
139
+ return panel;
140
+ }
@@ -0,0 +1,138 @@
1
+ /** Browser-safe presentation of saved raw maps. No renderer or source loading. */
2
+ import {
3
+ canonicalMaterialJson,
4
+ validateMaterialManifestAny,
5
+ type MaterialManifest,
6
+ } from '../material-library';
7
+ import type { PortableSamplerV1 } from '../portable-sampler';
8
+ export const RAW_MAP_FIDELITY = 'Raw map pixels · unshaded · not GPU capture evidence';
9
+ export interface MaterialViewerSelection {
10
+ materialId: string;
11
+ revisionId: string;
12
+ }
13
+ const id = /^[a-z][a-z0-9_-]{0,79}$/;
14
+ const revision = /^sha256:[a-f0-9]{64}$/;
15
+ export function materialViewerSelection(search: string): MaterialViewerSelection | undefined {
16
+ const query = new URLSearchParams(
17
+ search.startsWith('?') ? search : search.slice(search.indexOf('?') + 1),
18
+ );
19
+ const materialId = query.get('material'),
20
+ revisionId = query.get('materialRevision');
21
+ if (
22
+ query.getAll('material').length !== 1 ||
23
+ query.getAll('materialRevision').length !== 1 ||
24
+ !materialId ||
25
+ !revisionId ||
26
+ !id.test(materialId) ||
27
+ !revision.test(revisionId)
28
+ )
29
+ return undefined;
30
+ return { materialId, revisionId };
31
+ }
32
+ export function materialViewerHref(selection: MaterialViewerSelection): string {
33
+ if (!id.test(selection.materialId) || !revision.test(selection.revisionId))
34
+ throw new Error('Invalid exact material selection');
35
+ return `/?${new URLSearchParams({ material: selection.materialId, materialRevision: selection.revisionId })}`;
36
+ }
37
+ export function materialApiBase(selection: MaterialViewerSelection): string {
38
+ if (!id.test(selection.materialId) || !revision.test(selection.revisionId))
39
+ throw new Error('Invalid exact material selection');
40
+ return `/api/materials/${encodeURIComponent(selection.materialId)}/${encodeURIComponent(selection.revisionId)}`;
41
+ }
42
+ interface Rectangle {
43
+ x: number;
44
+ y: number;
45
+ width: number;
46
+ height: number;
47
+ }
48
+ export interface MaterialPreviewRegion {
49
+ name: string;
50
+ content: Rectangle;
51
+ allocation: Rectangle;
52
+ leftPercent: number;
53
+ topPercent: number;
54
+ widthPercent: number;
55
+ heightPercent: number;
56
+ }
57
+ export interface MaterialPreviewMap {
58
+ slot: string;
59
+ file: string;
60
+ url: string;
61
+ width: number;
62
+ height: number;
63
+ colorSpace: string;
64
+ imageSha256: string;
65
+ pixelHash?: string;
66
+ sampler?: PortableSamplerV1;
67
+ atlas: boolean;
68
+ compilerId?: string;
69
+ manifestHash?: string;
70
+ regions: MaterialPreviewRegion[];
71
+ }
72
+ export function materialPreviewModel(value: MaterialManifest) {
73
+ const material = validateMaterialManifestAny(value);
74
+ const base = materialApiBase(material);
75
+ const maps: MaterialPreviewMap[] = material.maps.map((map) => {
76
+ const modern = 'recipe' in map ? map : undefined;
77
+ const atlas = modern?.recipe?.kind === 'atlas' ? modern.recipe : undefined;
78
+ return {
79
+ slot: map.slot,
80
+ file: map.file,
81
+ url: `${base}/${encodeURIComponent(map.file)}`,
82
+ width: map.width,
83
+ height: map.height,
84
+ colorSpace: map.colorSpace,
85
+ imageSha256: map.sha256,
86
+ ...('pixelHash' in map ? { pixelHash: map.pixelHash } : {}),
87
+ ...('sampler' in map ? { sampler: { ...map.sampler } } : {}),
88
+ atlas: !!atlas,
89
+ ...(modern?.recipe ? { compilerId: modern.recipe.compilerId } : {}),
90
+ ...(atlas ? { manifestHash: atlas.manifest.manifestHash } : {}),
91
+ regions: atlas
92
+ ? atlas.manifest.regions.map((region) => ({
93
+ name: region.name,
94
+ content: { ...region.content },
95
+ allocation: { ...region.allocation },
96
+ leftPercent: (region.content.x / map.width) * 100,
97
+ topPercent: (region.content.y / map.height) * 100,
98
+ widthPercent: (region.content.width / map.width) * 100,
99
+ heightPercent: (region.content.height / map.height) * 100,
100
+ }))
101
+ : [],
102
+ };
103
+ });
104
+ return {
105
+ materialId: material.materialId,
106
+ revisionId: material.revisionId,
107
+ name: material.name,
108
+ fidelity: RAW_MAP_FIDELITY,
109
+ maps,
110
+ };
111
+ }
112
+ /** The retained evaluation and artifact are independently hash-bound by the host. */
113
+ export function materialEvaluationDependencies(
114
+ value: unknown,
115
+ artifactSha256: string,
116
+ ): MaterialManifest[] {
117
+ if (
118
+ !value ||
119
+ typeof value !== 'object' ||
120
+ Array.isArray(value) ||
121
+ (value as Record<string, unknown>)['artifactGlbSha256'] !== artifactSha256
122
+ )
123
+ throw new Error('Retained evaluation does not identify the displayed artifact');
124
+ const dependencies = (value as Record<string, unknown>)['materialLibraryDependencies'];
125
+ if (dependencies === undefined) return [];
126
+ if (!Array.isArray(dependencies) || dependencies.length > 16)
127
+ throw new Error('Invalid retained material dependency list');
128
+ const result = new Map<string, MaterialManifest>();
129
+ for (const raw of dependencies) {
130
+ const manifest = validateMaterialManifestAny(raw),
131
+ key = `${manifest.materialId}/${manifest.revisionId}`,
132
+ existing = result.get(key);
133
+ if (existing && canonicalMaterialJson(existing) !== canonicalMaterialJson(manifest))
134
+ throw new Error('Conflicting retained material identity');
135
+ result.set(key, manifest);
136
+ }
137
+ return [...result.values()];
138
+ }