@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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type ProceduralTextureAdmissionV3 } from "./procedural-texture-v3-contract.js";
|
|
2
|
+
import type { PinnedMaterialMapPortV1, VerifiedPinnedMaterialMapDescriptorV1 } from "./pinned-material-map.js";
|
|
3
|
+
import { TEXTURE_ATLAS_COMPILER_ID, type AtlasRectangleV1, type CanonicalTextureAtlasSourceV1, type CanonicalTextureAtlasSpecV1 } from "./texture-atlas-contract.js";
|
|
4
|
+
type Hash = `sha256:${string}`;
|
|
5
|
+
export interface TextureAtlasCompileOptionsV1 {
|
|
6
|
+
resources?: PinnedMaterialMapPortV1;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}
|
|
9
|
+
export interface TextureAtlasBudgetV1 {
|
|
10
|
+
sourceTexels: number;
|
|
11
|
+
encodedBytes: number;
|
|
12
|
+
workUnits: number;
|
|
13
|
+
conservativePixelAllocationBytes: number;
|
|
14
|
+
dilationScratchBytes: number;
|
|
15
|
+
}
|
|
16
|
+
interface PreparedSource {
|
|
17
|
+
id: string;
|
|
18
|
+
source: CanonicalTextureAtlasSourceV1;
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
descriptor?: VerifiedPinnedMaterialMapDescriptorV1;
|
|
22
|
+
admission?: ProceduralTextureAdmissionV3;
|
|
23
|
+
}
|
|
24
|
+
export interface TextureAtlasAdmissionV1 {
|
|
25
|
+
spec: CanonicalTextureAtlasSpecV1;
|
|
26
|
+
sources: PreparedSource[];
|
|
27
|
+
crops: Map<string, AtlasRectangleV1>;
|
|
28
|
+
budget: TextureAtlasBudgetV1;
|
|
29
|
+
}
|
|
30
|
+
export interface TextureAtlasSourceManifestV1 {
|
|
31
|
+
id: string;
|
|
32
|
+
kind: 'procedural' | 'resource';
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
decodedPixelHash: Hash;
|
|
36
|
+
recipeHash?: Hash;
|
|
37
|
+
compilerId?: string;
|
|
38
|
+
descriptor?: VerifiedPinnedMaterialMapDescriptorV1;
|
|
39
|
+
}
|
|
40
|
+
export interface TextureAtlasRegionManifestV1 {
|
|
41
|
+
name: string;
|
|
42
|
+
content: AtlasRectangleV1;
|
|
43
|
+
allocation: AtlasRectangleV1;
|
|
44
|
+
crop: AtlasRectangleV1;
|
|
45
|
+
sourceId: string;
|
|
46
|
+
}
|
|
47
|
+
export interface TextureAtlasManifestV1 {
|
|
48
|
+
version: 'kiln.atlas-manifest.v1';
|
|
49
|
+
compilerId: typeof TEXTURE_ATLAS_COMPILER_ID;
|
|
50
|
+
width: number;
|
|
51
|
+
height: number;
|
|
52
|
+
usage: CanonicalTextureAtlasSpecV1['usage'];
|
|
53
|
+
origin: 'top-left';
|
|
54
|
+
sampler: CanonicalTextureAtlasSpecV1['sampler'];
|
|
55
|
+
specHash: Hash;
|
|
56
|
+
layoutHash: Hash;
|
|
57
|
+
decodedPixelHash: Hash;
|
|
58
|
+
atlasPixelHash: Hash;
|
|
59
|
+
sources: TextureAtlasSourceManifestV1[];
|
|
60
|
+
regions: TextureAtlasRegionManifestV1[];
|
|
61
|
+
manifestHash: Hash;
|
|
62
|
+
}
|
|
63
|
+
export interface CompiledTextureAtlasV1 {
|
|
64
|
+
spec: CanonicalTextureAtlasSpecV1;
|
|
65
|
+
canonicalJson: string;
|
|
66
|
+
pixels: Uint8Array;
|
|
67
|
+
manifest: TextureAtlasManifestV1;
|
|
68
|
+
budget: TextureAtlasBudgetV1;
|
|
69
|
+
}
|
|
70
|
+
/** Admit all sources and aggregate work/buffer allocations before any source pixel load. */
|
|
71
|
+
export declare function admitTextureAtlasSpecV1(input: unknown, options?: TextureAtlasCompileOptionsV1): TextureAtlasAdmissionV1;
|
|
72
|
+
export declare function compileTextureAtlasSpecV1(input: unknown, options?: TextureAtlasCompileOptionsV1): Promise<CompiledTextureAtlasV1>;
|
|
73
|
+
export {};
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/** Deterministic fixed-scale composition. Resource loading is a trusted host capability. */
|
|
2
|
+
import { compileProceduralTextureSpecV3 } from "./procedural-texture-v3.js";
|
|
3
|
+
import { admitProceduralTextureSpecV3, hashProceduralTextureSpecV3, PROCEDURAL_TEXTURE_V3_COMPILER_ID, } from "./procedural-texture-v3-contract.js";
|
|
4
|
+
import { sha256Hex } from "./procedural-material-v2.js";
|
|
5
|
+
import { atlasAllocationV1, atlasFailure, atlasLayoutHashV1, atlasSourceIdentityV1, assertAtlasCropV1, canonicalizeTextureAtlasSpecV1, canonicalAtlasMetadataJsonV1, hashTextureAtlasSpecV1, TEXTURE_ATLAS_COMPILER_ID, TEXTURE_ATLAS_LIMITS, } from "./texture-atlas-contract.js";
|
|
6
|
+
const isHash = (value) => typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
7
|
+
const boundedInteger = (value, max) => Number.isSafeInteger(value) && value >= 1 && value <= max;
|
|
8
|
+
function checkDescriptor(d, source, usage) {
|
|
9
|
+
const slots = {
|
|
10
|
+
baseColor: 'albedo',
|
|
11
|
+
normal: 'normal',
|
|
12
|
+
metallicRoughness: 'metallicRoughness',
|
|
13
|
+
emissive: 'emissive',
|
|
14
|
+
occlusion: 'occlusion',
|
|
15
|
+
};
|
|
16
|
+
if (d.materialId !== source.pin.resourceId ||
|
|
17
|
+
d.revisionId !== source.pin.revisionId ||
|
|
18
|
+
d.resourceId !== source.resourceId ||
|
|
19
|
+
!isHash(d.revisionId) ||
|
|
20
|
+
!isHash(d.imageSha256))
|
|
21
|
+
atlasFailure('pinned source descriptor identity/hash mismatch.');
|
|
22
|
+
if (d.usage !== usage ||
|
|
23
|
+
slots[d.slot] !== usage ||
|
|
24
|
+
d.colorSpace !== (usage === 'albedo' || usage === 'emissive' ? 'srgb' : 'linear'))
|
|
25
|
+
atlasFailure('pinned source usage/slot/colorSpace mismatch.');
|
|
26
|
+
if (!boundedInteger(d.width, 4096) ||
|
|
27
|
+
!boundedInteger(d.height, 4096) ||
|
|
28
|
+
!boundedInteger(d.encodedByteLength, TEXTURE_ATLAS_LIMITS.encodedSourceBytes) ||
|
|
29
|
+
(d.decodedPixelHash !== undefined && !isHash(d.decodedPixelHash)))
|
|
30
|
+
atlasFailure('pinned source dimensions/byte/hash budget is invalid.');
|
|
31
|
+
for (const key of ['width', 'height', 'imageSha256', 'decodedPixelHash']) {
|
|
32
|
+
const expected = source.expected?.[key];
|
|
33
|
+
if (expected !== undefined && d[key] !== undefined && expected !== d[key])
|
|
34
|
+
atlasFailure(`source expected ${key} does not match verified descriptor.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Admit all sources and aggregate work/buffer allocations before any source pixel load. */
|
|
38
|
+
export function admitTextureAtlasSpecV1(input, options = {}) {
|
|
39
|
+
const spec = canonicalizeTextureAtlasSpecV1(input), unique = new Map(), crops = new Map();
|
|
40
|
+
const outputTexels = spec.width * spec.height;
|
|
41
|
+
// Include output and two output-sized hash snapshots/domain concatenation buffers.
|
|
42
|
+
const budget = {
|
|
43
|
+
sourceTexels: 0,
|
|
44
|
+
encodedBytes: 0,
|
|
45
|
+
workUnits: outputTexels * 13,
|
|
46
|
+
conservativePixelAllocationBytes: outputTexels * 12,
|
|
47
|
+
dilationScratchBytes: 0,
|
|
48
|
+
};
|
|
49
|
+
for (const region of spec.regions) {
|
|
50
|
+
const id = atlasSourceIdentityV1(region.source);
|
|
51
|
+
let prepared = unique.get(id);
|
|
52
|
+
if (!prepared) {
|
|
53
|
+
if (region.source.kind === 'procedural') {
|
|
54
|
+
const admission = admitProceduralTextureSpecV3(region.source.spec);
|
|
55
|
+
prepared = {
|
|
56
|
+
id,
|
|
57
|
+
source: region.source,
|
|
58
|
+
width: admission.spec.width,
|
|
59
|
+
height: admission.spec.height,
|
|
60
|
+
admission,
|
|
61
|
+
};
|
|
62
|
+
budget.workUnits += admission.estimatedWorkUnits;
|
|
63
|
+
budget.conservativePixelAllocationBytes += admission.conservativePixelAllocationBytes;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const described = options.resources?.describePinnedMaterialMap(region.source.resourceId, region.source.pin);
|
|
67
|
+
if (!described)
|
|
68
|
+
atlasFailure(`source ${region.name} requires a verified pinned library map. Get its exact map ID with kiln_material get and supply materialDependencies or project pins; approved resources are unsupported for atlas sources.`);
|
|
69
|
+
checkDescriptor(described, region.source, spec.usage);
|
|
70
|
+
// Snapshot verified metadata before an async loader can mutate its own object.
|
|
71
|
+
const descriptor = structuredClone(described);
|
|
72
|
+
prepared = {
|
|
73
|
+
id,
|
|
74
|
+
source: region.source,
|
|
75
|
+
width: descriptor.width,
|
|
76
|
+
height: descriptor.height,
|
|
77
|
+
descriptor,
|
|
78
|
+
};
|
|
79
|
+
budget.encodedBytes += descriptor.encodedByteLength;
|
|
80
|
+
// Returned owned pixels + our immutable snapshot + decoder/copy allowance + encoded input.
|
|
81
|
+
budget.conservativePixelAllocationBytes +=
|
|
82
|
+
descriptor.width * descriptor.height * 12 + descriptor.encodedByteLength;
|
|
83
|
+
}
|
|
84
|
+
const texels = prepared.width * prepared.height;
|
|
85
|
+
budget.sourceTexels += texels;
|
|
86
|
+
budget.workUnits += texels * 8;
|
|
87
|
+
budget.conservativePixelAllocationBytes += texels * 4; // Raw-pixel hash snapshot allowance.
|
|
88
|
+
unique.set(id, prepared);
|
|
89
|
+
}
|
|
90
|
+
if (region.source.kind === 'resource')
|
|
91
|
+
checkDescriptor(prepared.descriptor, region.source, spec.usage);
|
|
92
|
+
const crop = region.crop ?? { x: 0, y: 0, width: prepared.width, height: prepared.height };
|
|
93
|
+
assertAtlasCropV1(crop, region.content, prepared.width, prepared.height);
|
|
94
|
+
crops.set(region.name, crop);
|
|
95
|
+
const allocation = atlasAllocationV1(region), texels = allocation.width * allocation.height;
|
|
96
|
+
budget.workUnits += texels * (region.edgeMode === 'mask-rgb-dilate' ? 16 : 2);
|
|
97
|
+
if (region.edgeMode === 'mask-rgb-dilate')
|
|
98
|
+
budget.dilationScratchBytes = Math.max(budget.dilationScratchBytes, texels * 8);
|
|
99
|
+
}
|
|
100
|
+
budget.workUnits += (spec.regions.length * (spec.regions.length - 1)) / 2;
|
|
101
|
+
budget.conservativePixelAllocationBytes += budget.dilationScratchBytes;
|
|
102
|
+
if (budget.sourceTexels > TEXTURE_ATLAS_LIMITS.sourceTexels)
|
|
103
|
+
atlasFailure('full decoded source texel budget exceeded; a small crop does not reduce source decode cost.');
|
|
104
|
+
if (budget.encodedBytes > TEXTURE_ATLAS_LIMITS.encodedBytes)
|
|
105
|
+
atlasFailure('aggregate encoded source byte budget exceeded.');
|
|
106
|
+
if (budget.workUnits > TEXTURE_ATLAS_LIMITS.work)
|
|
107
|
+
atlasFailure(`aggregate work budget ${budget.workUnits} exceeds ${TEXTURE_ATLAS_LIMITS.work}.`);
|
|
108
|
+
if (budget.conservativePixelAllocationBytes > TEXTURE_ATLAS_LIMITS.allocatedBytes)
|
|
109
|
+
atlasFailure(`aggregate pixel allocation budget ${budget.conservativePixelAllocationBytes} exceeds ${TEXTURE_ATLAS_LIMITS.allocatedBytes}.`);
|
|
110
|
+
return {
|
|
111
|
+
spec,
|
|
112
|
+
sources: [...unique.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
113
|
+
crops,
|
|
114
|
+
budget,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async function pixelDigest(bytes) {
|
|
118
|
+
// Compiler/host snapshots are ArrayBuffer-backed, never shared mutable cache state.
|
|
119
|
+
const result = await crypto.subtle.digest('SHA-256', bytes);
|
|
120
|
+
return `sha256:${[...new Uint8Array(result)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
121
|
+
}
|
|
122
|
+
function cancelled(signal) {
|
|
123
|
+
if (signal?.aborted)
|
|
124
|
+
atlasFailure('compilation cancelled.');
|
|
125
|
+
}
|
|
126
|
+
/** Copy exact crop and fill only this region's disjoint allocation. Scratch is reused. */
|
|
127
|
+
function composeRegion(output, atlasWidth, region, crop, source, sourceWidth, distances, donors) {
|
|
128
|
+
const allocation = atlasAllocationV1(region), w = allocation.width, h = allocation.height, g = region.gutter;
|
|
129
|
+
const sourcePixel = (cx, cy) => ((crop.y + cy) * sourceWidth + crop.x + cx) * 4;
|
|
130
|
+
const destination = (x, y) => ((allocation.y + y) * atlasWidth + allocation.x + x) * 4;
|
|
131
|
+
if (region.edgeMode === 'copy-edge') {
|
|
132
|
+
for (let y = 0; y < h; y++)
|
|
133
|
+
for (let x = 0; x < w; x++) {
|
|
134
|
+
const s = sourcePixel(Math.max(0, Math.min(crop.width - 1, x - g)), Math.max(0, Math.min(crop.height - 1, y - g))), d = destination(x, y);
|
|
135
|
+
for (let c = 0; c < 4; c++)
|
|
136
|
+
output[d + c] = source[s + c];
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const length = w * h, infinity = 0x3fffffff;
|
|
141
|
+
distances.fill(infinity, 0, length);
|
|
142
|
+
donors.fill(infinity, 0, length);
|
|
143
|
+
for (let cy = 0; cy < crop.height; cy++)
|
|
144
|
+
for (let cx = 0; cx < crop.width; cx++) {
|
|
145
|
+
const s = sourcePixel(cx, cy), d = destination(cx + g, cy + g), index = (cy + g) * w + cx + g;
|
|
146
|
+
for (let c = 0; c < 4; c++)
|
|
147
|
+
output[d + c] = source[s + c];
|
|
148
|
+
if (source[s + 3] > 0) {
|
|
149
|
+
distances[index] = 0;
|
|
150
|
+
donors[index] = cy * crop.width + cx;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const consider = (i, neighbor) => {
|
|
154
|
+
const distance = distances[neighbor] + 1;
|
|
155
|
+
if (distance < distances[i] ||
|
|
156
|
+
(distance === distances[i] && donors[neighbor] < donors[i])) {
|
|
157
|
+
distances[i] = distance;
|
|
158
|
+
donors[i] = donors[neighbor];
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
for (let y = 0; y < h; y++)
|
|
162
|
+
for (let x = 0; x < w; x++) {
|
|
163
|
+
const i = y * w + x;
|
|
164
|
+
if (x)
|
|
165
|
+
consider(i, i - 1);
|
|
166
|
+
if (y)
|
|
167
|
+
consider(i, i - w);
|
|
168
|
+
}
|
|
169
|
+
for (let y = h - 1; y >= 0; y--)
|
|
170
|
+
for (let x = w - 1; x >= 0; x--) {
|
|
171
|
+
const i = y * w + x;
|
|
172
|
+
if (x + 1 < w)
|
|
173
|
+
consider(i, i + 1);
|
|
174
|
+
if (y + 1 < h)
|
|
175
|
+
consider(i, i + w);
|
|
176
|
+
}
|
|
177
|
+
for (let y = 0; y < h; y++)
|
|
178
|
+
for (let x = 0; x < w; x++) {
|
|
179
|
+
const d = destination(x, y);
|
|
180
|
+
if (output[d + 3] > 0)
|
|
181
|
+
continue;
|
|
182
|
+
const donor = donors[y * w + x];
|
|
183
|
+
if (donor === infinity) {
|
|
184
|
+
output[d] = 0;
|
|
185
|
+
output[d + 1] = 0;
|
|
186
|
+
output[d + 2] = 0;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
const s = sourcePixel(donor % crop.width, Math.floor(donor / crop.width));
|
|
190
|
+
for (let c = 0; c < 3; c++)
|
|
191
|
+
output[d + c] = source[s + c];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export async function compileTextureAtlasSpecV1(input, options = {}) {
|
|
196
|
+
const admission = admitTextureAtlasSpecV1(input, options), { spec, sources, crops, budget } = admission;
|
|
197
|
+
cancelled(options.signal);
|
|
198
|
+
const sourcePixels = new Map(), sourceManifests = [];
|
|
199
|
+
// Sequential loads avoid multiplying decoder/snapshot peaks; each exact source compiles once.
|
|
200
|
+
for (const prepared of sources) {
|
|
201
|
+
cancelled(options.signal);
|
|
202
|
+
let pixels, manifest;
|
|
203
|
+
if (prepared.source.kind === 'procedural') {
|
|
204
|
+
pixels = compileProceduralTextureSpecV3(prepared.source.spec).pixels;
|
|
205
|
+
manifest = {
|
|
206
|
+
id: prepared.id,
|
|
207
|
+
kind: 'procedural',
|
|
208
|
+
width: prepared.width,
|
|
209
|
+
height: prepared.height,
|
|
210
|
+
decodedPixelHash: await pixelDigest(pixels),
|
|
211
|
+
recipeHash: hashProceduralTextureSpecV3(prepared.source.spec),
|
|
212
|
+
compilerId: PROCEDURAL_TEXTURE_V3_COMPILER_ID,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const resolved = await options.resources.resolvePinnedMaterialMapPixels(prepared.source.resourceId, prepared.source.pin, { signal: options.signal });
|
|
217
|
+
checkDescriptor(resolved.descriptor, prepared.source, spec.usage);
|
|
218
|
+
if (canonicalAtlasMetadataJsonV1(resolved.descriptor) !==
|
|
219
|
+
canonicalAtlasMetadataJsonV1(prepared.descriptor))
|
|
220
|
+
atlasFailure('resolved source descriptor changed after admission.');
|
|
221
|
+
if (!(resolved.pixels instanceof Uint8Array) ||
|
|
222
|
+
resolved.pixels.length !== prepared.width * prepared.height * 4)
|
|
223
|
+
atlasFailure('decoded source RGBA dimensions/length mismatch.');
|
|
224
|
+
pixels = Uint8Array.from(resolved.pixels);
|
|
225
|
+
const decodedPixelHash = await pixelDigest(pixels);
|
|
226
|
+
if (decodedPixelHash !== resolved.decodedPixelHash ||
|
|
227
|
+
(prepared.descriptor.decodedPixelHash !== undefined &&
|
|
228
|
+
decodedPixelHash !== prepared.descriptor.decodedPixelHash))
|
|
229
|
+
atlasFailure('decoded source pixel hash mismatch.');
|
|
230
|
+
manifest = {
|
|
231
|
+
id: prepared.id,
|
|
232
|
+
kind: 'resource',
|
|
233
|
+
width: prepared.width,
|
|
234
|
+
height: prepared.height,
|
|
235
|
+
decodedPixelHash,
|
|
236
|
+
descriptor: prepared.descriptor,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
sourcePixels.set(prepared.id, pixels);
|
|
240
|
+
sourceManifests.push(manifest);
|
|
241
|
+
}
|
|
242
|
+
// Compare every reuse's expectations, including an old external source without a descriptor hash.
|
|
243
|
+
for (const region of spec.regions)
|
|
244
|
+
if (region.source.kind === 'resource' &&
|
|
245
|
+
region.source.expected?.decodedPixelHash !== undefined) {
|
|
246
|
+
const actual = sourceManifests.find((v) => v.id === atlasSourceIdentityV1(region.source)).decodedPixelHash;
|
|
247
|
+
if (actual !== region.source.expected.decodedPixelHash)
|
|
248
|
+
atlasFailure('source expected decodedPixelHash mismatch.');
|
|
249
|
+
}
|
|
250
|
+
cancelled(options.signal);
|
|
251
|
+
const pixels = new Uint8Array(spec.width * spec.height * 4), scratchLength = budget.dilationScratchBytes / 8;
|
|
252
|
+
const distances = new Int32Array(scratchLength), donors = new Int32Array(scratchLength);
|
|
253
|
+
for (const region of spec.regions)
|
|
254
|
+
composeRegion(pixels, spec.width, region, crops.get(region.name), sourcePixels.get(atlasSourceIdentityV1(region.source)), sources.find((v) => v.id === atlasSourceIdentityV1(region.source)).width, distances, donors);
|
|
255
|
+
const decodedPixelHash = await pixelDigest(pixels), header = new TextEncoder().encode(`kiln.atlas-pixels.v1\n${spec.width},${spec.height},${spec.usage}\n`);
|
|
256
|
+
const tagged = new Uint8Array(header.length + pixels.length);
|
|
257
|
+
tagged.set(header);
|
|
258
|
+
tagged.set(pixels, header.length);
|
|
259
|
+
const atlasPixelHash = await pixelDigest(tagged);
|
|
260
|
+
const payload = {
|
|
261
|
+
version: 'kiln.atlas-manifest.v1',
|
|
262
|
+
compilerId: TEXTURE_ATLAS_COMPILER_ID,
|
|
263
|
+
width: spec.width,
|
|
264
|
+
height: spec.height,
|
|
265
|
+
usage: spec.usage,
|
|
266
|
+
origin: 'top-left',
|
|
267
|
+
sampler: spec.sampler,
|
|
268
|
+
specHash: hashTextureAtlasSpecV1(spec),
|
|
269
|
+
layoutHash: atlasLayoutHashV1(spec),
|
|
270
|
+
decodedPixelHash,
|
|
271
|
+
atlasPixelHash,
|
|
272
|
+
sources: sourceManifests,
|
|
273
|
+
regions: spec.regions.map((r) => ({
|
|
274
|
+
name: r.name,
|
|
275
|
+
content: r.content,
|
|
276
|
+
allocation: atlasAllocationV1(r),
|
|
277
|
+
crop: crops.get(r.name),
|
|
278
|
+
sourceId: atlasSourceIdentityV1(r.source),
|
|
279
|
+
})),
|
|
280
|
+
};
|
|
281
|
+
const manifestHash = `sha256:${sha256Hex(`kiln.atlas-manifest.v1\n${canonicalAtlasMetadataJsonV1(payload)}`)}`;
|
|
282
|
+
cancelled(options.signal);
|
|
283
|
+
return {
|
|
284
|
+
spec,
|
|
285
|
+
canonicalJson: JSON.stringify(spec),
|
|
286
|
+
pixels,
|
|
287
|
+
manifest: { ...payload, manifestHash },
|
|
288
|
+
budget,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Three state and affine UV wrapper; host resources never enter authored arguments. */
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { type TextureAtlasCompileOptionsV1, type TextureAtlasManifestV1 } from "./texture-atlas-pixels.js";
|
|
4
|
+
/** SDK/host API. Sandbox integration closes over options and exposes exactly one argument. */
|
|
5
|
+
export declare function textureAtlas(input: unknown, options?: TextureAtlasCompileOptionsV1): Promise<THREE.DataTexture>;
|
|
6
|
+
export interface AtlasUVOptionsV1 {
|
|
7
|
+
inputOrigin: 'top-left' | 'bottom-left';
|
|
8
|
+
mode: 'content-boundary' | 'texel-center';
|
|
9
|
+
}
|
|
10
|
+
export declare function remapUVToAtlas(geometry: THREE.BufferGeometry, atlas: THREE.Texture | TextureAtlasManifestV1, name: string, options: AtlasUVOptionsV1): THREE.BufferGeometry;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** Three state and affine UV wrapper; host resources never enter authored arguments. */
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { applyPortableSamplerV1 } from "./portable-sampler-runtime.js";
|
|
4
|
+
import { compileTextureAtlasSpecV1, } from "./texture-atlas-pixels.js";
|
|
5
|
+
import { atlasFailure, canonicalAtlasMetadataJsonV1, TEXTURE_ATLAS_COMPILER_ID, TEXTURE_ATLAS_LIMITS, } from "./texture-atlas-contract.js";
|
|
6
|
+
import { sha256Hex } from "./procedural-material-v2.js";
|
|
7
|
+
import { remapUV } from "./uv-shapes.js";
|
|
8
|
+
/** SDK/host API. Sandbox integration closes over options and exposes exactly one argument. */
|
|
9
|
+
export async function textureAtlas(input, options = {}) {
|
|
10
|
+
const compiled = await compileTextureAtlasSpecV1(input, options);
|
|
11
|
+
const texture = new THREE.DataTexture(compiled.pixels, compiled.spec.width, compiled.spec.height, THREE.RGBAFormat, THREE.UnsignedByteType);
|
|
12
|
+
texture.name = 'Atlas';
|
|
13
|
+
texture.flipY = false;
|
|
14
|
+
texture.colorSpace =
|
|
15
|
+
compiled.spec.usage === 'albedo' || compiled.spec.usage === 'emissive'
|
|
16
|
+
? THREE.SRGBColorSpace
|
|
17
|
+
: THREE.NoColorSpace;
|
|
18
|
+
applyPortableSamplerV1(texture, compiled.spec.sampler);
|
|
19
|
+
texture.userData.kilnAtlas = { spec: compiled.spec, manifest: compiled.manifest };
|
|
20
|
+
texture.userData.kilnTexture = {
|
|
21
|
+
usage: compiled.spec.usage,
|
|
22
|
+
colorSpace: compiled.spec.usage === 'albedo' || compiled.spec.usage === 'emissive' ? 'srgb' : 'linear',
|
|
23
|
+
width: compiled.spec.width,
|
|
24
|
+
height: compiled.spec.height,
|
|
25
|
+
hasAlpha: compiled.pixels.some((v, i) => i % 4 === 3 && v < 255),
|
|
26
|
+
};
|
|
27
|
+
return texture;
|
|
28
|
+
}
|
|
29
|
+
function manifestOf(atlas) {
|
|
30
|
+
const value = atlas?.isTexture
|
|
31
|
+
? atlas.userData.kilnAtlas?.manifest
|
|
32
|
+
: atlas;
|
|
33
|
+
if (!value || typeof value !== 'object')
|
|
34
|
+
atlasFailure('an atlas texture or compiled manifest is required.');
|
|
35
|
+
canonicalAtlasMetadataJsonV1(value);
|
|
36
|
+
const manifest = value;
|
|
37
|
+
if (manifest.version !== 'kiln.atlas-manifest.v1' ||
|
|
38
|
+
manifest.compilerId !== TEXTURE_ATLAS_COMPILER_ID ||
|
|
39
|
+
manifest.origin !== 'top-left' ||
|
|
40
|
+
!Array.isArray(manifest.regions) ||
|
|
41
|
+
!manifest.regions.length ||
|
|
42
|
+
manifest.regions.length > 64 ||
|
|
43
|
+
!Number.isSafeInteger(manifest.width) ||
|
|
44
|
+
manifest.width < 4 ||
|
|
45
|
+
manifest.width > 1024 ||
|
|
46
|
+
!Number.isSafeInteger(manifest.height) ||
|
|
47
|
+
manifest.height < 4 ||
|
|
48
|
+
manifest.height > 1024)
|
|
49
|
+
atlasFailure('invalid atlas manifest dimensions/version.');
|
|
50
|
+
const { manifestHash, ...payload } = manifest;
|
|
51
|
+
if (`sha256:${sha256Hex(`kiln.atlas-manifest.v1\n${canonicalAtlasMetadataJsonV1(payload)}`)}` !==
|
|
52
|
+
manifestHash)
|
|
53
|
+
atlasFailure('atlas manifest identity mismatch.');
|
|
54
|
+
return manifest;
|
|
55
|
+
}
|
|
56
|
+
export function remapUVToAtlas(geometry, atlas, name, options) {
|
|
57
|
+
const manifest = manifestOf(atlas);
|
|
58
|
+
canonicalAtlasMetadataJsonV1(options);
|
|
59
|
+
if (!options ||
|
|
60
|
+
typeof options !== 'object' ||
|
|
61
|
+
Array.isArray(options) ||
|
|
62
|
+
Object.keys(options).some((k) => !['inputOrigin', 'mode'].includes(k)) ||
|
|
63
|
+
!['top-left', 'bottom-left'].includes(options.inputOrigin) ||
|
|
64
|
+
!['content-boundary', 'texel-center'].includes(options.mode))
|
|
65
|
+
atlasFailure('explicit inputOrigin and content-boundary/texel-center mode are required.');
|
|
66
|
+
const region = manifest.regions.find((r) => r.name === name);
|
|
67
|
+
if (!region)
|
|
68
|
+
atlasFailure(`unknown named region ${String(name)}.`);
|
|
69
|
+
const { x, y, width: w, height: h } = region.content;
|
|
70
|
+
if (![x, y, w, h].every(Number.isSafeInteger) ||
|
|
71
|
+
x < 0 ||
|
|
72
|
+
y < 0 ||
|
|
73
|
+
w < 1 ||
|
|
74
|
+
h < 1 ||
|
|
75
|
+
x + w > manifest.width ||
|
|
76
|
+
y + h > manifest.height)
|
|
77
|
+
atlasFailure('invalid region content rectangle.');
|
|
78
|
+
const uv = geometry?.getAttribute?.('uv'), position = geometry?.getAttribute?.('position');
|
|
79
|
+
if (!position ||
|
|
80
|
+
!uv ||
|
|
81
|
+
uv.itemSize !== 2 ||
|
|
82
|
+
uv.count !== position.count ||
|
|
83
|
+
uv.count < 1 ||
|
|
84
|
+
uv.count > TEXTURE_ATLAS_LIMITS.uvVertices ||
|
|
85
|
+
uv.isInstancedBufferAttribute ||
|
|
86
|
+
uv.data?.isInstancedInterleavedBuffer)
|
|
87
|
+
atlasFailure('matching per-vertex UV0 and positions are required; per-instance UVs are unsupported.');
|
|
88
|
+
let bytes = uv.count * 8;
|
|
89
|
+
const interleavedBuffers = new Set();
|
|
90
|
+
const count = (attribute) => {
|
|
91
|
+
if (attribute.isInterleavedBufferAttribute) {
|
|
92
|
+
// Three's geometry-copy data cache deduplicates interleaved backing buffers.
|
|
93
|
+
const buffer = attribute.array.buffer;
|
|
94
|
+
if (!interleavedBuffers.has(buffer)) {
|
|
95
|
+
interleavedBuffers.add(buffer);
|
|
96
|
+
bytes += buffer.byteLength;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Ordinary attributes/morphs/index clone independently even with shared input arrays.
|
|
101
|
+
// Typed convenience subclasses may allocate in both constructor and copy.
|
|
102
|
+
bytes +=
|
|
103
|
+
attribute.array.byteLength *
|
|
104
|
+
(attribute.constructor === THREE.BufferAttribute ||
|
|
105
|
+
attribute.constructor === THREE.InstancedBufferAttribute
|
|
106
|
+
? 1
|
|
107
|
+
: 2);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
for (const attr of Object.values(geometry.attributes))
|
|
111
|
+
count(attr);
|
|
112
|
+
for (const attrs of Object.values(geometry.morphAttributes))
|
|
113
|
+
for (const attr of attrs)
|
|
114
|
+
count(attr);
|
|
115
|
+
if (geometry.index)
|
|
116
|
+
count(geometry.index);
|
|
117
|
+
if (bytes > TEXTURE_ATLAS_LIMITS.uvAttributeBytes)
|
|
118
|
+
atlasFailure('owned UV clone attribute allocation budget exceeded.');
|
|
119
|
+
for (let i = 0; i < uv.count; i++)
|
|
120
|
+
if (![uv.getX(i), uv.getY(i)].every((v) => Number.isFinite(v) && v >= 0 && v <= 1))
|
|
121
|
+
atlasFailure('UV0 must be finite in the unit domain [0,1]; region tiling is unsupported.');
|
|
122
|
+
const center = options.mode === 'texel-center', su = (w - (center ? 1 : 0)) / manifest.width, sv = (h - (center ? 1 : 0)) / manifest.height;
|
|
123
|
+
const bottom = options.inputOrigin === 'bottom-left';
|
|
124
|
+
const offset = [
|
|
125
|
+
(x + (center ? 0.5 : 0)) / manifest.width,
|
|
126
|
+
(y + (bottom ? h - (center ? 0.5 : 0) : center ? 0.5 : 0)) / manifest.height,
|
|
127
|
+
];
|
|
128
|
+
const scale = [su, bottom ? -sv : sv];
|
|
129
|
+
const output = remapUV(geometry, { scale, offset });
|
|
130
|
+
output.userData.kilnAtlasBinding = {
|
|
131
|
+
manifestHash: manifest.manifestHash,
|
|
132
|
+
specHash: manifest.specHash,
|
|
133
|
+
layoutHash: manifest.layoutHash,
|
|
134
|
+
region: name,
|
|
135
|
+
content: structuredClone(region.content),
|
|
136
|
+
allocation: structuredClone(region.allocation),
|
|
137
|
+
inputOrigin: options.inputOrigin,
|
|
138
|
+
mode: options.mode,
|
|
139
|
+
scale,
|
|
140
|
+
offset,
|
|
141
|
+
};
|
|
142
|
+
return output;
|
|
143
|
+
}
|
package/lib/texture-bake.d.ts
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
* blocks, with a message that now names the procedural fix.
|
|
40
40
|
*/
|
|
41
41
|
import * as THREE from 'three';
|
|
42
|
+
import { type AtlasReferenceV1 } from "./atlas-provenance.js";
|
|
42
43
|
import type { TextureUsage } from "./textures.js";
|
|
43
44
|
export interface BakedTextureBindingV1 {
|
|
44
45
|
node: string;
|
|
@@ -90,6 +91,8 @@ export interface BakedTextureProvenanceV1 {
|
|
|
90
91
|
* regenerate the exact bytes. Absent for textures loaded from a file.
|
|
91
92
|
*/
|
|
92
93
|
procedural?: ProceduralRecipeRecord;
|
|
94
|
+
/** Compact atlas source identity; the export scene table owns its recipe/manifest. */
|
|
95
|
+
atlas?: AtlasReferenceV1;
|
|
93
96
|
}
|
|
94
97
|
/** The `userData.kilnProcedural` stamp `proceduralTexture()` leaves behind. */
|
|
95
98
|
interface ProceduralRecipeRecord {
|
package/lib/texture-bake.js
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import { createHash } from 'node:crypto';
|
|
42
42
|
import * as THREE from 'three';
|
|
43
|
+
import { collectAtlasProvenance, readAtlasEncodedTexture, readValidatedAtlasDelivery, } from "./atlas-provenance.js";
|
|
43
44
|
/**
|
|
44
45
|
* Material slots the GLB bridge can serialize, with the glTF usage each one
|
|
45
46
|
* implies. Kept in the same order the bridge reads them so a warning list and a
|
|
@@ -142,6 +143,7 @@ function isTexture(value) {
|
|
|
142
143
|
* @returns Provenance for each baked texture, sorted by name/slot.
|
|
143
144
|
*/
|
|
144
145
|
export async function bakeSceneTextures(root, warnings) {
|
|
146
|
+
const atlas = collectAtlasProvenance(root);
|
|
145
147
|
// Collect first, encode second: traversal is sync, encoding is not, and doing
|
|
146
148
|
// them together would interleave awaits with a mutating walk.
|
|
147
149
|
const pending = new Map();
|
|
@@ -154,7 +156,7 @@ export async function bakeSceneTextures(root, warnings) {
|
|
|
154
156
|
const texture = bag[slot];
|
|
155
157
|
if (!isTexture(texture))
|
|
156
158
|
continue;
|
|
157
|
-
if (texture.userData['encoded'])
|
|
159
|
+
if (texture.userData['encoded'] && !atlas.texture(texture))
|
|
158
160
|
continue;
|
|
159
161
|
const existing = pending.get(texture) ?? { texture, bindings: [] };
|
|
160
162
|
existing.bindings.push({
|
|
@@ -170,16 +172,66 @@ export async function bakeSceneTextures(root, warnings) {
|
|
|
170
172
|
const baked = [];
|
|
171
173
|
for (const entry of pending.values()) {
|
|
172
174
|
const name = entry.texture.name || '(unnamed texture)';
|
|
173
|
-
const
|
|
175
|
+
const atlasRef = atlas.texture(entry.texture);
|
|
176
|
+
const atlasRecord = atlasRef && atlas.table.atlases.find((a) => a.id === atlasRef.id);
|
|
177
|
+
const encoded = atlasRef ? readAtlasEncodedTexture(entry.texture) : undefined;
|
|
178
|
+
if (encoded &&
|
|
179
|
+
(encoded.mime !== 'image/png' ||
|
|
180
|
+
!(encoded.bytes instanceof Uint8Array) ||
|
|
181
|
+
encoded.bytes.length > 16_777_216))
|
|
182
|
+
throw new TypeError('Atlas baked encoding must be bounded PNG bytes.');
|
|
183
|
+
const raw = encoded && atlasRecord
|
|
184
|
+
? {
|
|
185
|
+
width: atlasRecord.spec.width,
|
|
186
|
+
height: atlasRecord.spec.height,
|
|
187
|
+
channels: 4,
|
|
188
|
+
data: new Uint8Array(0),
|
|
189
|
+
}
|
|
190
|
+
: readPixels(entry.texture);
|
|
174
191
|
const firstBinding = entry.bindings[0];
|
|
175
192
|
const bindingSummary = `material ${JSON.stringify(firstBinding.material)} (${firstBinding.slot})${entry.bindings.length > 1 ? ` and ${entry.bindings.length - 1} other binding(s)` : ''}`;
|
|
176
193
|
if (typeof raw === 'string') {
|
|
194
|
+
if (atlasRef)
|
|
195
|
+
throw new TypeError(`Atlas has stale compiled provenance: ${raw}; rebuild with textureAtlas() or remove atlas metadata for a manual texture.`);
|
|
177
196
|
warnings.push(`Texture ${JSON.stringify(name)} on ${bindingSummary} was dropped from the GLB: ${raw}.`);
|
|
178
197
|
continue;
|
|
179
198
|
}
|
|
199
|
+
if (atlasRecord) {
|
|
200
|
+
const stale = () => new TypeError('Atlas has stale compiled provenance: rebuild with textureAtlas() after editing pixels, or remove atlas metadata for a manual texture.');
|
|
201
|
+
if (encoded) {
|
|
202
|
+
const delivery = readValidatedAtlasDelivery(entry.texture);
|
|
203
|
+
if (delivery) {
|
|
204
|
+
if (delivery.id !== atlasRef.id ||
|
|
205
|
+
delivery.finalMime !== encoded.mime ||
|
|
206
|
+
delivery.finalImageSha256 !==
|
|
207
|
+
`sha256:${createHash('sha256').update(encoded.bytes).digest('hex')}`)
|
|
208
|
+
throw stale();
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const sharp = (await import('sharp')).default;
|
|
212
|
+
const image = sharp(encoded.bytes, {
|
|
213
|
+
limitInputPixels: atlasRecord.spec.width * atlasRecord.spec.height,
|
|
214
|
+
});
|
|
215
|
+
const metadata = await image.metadata();
|
|
216
|
+
if (metadata.width !== atlasRecord.spec.width ||
|
|
217
|
+
metadata.height !== atlasRecord.spec.height)
|
|
218
|
+
throw stale();
|
|
219
|
+
const decoded = await image.ensureAlpha().raw().toBuffer();
|
|
220
|
+
if (`sha256:${createHash('sha256').update(decoded).digest('hex')}` !==
|
|
221
|
+
atlasRecord.manifest.decodedPixelHash)
|
|
222
|
+
throw stale();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else if (raw.width !== atlasRecord.spec.width ||
|
|
226
|
+
raw.height !== atlasRecord.spec.height ||
|
|
227
|
+
raw.channels !== 4 ||
|
|
228
|
+
`sha256:${createHash('sha256').update(raw.data).digest('hex')}` !==
|
|
229
|
+
atlasRecord.manifest.decodedPixelHash)
|
|
230
|
+
throw stale();
|
|
231
|
+
}
|
|
180
232
|
let bytes;
|
|
181
233
|
try {
|
|
182
|
-
bytes = await encodePngFromRaw(raw);
|
|
234
|
+
bytes = encoded?.bytes ?? (await encodePngFromRaw(raw));
|
|
183
235
|
}
|
|
184
236
|
catch (err) {
|
|
185
237
|
warnings.push(`Texture ${JSON.stringify(name)} on ${bindingSummary} was dropped from the GLB: PNG encoding failed (${err instanceof Error ? err.message : String(err)}).`);
|
|
@@ -197,18 +249,20 @@ export async function bakeSceneTextures(root, warnings) {
|
|
|
197
249
|
// Prefer the authored portable usage over incidental slot order. A packed
|
|
198
250
|
// metallic-roughness map occupies both Three.js slots, where choosing the
|
|
199
251
|
// first (`roughnessMap`) loses the authored combined-channel meaning.
|
|
200
|
-
const primaryUsage =
|
|
252
|
+
const primaryUsage = atlasRecord?.spec.usage ??
|
|
253
|
+
recipe?.usage ??
|
|
201
254
|
(entry.bindings.some(({ slot }) => slot === 'roughnessMap') &&
|
|
202
255
|
entry.bindings.some(({ slot }) => slot === 'metalnessMap')
|
|
203
256
|
? 'metallicRoughness'
|
|
204
257
|
: (entry.bindings[0]?.usage ?? 'albedo'));
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
258
|
+
if (!encoded)
|
|
259
|
+
entry.texture.userData['kilnTexture'] = {
|
|
260
|
+
usage: primaryUsage,
|
|
261
|
+
colorSpace,
|
|
262
|
+
width: raw.width,
|
|
263
|
+
height: raw.height,
|
|
264
|
+
hasAlpha: hasTranslucentPixel(raw),
|
|
265
|
+
};
|
|
212
266
|
const sha1 = createHash('sha1').update(bytes).digest('hex');
|
|
213
267
|
const imageSha256 = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
214
268
|
baked.push({
|
|
@@ -228,6 +282,7 @@ export async function bakeSceneTextures(root, warnings) {
|
|
|
228
282
|
imageSha256,
|
|
229
283
|
sha1,
|
|
230
284
|
...(recipe ? { procedural: recipe } : {}),
|
|
285
|
+
...(atlasRef ? { atlas: atlasRef } : {}),
|
|
231
286
|
});
|
|
232
287
|
}
|
|
233
288
|
return baked.sort((a, b) => `${a.node}:${a.material}:${a.texture}:${a.slot}`.localeCompare(`${b.node}:${b.material}:${b.texture}:${b.slot}`));
|