@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,558 @@
|
|
|
1
|
+
/** Browser-safe named pixel regions. Admission never loads or allocates image pixels. */
|
|
2
|
+
import {
|
|
3
|
+
canonicalizePortableSamplerV1,
|
|
4
|
+
type CanonicalPortableSamplerV1,
|
|
5
|
+
type PortableSamplerV1,
|
|
6
|
+
} from './portable-sampler';
|
|
7
|
+
import {
|
|
8
|
+
admitProceduralTextureSpecV3,
|
|
9
|
+
hashProceduralTextureSpecV3,
|
|
10
|
+
type CanonicalProceduralTextureSpecV3,
|
|
11
|
+
type ProceduralTextureSpecV3,
|
|
12
|
+
} from './procedural-texture-v3-contract';
|
|
13
|
+
import { sha256Hex } from './procedural-material-v2';
|
|
14
|
+
import type { MaterialMapPinV1 } from './pinned-material-map';
|
|
15
|
+
import type { TextureAtlasManifestV1 } from './texture-atlas-pixels';
|
|
16
|
+
import { TEXTURE_USAGES, type TextureUsage } from './texture-contract';
|
|
17
|
+
import { AuthoringDiagnosticError } from './evaluator/authoring-diagnostic';
|
|
18
|
+
|
|
19
|
+
export const TEXTURE_ATLAS_COMPILER_ID = 'kiln.texture-atlas.v1.1' as const;
|
|
20
|
+
export const TEXTURE_ATLAS_LIMITS = Object.freeze({
|
|
21
|
+
edge: 1024,
|
|
22
|
+
outputTexels: 1_048_576,
|
|
23
|
+
regions: 64,
|
|
24
|
+
sources: 16,
|
|
25
|
+
sourceTexels: 4_194_304,
|
|
26
|
+
encodedSourceBytes: 8_388_608,
|
|
27
|
+
encodedBytes: 16_777_216,
|
|
28
|
+
jsonBytes: 262_144,
|
|
29
|
+
work: 64_000_000,
|
|
30
|
+
allocatedBytes: 67_108_864,
|
|
31
|
+
uvVertices: 262_144,
|
|
32
|
+
uvAttributeBytes: 134_217_728,
|
|
33
|
+
});
|
|
34
|
+
export interface AtlasRectangleV1 {
|
|
35
|
+
x: number;
|
|
36
|
+
y: number;
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
}
|
|
40
|
+
export type AtlasEdgeModeV1 = 'copy-edge' | 'mask-rgb-dilate';
|
|
41
|
+
export interface AtlasResourceExpectationV1 {
|
|
42
|
+
width?: number;
|
|
43
|
+
height?: number;
|
|
44
|
+
imageSha256?: `sha256:${string}`;
|
|
45
|
+
decodedPixelHash?: `sha256:${string}`;
|
|
46
|
+
}
|
|
47
|
+
export type TextureAtlasSourceV1 =
|
|
48
|
+
| { kind: 'procedural'; spec: ProceduralTextureSpecV3 }
|
|
49
|
+
| {
|
|
50
|
+
kind: 'resource';
|
|
51
|
+
resourceId: string;
|
|
52
|
+
pin: MaterialMapPinV1;
|
|
53
|
+
expected?: AtlasResourceExpectationV1;
|
|
54
|
+
};
|
|
55
|
+
export interface TextureAtlasRegionV1 {
|
|
56
|
+
name: string;
|
|
57
|
+
content: AtlasRectangleV1;
|
|
58
|
+
gutter?: number;
|
|
59
|
+
edgeMode?: AtlasEdgeModeV1;
|
|
60
|
+
source: TextureAtlasSourceV1;
|
|
61
|
+
crop?: AtlasRectangleV1;
|
|
62
|
+
}
|
|
63
|
+
export interface TextureAtlasSpecV1 {
|
|
64
|
+
schemaVersion: 1;
|
|
65
|
+
width: number;
|
|
66
|
+
height: number;
|
|
67
|
+
usage: TextureUsage;
|
|
68
|
+
sampler?: PortableSamplerV1;
|
|
69
|
+
regions: TextureAtlasRegionV1[];
|
|
70
|
+
}
|
|
71
|
+
export type CanonicalTextureAtlasSourceV1 =
|
|
72
|
+
| { kind: 'procedural'; spec: CanonicalProceduralTextureSpecV3 }
|
|
73
|
+
| Extract<TextureAtlasSourceV1, { kind: 'resource' }>;
|
|
74
|
+
export interface CanonicalTextureAtlasRegionV1 {
|
|
75
|
+
name: string;
|
|
76
|
+
content: AtlasRectangleV1;
|
|
77
|
+
gutter: number;
|
|
78
|
+
edgeMode: AtlasEdgeModeV1;
|
|
79
|
+
source: CanonicalTextureAtlasSourceV1;
|
|
80
|
+
crop?: AtlasRectangleV1;
|
|
81
|
+
}
|
|
82
|
+
export interface CanonicalTextureAtlasSpecV1 {
|
|
83
|
+
schemaVersion: 1;
|
|
84
|
+
width: number;
|
|
85
|
+
height: number;
|
|
86
|
+
usage: TextureUsage;
|
|
87
|
+
sampler: CanonicalPortableSamplerV1;
|
|
88
|
+
regions: CanonicalTextureAtlasRegionV1[];
|
|
89
|
+
}
|
|
90
|
+
export class TextureAtlasError extends AuthoringDiagnosticError {
|
|
91
|
+
constructor(message: string) {
|
|
92
|
+
super('TEXTURE_ATLAS_CONTRACT', `textureAtlas: ${message}`);
|
|
93
|
+
this.name = 'TextureAtlasError';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function atlasFailure(message: string): never {
|
|
97
|
+
throw new TextureAtlasError(message);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function preflight(input: unknown): void {
|
|
101
|
+
let nodes = 0;
|
|
102
|
+
let bytes = 0;
|
|
103
|
+
const encoder = new TextEncoder();
|
|
104
|
+
const addBytes = (count: number) => {
|
|
105
|
+
bytes += count;
|
|
106
|
+
if (bytes > TEXTURE_ATLAS_LIMITS.jsonBytes) atlasFailure('JSON byte budget exceeded.');
|
|
107
|
+
};
|
|
108
|
+
const visit = (value: unknown, depth: number): void => {
|
|
109
|
+
if (++nodes > 65_536 || depth > 16) atlasFailure('JSON node/depth budget exceeded.');
|
|
110
|
+
if (value === null || typeof value === 'boolean') {
|
|
111
|
+
addBytes(value === false ? 5 : 4);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (typeof value === 'number') {
|
|
115
|
+
if (!Number.isFinite(value)) atlasFailure('JSON numbers must be finite.');
|
|
116
|
+
addBytes(JSON.stringify(value).length);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (typeof value === 'string') {
|
|
120
|
+
if (value.length > 4096) atlasFailure('JSON string budget exceeded.');
|
|
121
|
+
addBytes(encoder.encode(JSON.stringify(value)).length);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (typeof value !== 'object') atlasFailure('plain JSON data is required.');
|
|
125
|
+
const array = Array.isArray(value),
|
|
126
|
+
proto = Object.getPrototypeOf(value);
|
|
127
|
+
if (array ? proto !== Array.prototype : proto !== null && proto !== Object.prototype)
|
|
128
|
+
atlasFailure('plain JSON prototypes are required.');
|
|
129
|
+
const length = array ? Object.getOwnPropertyDescriptor(value, 'length')?.value : undefined;
|
|
130
|
+
if (array && (!Number.isSafeInteger(length) || length > 1024))
|
|
131
|
+
atlasFailure('JSON array budget exceeded.');
|
|
132
|
+
const keys = Reflect.ownKeys(value);
|
|
133
|
+
if (keys.length > 1024 || (array && keys.length !== length + 1))
|
|
134
|
+
atlasFailure('dense bounded JSON fields are required.');
|
|
135
|
+
addBytes(2 + Math.max(0, keys.length - (array ? 1 : 0) - 1));
|
|
136
|
+
for (const key of keys) {
|
|
137
|
+
if (array && key === 'length') continue;
|
|
138
|
+
if (typeof key !== 'string' || ['__proto__', 'prototype', 'constructor'].includes(key))
|
|
139
|
+
atlasFailure('forbidden JSON field.');
|
|
140
|
+
if (key.length > 4096) atlasFailure('JSON field name budget exceeded.');
|
|
141
|
+
if (!array) addBytes(encoder.encode(JSON.stringify(key)).length + 1);
|
|
142
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
|
143
|
+
if (!descriptor.enumerable || !('value' in descriptor))
|
|
144
|
+
atlasFailure('JSON accessors/hidden fields are unsupported.');
|
|
145
|
+
if (array && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= length))
|
|
146
|
+
atlasFailure('invalid JSON array key.');
|
|
147
|
+
visit(descriptor.value, depth + 1);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
visit(input, 0);
|
|
151
|
+
}
|
|
152
|
+
function record(value: unknown, fields: string[], label: string): Record<string, unknown> {
|
|
153
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
154
|
+
atlasFailure(`${label} must be a JSON object.`);
|
|
155
|
+
const r = value as Record<string, unknown>;
|
|
156
|
+
if (Object.keys(r).some((k) => !fields.includes(k)))
|
|
157
|
+
atlasFailure(`${label} has an unknown field.`);
|
|
158
|
+
return r;
|
|
159
|
+
}
|
|
160
|
+
function integer(value: unknown, min: number, max: number, label: string): number {
|
|
161
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min || value > max)
|
|
162
|
+
atlasFailure(`${label} must be an integer ${min}..${max}.`);
|
|
163
|
+
return value === 0 ? 0 : value;
|
|
164
|
+
}
|
|
165
|
+
function hash(value: unknown, label: string): `sha256:${string}` {
|
|
166
|
+
if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value))
|
|
167
|
+
atlasFailure(`${label} must be an exact SHA-256 hash.`);
|
|
168
|
+
return value as `sha256:${string}`;
|
|
169
|
+
}
|
|
170
|
+
function rect(value: unknown, label: string): AtlasRectangleV1 {
|
|
171
|
+
const r = record(value, ['x', 'y', 'width', 'height'], label);
|
|
172
|
+
return {
|
|
173
|
+
x: integer(r['x'], 0, 4096, `${label}.x`),
|
|
174
|
+
y: integer(r['y'], 0, 4096, `${label}.y`),
|
|
175
|
+
width: integer(r['width'], 1, 4096, `${label}.width`),
|
|
176
|
+
height: integer(r['height'], 1, 4096, `${label}.height`),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export function atlasAllocationV1(
|
|
180
|
+
region: Pick<CanonicalTextureAtlasRegionV1, 'content' | 'gutter'>,
|
|
181
|
+
): AtlasRectangleV1 {
|
|
182
|
+
const { content: c, gutter: g } = region;
|
|
183
|
+
return { x: c.x - g, y: c.y - g, width: c.width + 2 * g, height: c.height + 2 * g };
|
|
184
|
+
}
|
|
185
|
+
export function assertAtlasCropV1(
|
|
186
|
+
crop: AtlasRectangleV1,
|
|
187
|
+
content: AtlasRectangleV1,
|
|
188
|
+
width: number,
|
|
189
|
+
height: number,
|
|
190
|
+
): void {
|
|
191
|
+
if (crop.x + crop.width > width || crop.y + crop.height > height)
|
|
192
|
+
atlasFailure('source crop is out of bounds.');
|
|
193
|
+
if (crop.width !== content.width || crop.height !== content.height)
|
|
194
|
+
atlasFailure('source crop/content dimensions must match; resampling is unsupported.');
|
|
195
|
+
}
|
|
196
|
+
export function atlasSourceIdentityV1(source: CanonicalTextureAtlasSourceV1): string {
|
|
197
|
+
return source.kind === 'procedural'
|
|
198
|
+
? `procedural:${hashProceduralTextureSpecV3(source.spec)}`
|
|
199
|
+
: `resource:${source.pin.resourceId}:${source.pin.revisionId}:${source.resourceId}`;
|
|
200
|
+
}
|
|
201
|
+
export function canonicalizeTextureAtlasSpecV1(input: unknown): CanonicalTextureAtlasSpecV1 {
|
|
202
|
+
preflight(input);
|
|
203
|
+
const r = record(
|
|
204
|
+
input,
|
|
205
|
+
['schemaVersion', 'width', 'height', 'usage', 'sampler', 'regions'],
|
|
206
|
+
'atlas',
|
|
207
|
+
);
|
|
208
|
+
if (r['schemaVersion'] !== 1) atlasFailure('schemaVersion must be 1.');
|
|
209
|
+
const width = integer(r['width'], 4, TEXTURE_ATLAS_LIMITS.edge, 'width'),
|
|
210
|
+
height = integer(r['height'], 4, TEXTURE_ATLAS_LIMITS.edge, 'height');
|
|
211
|
+
if (!(TEXTURE_USAGES as readonly unknown[]).includes(r['usage']))
|
|
212
|
+
atlasFailure('usage must be a texture usage.');
|
|
213
|
+
const usage = r['usage'] as TextureUsage;
|
|
214
|
+
if (
|
|
215
|
+
!Array.isArray(r['regions']) ||
|
|
216
|
+
!r['regions'].length ||
|
|
217
|
+
r['regions'].length > TEXTURE_ATLAS_LIMITS.regions
|
|
218
|
+
)
|
|
219
|
+
atlasFailure('regions must contain 1..64 entries.');
|
|
220
|
+
const regions = r['regions']
|
|
221
|
+
.map((value, i): CanonicalTextureAtlasRegionV1 => {
|
|
222
|
+
const q = record(
|
|
223
|
+
value,
|
|
224
|
+
['name', 'content', 'gutter', 'edgeMode', 'source', 'crop'],
|
|
225
|
+
`regions[${i}]`,
|
|
226
|
+
);
|
|
227
|
+
if (typeof q['name'] !== 'string' || !/^[a-z][a-z0-9_-]{0,79}$/.test(q['name']))
|
|
228
|
+
atlasFailure('region name must be a stable lowercase identifier.');
|
|
229
|
+
const content = rect(q['content'], 'content'),
|
|
230
|
+
gutter = q['gutter'] === undefined ? 2 : integer(q['gutter'], 0, 32, 'gutter');
|
|
231
|
+
const edgeMode = q['edgeMode'] === undefined ? 'copy-edge' : q['edgeMode'];
|
|
232
|
+
if (!['copy-edge', 'mask-rgb-dilate'].includes(edgeMode as string))
|
|
233
|
+
atlasFailure('unsupported edgeMode.');
|
|
234
|
+
if (edgeMode === 'mask-rgb-dilate' && usage !== 'albedo' && usage !== 'emissive')
|
|
235
|
+
atlasFailure('mask RGB dilation requires color usage.');
|
|
236
|
+
const s = record(q['source'], ['kind', 'spec', 'resourceId', 'pin', 'expected'], 'source');
|
|
237
|
+
let source: CanonicalTextureAtlasSourceV1;
|
|
238
|
+
let crop = q['crop'] === undefined ? undefined : rect(q['crop'], 'crop');
|
|
239
|
+
if (s['kind'] === 'procedural') {
|
|
240
|
+
record(s, ['kind', 'spec'], 'procedural source');
|
|
241
|
+
const admission = admitProceduralTextureSpecV3(s['spec']);
|
|
242
|
+
if (admission.spec.usage !== usage)
|
|
243
|
+
atlasFailure('source usage does not match atlas usage.');
|
|
244
|
+
source = { kind: 'procedural', spec: admission.spec };
|
|
245
|
+
crop ??= { x: 0, y: 0, width: admission.spec.width, height: admission.spec.height };
|
|
246
|
+
assertAtlasCropV1(crop, content, admission.spec.width, admission.spec.height);
|
|
247
|
+
} else if (s['kind'] === 'resource') {
|
|
248
|
+
record(s, ['kind', 'resourceId', 'pin', 'expected'], 'resource source');
|
|
249
|
+
if (
|
|
250
|
+
typeof s['resourceId'] !== 'string' ||
|
|
251
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,199}$/.test(s['resourceId'])
|
|
252
|
+
)
|
|
253
|
+
atlasFailure('source resourceId must be an exact returned map ID.');
|
|
254
|
+
const p = record(s['pin'], ['resourceId', 'revisionId', 'sha256'], 'source pin');
|
|
255
|
+
if (typeof p['resourceId'] !== 'string' || !/^[a-z][a-z0-9_-]{0,79}$/.test(p['resourceId']))
|
|
256
|
+
atlasFailure('pin resourceId must be a material ID.');
|
|
257
|
+
const revisionId = hash(p['revisionId'], 'pin revisionId'),
|
|
258
|
+
sha256 = hash(p['sha256'], 'pin sha256');
|
|
259
|
+
if (revisionId !== sha256) atlasFailure('source pin revision/hash mismatch.');
|
|
260
|
+
let expected: AtlasResourceExpectationV1 | undefined;
|
|
261
|
+
if (s['expected'] !== undefined) {
|
|
262
|
+
const e = record(
|
|
263
|
+
s['expected'],
|
|
264
|
+
['width', 'height', 'imageSha256', 'decodedPixelHash'],
|
|
265
|
+
'source expected',
|
|
266
|
+
);
|
|
267
|
+
expected = {
|
|
268
|
+
...(e['width'] === undefined
|
|
269
|
+
? {}
|
|
270
|
+
: { width: integer(e['width'], 1, 4096, 'expected.width') }),
|
|
271
|
+
...(e['height'] === undefined
|
|
272
|
+
? {}
|
|
273
|
+
: { height: integer(e['height'], 1, 4096, 'expected.height') }),
|
|
274
|
+
...(e['imageSha256'] === undefined
|
|
275
|
+
? {}
|
|
276
|
+
: { imageSha256: hash(e['imageSha256'], 'expected.imageSha256') }),
|
|
277
|
+
...(e['decodedPixelHash'] === undefined
|
|
278
|
+
? {}
|
|
279
|
+
: { decodedPixelHash: hash(e['decodedPixelHash'], 'expected.decodedPixelHash') }),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
source = {
|
|
283
|
+
kind: 'resource',
|
|
284
|
+
resourceId: s['resourceId'],
|
|
285
|
+
pin: { resourceId: p['resourceId'], revisionId, sha256 },
|
|
286
|
+
...(expected ? { expected } : {}),
|
|
287
|
+
};
|
|
288
|
+
if (crop && (crop.width !== content.width || crop.height !== content.height))
|
|
289
|
+
atlasFailure('source crop/content dimensions must match; resampling is unsupported.');
|
|
290
|
+
} else atlasFailure('source kind must be procedural or pinned resource.');
|
|
291
|
+
const result = {
|
|
292
|
+
name: q['name'],
|
|
293
|
+
content,
|
|
294
|
+
gutter,
|
|
295
|
+
edgeMode: edgeMode as AtlasEdgeModeV1,
|
|
296
|
+
source,
|
|
297
|
+
...(crop ? { crop } : {}),
|
|
298
|
+
};
|
|
299
|
+
const allocation = atlasAllocationV1(result);
|
|
300
|
+
if (
|
|
301
|
+
allocation.x < 0 ||
|
|
302
|
+
allocation.y < 0 ||
|
|
303
|
+
allocation.x + allocation.width > width ||
|
|
304
|
+
allocation.y + allocation.height > height
|
|
305
|
+
)
|
|
306
|
+
atlasFailure('padded allocation is out of bounds.');
|
|
307
|
+
return result;
|
|
308
|
+
})
|
|
309
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
310
|
+
if (new Set(regions.map((v) => v.name)).size !== regions.length)
|
|
311
|
+
atlasFailure('duplicate region name.');
|
|
312
|
+
for (let i = 0; i < regions.length; i++)
|
|
313
|
+
for (let j = i + 1; j < regions.length; j++) {
|
|
314
|
+
const a = atlasAllocationV1(regions[i]!),
|
|
315
|
+
b = atlasAllocationV1(regions[j]!);
|
|
316
|
+
if (
|
|
317
|
+
a.x < b.x + b.width &&
|
|
318
|
+
b.x < a.x + a.width &&
|
|
319
|
+
a.y < b.y + b.height &&
|
|
320
|
+
b.y < a.y + a.height
|
|
321
|
+
)
|
|
322
|
+
atlasFailure(`padded allocations overlap: ${regions[i]!.name}, ${regions[j]!.name}.`);
|
|
323
|
+
}
|
|
324
|
+
if (
|
|
325
|
+
new Set(regions.map((v) => atlasSourceIdentityV1(v.source))).size > TEXTURE_ATLAS_LIMITS.sources
|
|
326
|
+
)
|
|
327
|
+
atlasFailure('distinct source budget exceeds 16.');
|
|
328
|
+
let sampler: CanonicalPortableSamplerV1;
|
|
329
|
+
try {
|
|
330
|
+
sampler = canonicalizePortableSamplerV1(
|
|
331
|
+
r['sampler'] === undefined
|
|
332
|
+
? {
|
|
333
|
+
minFilter: 'linear-mipmap-linear',
|
|
334
|
+
magFilter: 'linear',
|
|
335
|
+
wrapS: 'clamp-to-edge',
|
|
336
|
+
wrapT: 'clamp-to-edge',
|
|
337
|
+
}
|
|
338
|
+
: r['sampler'],
|
|
339
|
+
);
|
|
340
|
+
} catch (e) {
|
|
341
|
+
atlasFailure(`sampler: ${(e as Error).message}`);
|
|
342
|
+
}
|
|
343
|
+
const spec: CanonicalTextureAtlasSpecV1 = {
|
|
344
|
+
schemaVersion: 1,
|
|
345
|
+
width,
|
|
346
|
+
height,
|
|
347
|
+
usage,
|
|
348
|
+
sampler,
|
|
349
|
+
regions,
|
|
350
|
+
};
|
|
351
|
+
if (new TextEncoder().encode(JSON.stringify(spec)).length > TEXTURE_ATLAS_LIMITS.jsonBytes)
|
|
352
|
+
atlasFailure('canonical JSON byte budget exceeded.');
|
|
353
|
+
return spec;
|
|
354
|
+
}
|
|
355
|
+
export function canonicalTextureAtlasJsonV1(input: unknown): string {
|
|
356
|
+
return JSON.stringify(canonicalizeTextureAtlasSpecV1(input));
|
|
357
|
+
}
|
|
358
|
+
export function hashTextureAtlasSpecV1(input: unknown): `sha256:${string}` {
|
|
359
|
+
return `sha256:${sha256Hex(`${TEXTURE_ATLAS_COMPILER_ID}\n${canonicalTextureAtlasJsonV1(input)}`)}`;
|
|
360
|
+
}
|
|
361
|
+
/** Internal identity helper for bounded atlas metadata; stable across object property order. */
|
|
362
|
+
export function canonicalAtlasMetadataJsonV1(input: unknown): string {
|
|
363
|
+
preflight(input);
|
|
364
|
+
const sort = (value: unknown): unknown =>
|
|
365
|
+
Array.isArray(value)
|
|
366
|
+
? value.map(sort)
|
|
367
|
+
: value !== null && typeof value === 'object'
|
|
368
|
+
? Object.fromEntries(
|
|
369
|
+
Object.entries(value)
|
|
370
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
371
|
+
.map(([k, v]) => [k, sort(v)]),
|
|
372
|
+
)
|
|
373
|
+
: value;
|
|
374
|
+
return JSON.stringify(sort(input));
|
|
375
|
+
}
|
|
376
|
+
export function atlasLayoutHashV1(spec: CanonicalTextureAtlasSpecV1): `sha256:${string}` {
|
|
377
|
+
return `sha256:${sha256Hex(`kiln.atlas-layout.v1\n${canonicalAtlasMetadataJsonV1({ width: spec.width, height: spec.height, origin: 'top-left', regions: spec.regions.map((r) => ({ name: r.name, content: r.content, allocation: atlasAllocationV1(r) })) })}`)}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Pure persisted-manifest reader. Actual PNG/RGBA and recompilation belong to the host. */
|
|
381
|
+
export function validateTextureAtlasManifestV1(
|
|
382
|
+
input: unknown,
|
|
383
|
+
providedSpec: CanonicalTextureAtlasSpecV1,
|
|
384
|
+
): TextureAtlasManifestV1 {
|
|
385
|
+
preflight(input);
|
|
386
|
+
const spec = canonicalizeTextureAtlasSpecV1(providedSpec);
|
|
387
|
+
const r = record(
|
|
388
|
+
input,
|
|
389
|
+
[
|
|
390
|
+
'version',
|
|
391
|
+
'compilerId',
|
|
392
|
+
'width',
|
|
393
|
+
'height',
|
|
394
|
+
'usage',
|
|
395
|
+
'origin',
|
|
396
|
+
'sampler',
|
|
397
|
+
'specHash',
|
|
398
|
+
'layoutHash',
|
|
399
|
+
'decodedPixelHash',
|
|
400
|
+
'atlasPixelHash',
|
|
401
|
+
'sources',
|
|
402
|
+
'regions',
|
|
403
|
+
'manifestHash',
|
|
404
|
+
],
|
|
405
|
+
'manifest',
|
|
406
|
+
);
|
|
407
|
+
if (
|
|
408
|
+
r['version'] !== 'kiln.atlas-manifest.v1' ||
|
|
409
|
+
r['compilerId'] !== TEXTURE_ATLAS_COMPILER_ID ||
|
|
410
|
+
r['width'] !== spec.width ||
|
|
411
|
+
r['height'] !== spec.height ||
|
|
412
|
+
r['usage'] !== spec.usage ||
|
|
413
|
+
r['origin'] !== 'top-left'
|
|
414
|
+
)
|
|
415
|
+
atlasFailure('manifest version/compiler/dimensions/usage/origin mismatch.');
|
|
416
|
+
if (
|
|
417
|
+
canonicalAtlasMetadataJsonV1(r['sampler']) !== canonicalAtlasMetadataJsonV1(spec.sampler) ||
|
|
418
|
+
r['specHash'] !== hashTextureAtlasSpecV1(spec) ||
|
|
419
|
+
r['layoutHash'] !== atlasLayoutHashV1(spec)
|
|
420
|
+
)
|
|
421
|
+
atlasFailure('manifest sampler/spec/layout identity mismatch.');
|
|
422
|
+
hash(r['decodedPixelHash'], 'manifest decodedPixelHash');
|
|
423
|
+
hash(r['atlasPixelHash'], 'manifest atlasPixelHash');
|
|
424
|
+
const expectedSources = new Map(
|
|
425
|
+
spec.regions.map((region) => [atlasSourceIdentityV1(region.source), region.source]),
|
|
426
|
+
);
|
|
427
|
+
if (
|
|
428
|
+
!Array.isArray(r['sources']) ||
|
|
429
|
+
r['sources'].length !== expectedSources.size ||
|
|
430
|
+
!Array.isArray(r['regions']) ||
|
|
431
|
+
r['regions'].length !== spec.regions.length
|
|
432
|
+
)
|
|
433
|
+
atlasFailure('manifest source/region inventory mismatch.');
|
|
434
|
+
const dimensions = new Map<string, { width: number; height: number }>();
|
|
435
|
+
for (const sourceValue of r['sources']) {
|
|
436
|
+
const s = record(
|
|
437
|
+
sourceValue,
|
|
438
|
+
[
|
|
439
|
+
'id',
|
|
440
|
+
'kind',
|
|
441
|
+
'width',
|
|
442
|
+
'height',
|
|
443
|
+
'decodedPixelHash',
|
|
444
|
+
'recipeHash',
|
|
445
|
+
'compilerId',
|
|
446
|
+
'descriptor',
|
|
447
|
+
],
|
|
448
|
+
'manifest source',
|
|
449
|
+
);
|
|
450
|
+
if (typeof s['id'] !== 'string' || dimensions.has(s['id']))
|
|
451
|
+
atlasFailure('duplicate/invalid manifest source identity.');
|
|
452
|
+
const source = expectedSources.get(s['id']);
|
|
453
|
+
if (!source || s['kind'] !== source.kind) atlasFailure('manifest source identity mismatch.');
|
|
454
|
+
const width = integer(s['width'], 1, 4096, 'manifest source width'),
|
|
455
|
+
height = integer(s['height'], 1, 4096, 'manifest source height');
|
|
456
|
+
hash(s['decodedPixelHash'], 'manifest source decodedPixelHash');
|
|
457
|
+
if (source.kind === 'procedural') {
|
|
458
|
+
record(
|
|
459
|
+
s,
|
|
460
|
+
['id', 'kind', 'width', 'height', 'decodedPixelHash', 'recipeHash', 'compilerId'],
|
|
461
|
+
'manifest procedural source',
|
|
462
|
+
);
|
|
463
|
+
if (
|
|
464
|
+
width !== source.spec.width ||
|
|
465
|
+
height !== source.spec.height ||
|
|
466
|
+
s['recipeHash'] !== hashProceduralTextureSpecV3(source.spec) ||
|
|
467
|
+
s['compilerId'] !== 'kiln.procedural-rgba.v3.1'
|
|
468
|
+
)
|
|
469
|
+
atlasFailure('manifest procedural source identity mismatch.');
|
|
470
|
+
} else {
|
|
471
|
+
record(
|
|
472
|
+
s,
|
|
473
|
+
['id', 'kind', 'width', 'height', 'decodedPixelHash', 'descriptor'],
|
|
474
|
+
'manifest resource source',
|
|
475
|
+
);
|
|
476
|
+
const d = record(
|
|
477
|
+
s['descriptor'],
|
|
478
|
+
[
|
|
479
|
+
'materialId',
|
|
480
|
+
'revisionId',
|
|
481
|
+
'resourceId',
|
|
482
|
+
'slot',
|
|
483
|
+
'usage',
|
|
484
|
+
'colorSpace',
|
|
485
|
+
'width',
|
|
486
|
+
'height',
|
|
487
|
+
'encodedByteLength',
|
|
488
|
+
'imageSha256',
|
|
489
|
+
'decodedPixelHash',
|
|
490
|
+
],
|
|
491
|
+
'manifest source descriptor',
|
|
492
|
+
);
|
|
493
|
+
const slots: Record<string, string> = {
|
|
494
|
+
baseColor: 'albedo',
|
|
495
|
+
normal: 'normal',
|
|
496
|
+
metallicRoughness: 'metallicRoughness',
|
|
497
|
+
emissive: 'emissive',
|
|
498
|
+
occlusion: 'occlusion',
|
|
499
|
+
};
|
|
500
|
+
if (
|
|
501
|
+
d['materialId'] !== source.pin.resourceId ||
|
|
502
|
+
d['revisionId'] !== source.pin.revisionId ||
|
|
503
|
+
d['resourceId'] !== source.resourceId ||
|
|
504
|
+
d['width'] !== width ||
|
|
505
|
+
d['height'] !== height ||
|
|
506
|
+
d['usage'] !== spec.usage ||
|
|
507
|
+
typeof d['slot'] !== 'string' ||
|
|
508
|
+
slots[d['slot']] !== spec.usage ||
|
|
509
|
+
d['colorSpace'] !==
|
|
510
|
+
(spec.usage === 'albedo' || spec.usage === 'emissive' ? 'srgb' : 'linear')
|
|
511
|
+
)
|
|
512
|
+
atlasFailure('manifest pinned resource descriptor mismatch.');
|
|
513
|
+
hash(d['imageSha256'], 'manifest source imageSha256');
|
|
514
|
+
integer(
|
|
515
|
+
d['encodedByteLength'],
|
|
516
|
+
1,
|
|
517
|
+
TEXTURE_ATLAS_LIMITS.encodedSourceBytes,
|
|
518
|
+
'manifest encodedByteLength',
|
|
519
|
+
);
|
|
520
|
+
if (d['decodedPixelHash'] !== undefined && d['decodedPixelHash'] !== s['decodedPixelHash'])
|
|
521
|
+
atlasFailure('manifest source decodedPixelHash mismatch.');
|
|
522
|
+
for (const key of ['width', 'height', 'imageSha256', 'decodedPixelHash'] as const) {
|
|
523
|
+
const expected = source.expected?.[key];
|
|
524
|
+
if (expected !== undefined && expected !== (key === 'decodedPixelHash' ? s[key] : d[key]))
|
|
525
|
+
atlasFailure('manifest source expectation mismatch.');
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
dimensions.set(s['id'], { width, height });
|
|
529
|
+
}
|
|
530
|
+
for (let i = 0; i < spec.regions.length; i++) {
|
|
531
|
+
const region = spec.regions[i]!,
|
|
532
|
+
v = record(
|
|
533
|
+
r['regions'][i],
|
|
534
|
+
['name', 'content', 'allocation', 'crop', 'sourceId'],
|
|
535
|
+
'manifest region',
|
|
536
|
+
);
|
|
537
|
+
const sourceId = atlasSourceIdentityV1(region.source),
|
|
538
|
+
size = dimensions.get(sourceId)!;
|
|
539
|
+
const crop = region.crop ?? { x: 0, y: 0, width: size.width, height: size.height };
|
|
540
|
+
assertAtlasCropV1(crop, region.content, size.width, size.height);
|
|
541
|
+
if (
|
|
542
|
+
v['name'] !== region.name ||
|
|
543
|
+
v['sourceId'] !== sourceId ||
|
|
544
|
+
canonicalAtlasMetadataJsonV1(v['content']) !== canonicalAtlasMetadataJsonV1(region.content) ||
|
|
545
|
+
canonicalAtlasMetadataJsonV1(v['allocation']) !==
|
|
546
|
+
canonicalAtlasMetadataJsonV1(atlasAllocationV1(region)) ||
|
|
547
|
+
canonicalAtlasMetadataJsonV1(v['crop']) !== canonicalAtlasMetadataJsonV1(crop)
|
|
548
|
+
)
|
|
549
|
+
atlasFailure('manifest named region/crop/allocation identity mismatch.');
|
|
550
|
+
}
|
|
551
|
+
const { manifestHash, ...payload } = r;
|
|
552
|
+
if (
|
|
553
|
+
manifestHash !==
|
|
554
|
+
`sha256:${sha256Hex(`kiln.atlas-manifest.v1\n${canonicalAtlasMetadataJsonV1(payload)}`)}`
|
|
555
|
+
)
|
|
556
|
+
atlasFailure('manifest hash mismatch.');
|
|
557
|
+
return structuredClone(input) as TextureAtlasManifestV1;
|
|
558
|
+
}
|