@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/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:
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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);
|
package/src/viewer/live-panel.ts
CHANGED
|
@@ -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)
|
|
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
|
+
}
|