@instruktlabs/kiln 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +74 -0
  4. package/README.md +7 -0
  5. package/dist/agent-run.mjs +6392 -1477
  6. package/dist/build.json +23 -23
  7. package/dist/cli.mjs +8453 -3407
  8. package/dist/evaluator-worker.mjs +16608 -12387
  9. package/dist/mcp-engine.mjs +6746 -1725
  10. package/dist/mcp-server.mjs +4 -4
  11. package/dist/viewer/app.js +203 -199
  12. package/dist/viewer/chat.html +209 -205
  13. package/dist/viewer/style.css +10 -0
  14. package/docs/atlas-provenance.md +23 -0
  15. package/docs/community-exporter.md +27 -0
  16. package/docs/export-profiles.md +20 -0
  17. package/docs/material-library.md +9 -5
  18. package/docs/procedural-surfaces-v3.md +276 -0
  19. package/docs/projects-and-live-review.md +1 -1
  20. package/docs/rendering.md +41 -0
  21. package/docs/sdk.md +53 -0
  22. package/docs/tools-operations.md +2 -2
  23. package/docs/tools.md +3 -3
  24. package/lib/asset-materials-node.d.ts +3 -3
  25. package/lib/asset-materials-node.js +29 -33
  26. package/lib/assets-node.d.ts +1 -1
  27. package/lib/assets-node.js +11 -4
  28. package/lib/assets.d.ts +4 -2
  29. package/lib/assets.js +61 -16
  30. package/lib/atlas-provenance.d.ts +86 -0
  31. package/lib/atlas-provenance.js +448 -0
  32. package/lib/character.d.ts +11 -2
  33. package/lib/character.js +90 -2
  34. package/lib/community-exporter.d.ts +3 -2
  35. package/lib/community-exporter.js +139 -3
  36. package/lib/discovery/helper-contracts.js +161 -7
  37. package/lib/discovery/helper-specs.js +49 -4
  38. package/lib/discovery/shapes.js +20 -7
  39. package/lib/engine-identity.d.ts +1 -1
  40. package/lib/engine-identity.js +1 -1
  41. package/lib/evaluator/authoring-diagnostic.d.ts +1 -1
  42. package/lib/evaluator/authoring-diagnostic.js +12 -0
  43. package/lib/evaluator/protocol.d.ts +2 -2
  44. package/lib/evaluator/protocol.js +16 -6
  45. package/lib/evaluator/subprocess.js +2 -2
  46. package/lib/exporter-node.js +9 -1
  47. package/lib/geometry-budget.d.ts +7 -0
  48. package/lib/geometry-budget.js +8 -0
  49. package/lib/gltf-texture-binding.d.ts +6 -0
  50. package/lib/gltf-texture-binding.js +54 -0
  51. package/lib/material-authoring-node.d.ts +6 -0
  52. package/lib/material-authoring-node.js +49 -0
  53. package/lib/material-authoring-schema.d.ts +871 -0
  54. package/lib/material-authoring-schema.js +310 -0
  55. package/lib/material-export.d.ts +5 -0
  56. package/lib/material-export.js +21 -0
  57. package/lib/material-library-node.d.ts +34 -8
  58. package/lib/material-library-node.js +691 -11
  59. package/lib/material-library.d.ts +3704 -4
  60. package/lib/material-library.js +272 -0
  61. package/lib/mcp-core.d.ts +1 -1
  62. package/lib/pinned-material-map.d.ts +33 -0
  63. package/lib/pinned-material-map.js +1 -0
  64. package/lib/portable-material-runtime.d.ts +5 -0
  65. package/lib/portable-material-runtime.js +160 -0
  66. package/lib/portable-sampler-runtime.d.ts +4 -0
  67. package/lib/portable-sampler-runtime.js +26 -0
  68. package/lib/portable-sampler.d.ts +11 -0
  69. package/lib/portable-sampler.js +42 -0
  70. package/lib/primitives.d.ts +15 -3
  71. package/lib/primitives.js +24 -4
  72. package/lib/procedural-material-v2.d.ts +2 -0
  73. package/lib/procedural-material-v2.js +2 -1
  74. package/lib/procedural-material-v3.d.ts +72 -0
  75. package/lib/procedural-material-v3.js +162 -0
  76. package/lib/procedural-texture-v2-compiler.d.ts +31 -0
  77. package/lib/procedural-texture-v2-compiler.js +172 -0
  78. package/lib/procedural-texture-v3-contract.d.ts +125 -0
  79. package/lib/procedural-texture-v3-contract.js +333 -0
  80. package/lib/procedural-texture-v3.d.ts +12 -0
  81. package/lib/procedural-texture-v3.js +165 -0
  82. package/lib/procedural-texture.d.ts +9 -31
  83. package/lib/procedural-texture.js +43 -170
  84. package/lib/project-bundle-node.d.ts +1 -1
  85. package/lib/project-bundle-node.js +70 -13
  86. package/lib/project-bundle.d.ts +91 -3
  87. package/lib/project-bundle.js +48 -7
  88. package/lib/qa/atlas.d.ts +21 -0
  89. package/lib/qa/atlas.js +474 -0
  90. package/lib/qa/index.d.ts +1 -0
  91. package/lib/qa/index.js +1 -0
  92. package/lib/qa/material.js +2 -0
  93. package/lib/qa/skin-integrity.d.ts +18 -0
  94. package/lib/qa/skin-integrity.js +221 -0
  95. package/lib/render-service-host.js +4 -7
  96. package/lib/render.d.ts +3 -3
  97. package/lib/render.js +108 -16
  98. package/lib/skin-binding.d.ts +20 -0
  99. package/lib/skin-binding.js +144 -0
  100. package/lib/texture-atlas-contract.d.ts +97 -0
  101. package/lib/texture-atlas-contract.js +414 -0
  102. package/lib/texture-atlas-pixels.d.ts +73 -0
  103. package/lib/texture-atlas-pixels.js +290 -0
  104. package/lib/texture-atlas.d.ts +10 -0
  105. package/lib/texture-atlas.js +143 -0
  106. package/lib/texture-bake.d.ts +3 -0
  107. package/lib/texture-bake.js +66 -11
  108. package/lib/texture-resolver.d.ts +22 -0
  109. package/lib/texture-resolver.js +24 -0
  110. package/lib/tools/materials.js +8 -7
  111. package/lib/tools/registry.js +5 -16
  112. package/lib/tools/review.js +1 -1
  113. package/lib/uv-data.d.ts +32 -0
  114. package/lib/uv-data.js +172 -0
  115. package/lib/uv-shapes.js +3 -0
  116. package/lib/uv.d.ts +9 -3
  117. package/lib/uv.js +53 -82
  118. package/lib/views/camera.d.ts +3 -3
  119. package/lib/views/camera.js +32 -28
  120. package/lib/views/face-policy.d.ts +9 -0
  121. package/lib/views/face-policy.js +16 -0
  122. package/lib/views/glb.js +218 -24
  123. package/lib/views/pose.d.ts +7 -1
  124. package/lib/views/pose.js +63 -12
  125. package/lib/views/raster.d.ts +6 -2
  126. package/lib/views/raster.js +49 -37
  127. package/lib/views/scene-copy.d.ts +6 -0
  128. package/lib/views/scene-copy.js +73 -0
  129. package/lib/views/scene-raster.js +2 -1
  130. package/lib/views/triangle-coverage.d.ts +8 -0
  131. package/lib/views/triangle-coverage.js +58 -0
  132. package/lib/workspace-node.js +7 -3
  133. package/lib/workspace.d.ts +2 -2
  134. package/package.json +3 -1
  135. package/plugin.json +1 -1
  136. package/plugins/kiln-engine/.claude-plugin/plugin.json +1 -1
  137. package/plugins/kiln-engine/README.md +3 -3
  138. package/plugins/kiln-engine/package-provenance.json +6 -6
  139. package/plugins/kiln-engine/plugin.json +1 -1
  140. package/plugins/kiln-engine/runtime.json +1 -1
  141. package/skills/kiln-author-asset/references/geometry-recipes.md +124 -0
  142. package/skills/kiln-author-asset/references/projects-and-materials.md +32 -2
  143. package/skills/kiln-compose-scene/references/runtime-scenes.md +19 -0
  144. package/skills/kiln-qa-asset/references/integration-checks.md +37 -2
  145. package/src/asset-materials-node.ts +38 -45
  146. package/src/assets-node.ts +22 -5
  147. package/src/assets.ts +61 -20
  148. package/src/atlas-provenance.ts +522 -0
  149. package/src/character.ts +107 -2
  150. package/src/community-exporter.ts +143 -3
  151. package/src/discovery/helper-contracts.ts +188 -7
  152. package/src/discovery/helper-specs.ts +69 -4
  153. package/src/discovery/shapes.ts +25 -9
  154. package/src/engine-identity.ts +1 -1
  155. package/src/evaluator/authoring-diagnostic.ts +19 -1
  156. package/src/evaluator/protocol.ts +19 -9
  157. package/src/evaluator/subprocess.ts +2 -2
  158. package/src/exporter-node.ts +12 -1
  159. package/src/generated/mcp-manifest.json +3 -3
  160. package/src/geometry-budget.ts +8 -0
  161. package/src/gltf-texture-binding.ts +60 -0
  162. package/src/material-authoring-node.ts +68 -0
  163. package/src/material-authoring-schema.ts +343 -0
  164. package/src/material-export.ts +25 -0
  165. package/src/material-library-node.ts +840 -16
  166. package/src/material-library.ts +348 -5
  167. package/src/pinned-material-map.ts +43 -0
  168. package/src/portable-material-runtime.ts +200 -0
  169. package/src/portable-sampler-runtime.ts +40 -0
  170. package/src/portable-sampler.ts +54 -0
  171. package/src/primitives.ts +98 -4
  172. package/src/procedural-material-v2.ts +2 -1
  173. package/src/procedural-material-v3.ts +224 -0
  174. package/src/procedural-texture-v2-compiler.ts +234 -0
  175. package/src/procedural-texture-v3-contract.ts +437 -0
  176. package/src/procedural-texture-v3.ts +220 -0
  177. package/src/procedural-texture.ts +61 -229
  178. package/src/project-bundle-node.ts +106 -14
  179. package/src/project-bundle.ts +61 -18
  180. package/src/project-cli.ts +29 -17
  181. package/src/qa/atlas.ts +597 -0
  182. package/src/qa/index.ts +1 -0
  183. package/src/qa/material.ts +2 -0
  184. package/src/qa/skin-integrity.ts +291 -0
  185. package/src/render-service-host.ts +4 -6
  186. package/src/render.ts +133 -15
  187. package/src/skin-binding.ts +225 -0
  188. package/src/texture-atlas-contract.ts +558 -0
  189. package/src/texture-atlas-pixels.ts +456 -0
  190. package/src/texture-atlas.ts +185 -0
  191. package/src/texture-bake.ts +88 -10
  192. package/src/texture-resolver.ts +55 -0
  193. package/src/tools/materials.ts +15 -11
  194. package/src/tools/registry.ts +5 -15
  195. package/src/tools/review.ts +1 -1
  196. package/src/uv-data.ts +238 -0
  197. package/src/uv-shapes.ts +3 -0
  198. package/src/uv.ts +73 -128
  199. package/src/viewer/app.ts +37 -0
  200. package/src/viewer/live-panel.ts +57 -1
  201. package/src/viewer/material-preview-panel.ts +140 -0
  202. package/src/viewer/material-preview.ts +138 -0
  203. package/src/viewer/materials-panel.ts +34 -5
  204. package/src/viewer/navigation.ts +2 -0
  205. package/src/views/camera.ts +37 -40
  206. package/src/views/face-policy.ts +20 -0
  207. package/src/views/glb.ts +294 -17
  208. package/src/views/pose.ts +71 -13
  209. package/src/views/raster.ts +73 -46
  210. package/src/views/scene-copy.ts +86 -0
  211. package/src/views/scene-raster.ts +2 -1
  212. package/src/views/triangle-coverage.ts +78 -0
  213. package/src/workspace-node.ts +11 -3
  214. package/src/workspace.ts +2 -2
@@ -5,16 +5,13 @@ import { FileWorkspace, localWorkspaceRoot } from './workspace-node';
5
5
  import { MAX_PROJECT_BYTES } from './projects';
6
6
  import { createKilnProjectDef } from './tools/projects';
7
7
  import { createKilnMaterialDef } from './tools/materials';
8
+ import { MATERIAL_LIBRARY_LIMITS, materialLibraryPortableSpec } from './material-library';
8
9
  import {
9
- MATERIAL_LIBRARY_LIMITS,
10
- materialLibraryPortableSpec,
11
- proceduralMaterialDraftSchema,
12
- } from './material-library';
13
- import {
14
- createMaterialRecordV1,
15
10
  createMaterialLibraryPayload,
16
11
  decodeMaterialLibraryPayload,
12
+ resolveMaterialRecordClosure,
17
13
  } from './material-library-node';
14
+ import { createProceduralMaterialRecord } from './material-authoring-node';
18
15
  import { writeNewDestinationsAtomic } from './cli-output';
19
16
  import { localAssetLibrary } from './assets-node';
20
17
  import { exportWorkspaceProjectBundle, importWorkspaceProjectBundle } from './project-bundle-node';
@@ -44,6 +41,9 @@ revision; supplied top-level fields replace their previous values.
44
41
  Material imports verify embedded map bytes and provenance. Procedural creation
45
42
  uses a JSON recipe with explicit creator/license records. These commands do not
46
43
  fetch source URLs or read paths from imported JSON.
44
+ Legacy recipes retain their existing behavior. Opt-in schemaVersion 2 drafts
45
+ add explicit samplers and RGBA or named atlas recipes. Atlas sources pin exact
46
+ saved material revisions; export carries the complete verified dependency closure.
47
47
  Project and material storage is under <workspace>/.kiln. KILN_WORKSPACE selects the
48
48
  workspace. KILN_PROGRAM_STORE overrides only the source store when both are set.
49
49
  Without KILN_WORKSPACE, its grandparent remains the workspace root for compatibility.
@@ -214,21 +214,28 @@ export async function projectMain(argv: readonly string[]): Promise<number> {
214
214
  } else if (action === 'list')
215
215
  output = await createKilnMaterialDef(materials).run({ action: 'list' });
216
216
  else if (action === 'get' || action === 'export') {
217
- const record = await materials.read(positional[0]!, positional[1]!);
217
+ const manifest =
218
+ action === 'get'
219
+ ? (await materials.read(positional[0]!, positional[1]!)).manifest
220
+ : await materials.readManifest(positional[0]!, positional[1]!);
218
221
  if (action === 'get')
219
222
  output = {
220
- manifest: record.manifest,
221
- portableSpec: materialLibraryPortableSpec(record.manifest),
223
+ manifest,
224
+ portableSpec: materialLibraryPortableSpec(manifest),
222
225
  };
223
226
  else {
224
227
  if (!flags.out) throw new Error('material export requires --out');
225
- const payload = await createMaterialLibraryPayload([record]);
228
+ const records = await resolveMaterialRecordClosure([manifest], {
229
+ readManifest: (id, revision) => materials.readManifest(id, revision),
230
+ read: (id, revision) => materials.readRecordFiles(id, revision),
231
+ });
232
+ const payload = await createMaterialLibraryPayload(records);
226
233
  const path = resolve(flags.out);
227
234
  await writeNewDestinationsAtomic([{ path, data: JSON.stringify(payload, null, 2) }]);
228
235
  output = {
229
236
  file: path,
230
- materialId: record.manifest.materialId,
231
- revisionId: record.manifest.revisionId,
237
+ materialId: manifest.materialId,
238
+ revisionId: manifest.revisionId,
232
239
  };
233
240
  }
234
241
  } else {
@@ -237,11 +244,16 @@ export async function projectMain(argv: readonly string[]): Promise<number> {
237
244
  flags.file,
238
245
  action === 'procedural' ? MATERIAL_LIBRARY_LIMITS.maxManifestBytes : 24 * 1024 * 1024,
239
246
  );
240
- const records =
241
- action === 'procedural'
242
- ? [await createMaterialRecordV1(proceduralMaterialDraftSchema.parse(input))]
243
- : await decodeMaterialLibraryPayload(input);
244
- output = { materials: await materials.import(records) };
247
+ if (action === 'procedural') {
248
+ const { record, dependencies } = await createProceduralMaterialRecord(input, materials);
249
+ output = {
250
+ materials: await materials.import(
251
+ [record],
252
+ dependencies.length ? { materials: dependencies } : undefined,
253
+ ),
254
+ };
255
+ } else
256
+ output = { materials: await materials.import(await decodeMaterialLibraryPayload(input)) };
245
257
  }
246
258
  console.log(JSON.stringify(output, null, 2));
247
259
  return 0;
@@ -0,0 +1,597 @@
1
+ /** Image-free source inspection. A valid region domain is not evidence of intended corner orientation. */
2
+ import * as THREE from 'three';
3
+ import {
4
+ readAtlasTextureProvenance,
5
+ readAtlasGeometryBinding,
6
+ readValidatedAtlasDelivery,
7
+ type AtlasProvenanceV1,
8
+ } from '../atlas-provenance';
9
+ import { canonicalAtlasMetadataJsonV1 } from '../texture-atlas-contract';
10
+ import { canonicalizePortableSamplerV1 } from '../portable-sampler';
11
+ import { applyPortableSamplerV1 } from '../portable-sampler-runtime';
12
+ import type { QaFinding } from './types';
13
+
14
+ export const ATLAS_QA_LIMITS = Object.freeze({
15
+ nodes: 4096,
16
+ depth: 128,
17
+ textures: 128,
18
+ bindings: 1024,
19
+ uvVisits: 1_048_576,
20
+ findings: 64,
21
+ metadataBytes: 4_194_304,
22
+ });
23
+ export interface AtlasStructuralInspectionV1 {
24
+ schemaVersion: 1;
25
+ findings: QaFinding[];
26
+ coverage: 'none' | 'complete' | 'partial';
27
+ texturesChecked: number;
28
+ bindingsChecked: number;
29
+ uvVerticesChecked: number;
30
+ nodesChecked: number;
31
+ truncated: boolean;
32
+ }
33
+ const slots = [
34
+ ['map', ['albedo']],
35
+ ['normalMap', ['normal']],
36
+ ['roughnessMap', ['roughness', 'metallicRoughness']],
37
+ ['metalnessMap', ['metalness', 'metallicRoughness']],
38
+ ['emissiveMap', ['emissive']],
39
+ ['aoMap', ['occlusion']],
40
+ ] as const;
41
+ function own(value: unknown, key: string): unknown {
42
+ if (!value || typeof value !== 'object') return undefined;
43
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
44
+ if (!descriptor) return undefined;
45
+ if (!descriptor.enumerable || !('value' in descriptor))
46
+ throw Error('metadata data field required');
47
+ return descriptor.value;
48
+ }
49
+ type Entry = {
50
+ texture: THREE.Texture;
51
+ p: AtlasProvenanceV1;
52
+ slot: string;
53
+ material: THREE.Material;
54
+ materialIndex: number;
55
+ };
56
+ type UvAttribute = THREE.BufferAttribute | THREE.InterleavedBufferAttribute;
57
+ function representationError(attribute: UvAttribute, value: number): number {
58
+ // Three's toHalfFloat truncates its mantissa: allow one FP16 quantum, not half an ulp.
59
+ if ((attribute as UvAttribute & { isFloat16BufferAttribute?: boolean }).isFloat16BufferAttribute)
60
+ return Math.max(2 ** -24, 2 ** (Math.floor(Math.log2(Math.abs(value))) - 10));
61
+ if (!attribute.normalized) return 0;
62
+ const denominators: Record<string, number> = {
63
+ '[object Uint8Array]': 255,
64
+ '[object Uint8ClampedArray]': 255,
65
+ '[object Int8Array]': 127,
66
+ '[object Uint16Array]': 65535,
67
+ '[object Int16Array]': 32767,
68
+ '[object Uint32Array]': 4294967295,
69
+ '[object Int32Array]': 2147483647,
70
+ };
71
+ const denominator = denominators[Object.prototype.toString.call(attribute.array)];
72
+ return denominator ? 0.5 / denominator : 0;
73
+ }
74
+
75
+ export function inspectAtlasScene(root: unknown): AtlasStructuralInspectionV1 {
76
+ const result: AtlasStructuralInspectionV1 = {
77
+ schemaVersion: 1,
78
+ findings: [],
79
+ coverage: 'none',
80
+ texturesChecked: 0,
81
+ bindingsChecked: 0,
82
+ uvVerticesChecked: 0,
83
+ nodesChecked: 0,
84
+ truncated: false,
85
+ };
86
+ if (!(root instanceof THREE.Object3D)) return result;
87
+ let encountered = false,
88
+ metadataBytes = 0,
89
+ metadataFull = false;
90
+ const cache = new Map<THREE.Texture, AtlasProvenanceV1 | undefined | false>();
91
+ const unavailable = new Set<THREE.Texture>();
92
+ const seen = new Set<THREE.Object3D>();
93
+ const stack: { node: THREE.Object3D; path: string; depth: number }[] = [
94
+ { node: root, path: `${(root.name || root.type).slice(0, 80)}[0]`, depth: 0 },
95
+ ];
96
+ const partial = () => {
97
+ result.truncated = true;
98
+ result.coverage = 'partial';
99
+ };
100
+ const finding = (
101
+ code: string,
102
+ message: string,
103
+ path: string,
104
+ entry?: Pick<Entry, 'material' | 'texture' | 'slot'>,
105
+ ) => {
106
+ if (result.findings.length >= ATLAS_QA_LIMITS.findings - 1) {
107
+ partial();
108
+ return;
109
+ }
110
+ result.findings.push({
111
+ code,
112
+ message,
113
+ disposition: 'block',
114
+ dimension: 'exportIntegrity',
115
+ profile: 'material.atlas.source',
116
+ affected: {
117
+ nodePath: path,
118
+ ...(entry
119
+ ? {
120
+ material: entry.material.name || '(unnamed material)',
121
+ texture: entry.texture.name || '(unnamed texture)',
122
+ attribute: entry.slot,
123
+ }
124
+ : {}),
125
+ },
126
+ repairText:
127
+ 'Restore the exact atlas metadata, named remap and compatible texture binding, then inspect again.',
128
+ });
129
+ };
130
+ while (stack.length && result.findings.length < ATLAS_QA_LIMITS.findings - 1) {
131
+ if (result.nodesChecked >= ATLAS_QA_LIMITS.nodes) {
132
+ partial();
133
+ break;
134
+ }
135
+ const { node, path, depth } = stack.pop()!;
136
+ if (seen.has(node)) {
137
+ partial();
138
+ continue;
139
+ }
140
+ seen.add(node);
141
+ result.nodesChecked++;
142
+ if (!node.visible) continue;
143
+ const room = Math.max(0, ATLAS_QA_LIMITS.nodes - result.nodesChecked - stack.length);
144
+ if (depth >= ATLAS_QA_LIMITS.depth && node.children.length) partial();
145
+ else {
146
+ if (node.children.length > room) partial();
147
+ for (let i = Math.min(node.children.length, room) - 1; i >= 0; i--) {
148
+ const child = node.children[i]!;
149
+ stack.push({
150
+ node: child,
151
+ path: `${path}/${(child.name || child.type).slice(0, 80)}[${i}]`.slice(-800),
152
+ depth: depth + 1,
153
+ });
154
+ }
155
+ }
156
+ const mesh = node as THREE.Mesh;
157
+ if (!mesh.isMesh || !mesh.geometry) continue;
158
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
159
+ const geometryCount =
160
+ mesh.geometry.index?.count ?? mesh.geometry.getAttribute('position')?.count ?? 0;
161
+ const start = mesh.geometry.drawRange.start,
162
+ length = mesh.geometry.drawRange.count;
163
+ let active: Set<number> | undefined;
164
+ if (
165
+ Number.isSafeInteger(geometryCount) &&
166
+ geometryCount >= 0 &&
167
+ Number.isSafeInteger(start) &&
168
+ start >= 0 &&
169
+ (length === Infinity || (Number.isSafeInteger(length) && length >= 0))
170
+ ) {
171
+ active = new Set<number>();
172
+ const end = Math.min(geometryCount, start + length);
173
+ if (!Array.isArray(mesh.material)) {
174
+ if (end - start >= 3) active.add(0);
175
+ } else {
176
+ if (mesh.geometry.groups.length > ATLAS_QA_LIMITS.bindings) partial();
177
+ for (
178
+ let gi = 0;
179
+ gi < Math.min(mesh.geometry.groups.length, ATLAS_QA_LIMITS.bindings);
180
+ gi++
181
+ ) {
182
+ const group = mesh.geometry.groups[gi]!,
183
+ materialIndex = group.materialIndex ?? 0;
184
+ if (
185
+ !Number.isSafeInteger(group.start) ||
186
+ group.start < 0 ||
187
+ !Number.isSafeInteger(group.count) ||
188
+ group.count < 0 ||
189
+ group.start + group.count > geometryCount ||
190
+ !Number.isSafeInteger(materialIndex) ||
191
+ materialIndex < 0 ||
192
+ materialIndex >= materials.length
193
+ ) {
194
+ partial();
195
+ active = undefined;
196
+ break;
197
+ }
198
+ if (Math.min(end, group.start + group.count) - Math.max(start, group.start) >= 3)
199
+ active.add(materialIndex);
200
+ }
201
+ }
202
+ } else partial();
203
+ if (active?.size === 0) continue;
204
+ const entries: Entry[] = [];
205
+ let associationPartial =
206
+ materials.length > ATLAS_QA_LIMITS.bindings ||
207
+ mesh.geometry.groups.length > ATLAS_QA_LIMITS.bindings;
208
+ if (materials.length > ATLAS_QA_LIMITS.bindings) partial();
209
+ for (let mi = 0; mi < Math.min(materials.length, ATLAS_QA_LIMITS.bindings); mi++) {
210
+ const material = materials[mi];
211
+ if (!material || material.visible === false || (active && !active.has(mi))) continue;
212
+ for (const [slot, allowed] of slots) {
213
+ const texture = (material as unknown as Record<string, unknown>)[slot];
214
+ if (!(texture instanceof THREE.Texture)) continue;
215
+ let p = cache.get(texture);
216
+ if (!cache.has(texture)) {
217
+ try {
218
+ // Ordinary textures do not acquire an atlas requirement.
219
+ if (
220
+ !Object.hasOwn(texture.userData, 'kilnAtlas') &&
221
+ !Object.hasOwn(texture.userData, 'kilnMaterialResource')
222
+ ) {
223
+ cache.set(texture, undefined);
224
+ continue;
225
+ }
226
+ if (metadataFull || result.texturesChecked >= ATLAS_QA_LIMITS.textures) {
227
+ partial();
228
+ associationPartial = true;
229
+ unavailable.add(texture);
230
+ cache.set(texture, false);
231
+ continue;
232
+ }
233
+ p = readAtlasTextureProvenance(texture);
234
+ if (p) {
235
+ encountered = true;
236
+ if (result.texturesChecked >= ATLAS_QA_LIMITS.textures) {
237
+ partial();
238
+ cache.set(texture, false);
239
+ continue;
240
+ }
241
+ metadataBytes += new TextEncoder().encode(canonicalAtlasMetadataJsonV1(p)).length;
242
+ if (metadataBytes > ATLAS_QA_LIMITS.metadataBytes) {
243
+ partial();
244
+ associationPartial = true;
245
+ metadataFull = true;
246
+ unavailable.add(texture);
247
+ cache.set(texture, false);
248
+ continue;
249
+ }
250
+ result.texturesChecked++;
251
+ }
252
+ cache.set(texture, p);
253
+ } catch {
254
+ encountered = true;
255
+ cache.set(texture, false);
256
+ finding(
257
+ 'ATLAS_METADATA_INVALID',
258
+ 'Present atlas metadata is invalid or has conflicting identities.',
259
+ path,
260
+ { material, texture, slot },
261
+ );
262
+ continue;
263
+ }
264
+ }
265
+ if (!p) {
266
+ if (unavailable.has(texture)) associationPartial = true;
267
+ continue;
268
+ }
269
+ if (result.bindingsChecked >= ATLAS_QA_LIMITS.bindings) {
270
+ partial();
271
+ associationPartial = true;
272
+ continue;
273
+ }
274
+ result.bindingsChecked++;
275
+ const entry = { texture, p, slot, material, materialIndex: mi };
276
+ entries.push(entry);
277
+ const image = texture.image as { width?: unknown; height?: unknown } | undefined;
278
+ if (image?.width === undefined || image?.height === undefined) partial();
279
+ if (
280
+ (image?.width !== undefined && image.width !== p.manifest.width) ||
281
+ (image?.height !== undefined && image.height !== p.manifest.height)
282
+ )
283
+ finding(
284
+ 'ATLAS_METADATA_INVALID',
285
+ 'Actual atlas image dimensions differ from its declared manifest.',
286
+ path,
287
+ entry,
288
+ );
289
+ if (!(allowed as readonly string[]).includes(p.spec.usage))
290
+ finding(
291
+ 'ATLAS_SLOT_USAGE',
292
+ 'Atlas usage does not match its actual material slot.',
293
+ path,
294
+ entry,
295
+ );
296
+ try {
297
+ const override = own(texture.userData, 'kilnPortableBinding');
298
+ let sampler = p.manifest.sampler;
299
+ const delivery = readValidatedAtlasDelivery(texture);
300
+ if (delivery !== undefined) {
301
+ if (delivery.id !== p.manifest.manifestHash) {
302
+ finding(
303
+ 'ATLAS_METADATA_INVALID',
304
+ 'Atlas metadata differs from its validated imported delivery identity.',
305
+ path,
306
+ entry,
307
+ );
308
+ continue;
309
+ }
310
+ const effective = delivery.effectiveSampler;
311
+ if (!effective) throw Error('validated imported sampler unavailable');
312
+ const min: Record<number, string> = {
313
+ 9728: 'nearest',
314
+ 9729: 'linear',
315
+ 9984: 'nearest-mipmap-nearest',
316
+ 9985: 'linear-mipmap-nearest',
317
+ 9986: 'nearest-mipmap-linear',
318
+ 9987: 'linear-mipmap-linear',
319
+ };
320
+ const mag: Record<number, string> = { 9728: 'nearest', 9729: 'linear' };
321
+ const wrap: Record<number, string> = {
322
+ 10497: 'repeat',
323
+ 33071: 'clamp-to-edge',
324
+ 33648: 'mirrored-repeat',
325
+ };
326
+ sampler = canonicalizePortableSamplerV1({
327
+ minFilter: min[effective.minFilter ?? 9729],
328
+ magFilter: mag[effective.magFilter ?? 9729],
329
+ wrapS: wrap[effective.wrapS],
330
+ wrapT: wrap[effective.wrapT],
331
+ });
332
+ } else if (override !== undefined) {
333
+ canonicalAtlasMetadataJsonV1(override);
334
+ if (own(override, 'schemaVersion') !== 3) throw Error('invalid override version');
335
+ sampler = canonicalizePortableSamplerV1(own(override, 'sampler'));
336
+ }
337
+ const expected = applyPortableSamplerV1(new THREE.Texture(), sampler);
338
+ if (
339
+ texture.minFilter !== expected.minFilter ||
340
+ texture.magFilter !== expected.magFilter ||
341
+ texture.wrapS !== expected.wrapS ||
342
+ texture.wrapT !== expected.wrapT ||
343
+ texture.generateMipmaps !== expected.generateMipmaps
344
+ )
345
+ finding(
346
+ 'ATLAS_SAMPLER_MISMATCH',
347
+ 'Actual atlas filtering, wrapping or mip policy differs from its declared sampler.',
348
+ path,
349
+ entry,
350
+ );
351
+ } catch {
352
+ finding(
353
+ 'ATLAS_SAMPLER_MISMATCH',
354
+ 'The effective atlas sampler declaration is invalid.',
355
+ path,
356
+ entry,
357
+ );
358
+ }
359
+ if (texture.flipY !== false)
360
+ finding(
361
+ 'ATLAS_BINDING_TRANSFORM',
362
+ 'Canonical atlas texture orientation requires flipY=false.',
363
+ path,
364
+ entry,
365
+ );
366
+ }
367
+ }
368
+ let stamp: unknown;
369
+ try {
370
+ stamp = own(mesh.geometry.userData, 'kilnAtlasBinding');
371
+ if (stamp !== undefined) canonicalAtlasMetadataJsonV1(stamp);
372
+ } catch {
373
+ encountered = true;
374
+ finding('ATLAS_METADATA_INVALID', 'Present named atlas binding metadata is invalid.', path);
375
+ continue;
376
+ }
377
+ if (stamp === undefined) continue; // Manual full-sheet use is supported.
378
+ encountered = true;
379
+ const primary = entries.find((e) => e.p.manifest.manifestHash === own(stamp, 'manifestHash'));
380
+ if (!primary) {
381
+ if (associationPartial) {
382
+ partial();
383
+ continue;
384
+ }
385
+ finding(
386
+ 'ATLAS_BINDING_IDENTITY',
387
+ 'Named binding has no matching exact primary atlas texture.',
388
+ path,
389
+ );
390
+ continue;
391
+ }
392
+ if (!primary.p.manifest.regions.some((r) => r.name === own(stamp, 'region'))) {
393
+ finding(
394
+ 'ATLAS_REGION_MISSING',
395
+ 'Named binding refers to a missing atlas region.',
396
+ path,
397
+ primary,
398
+ );
399
+ continue;
400
+ }
401
+ let binding: ReturnType<typeof readAtlasGeometryBinding>;
402
+ try {
403
+ binding = readAtlasGeometryBinding(mesh.geometry, primary.p);
404
+ } catch {
405
+ finding(
406
+ 'ATLAS_BINDING_IDENTITY',
407
+ 'Named binding hashes, rectangles or remap formula differ from its exact atlas.',
408
+ path,
409
+ primary,
410
+ );
411
+ continue;
412
+ }
413
+ if (!binding) continue;
414
+ const geometry = mesh.geometry,
415
+ uv = geometry.getAttribute('uv'),
416
+ position = geometry.getAttribute('position'),
417
+ index = geometry.index;
418
+ if (
419
+ !uv ||
420
+ !position ||
421
+ uv.itemSize !== 2 ||
422
+ uv.count !== position.count ||
423
+ !Number.isSafeInteger(uv.count) ||
424
+ (uv as THREE.InstancedBufferAttribute).isInstancedBufferAttribute ||
425
+ (
426
+ (uv as THREE.InterleavedBufferAttribute).data as
427
+ | (THREE.InterleavedBuffer & { isInstancedInterleavedBuffer?: boolean })
428
+ | undefined
429
+ )?.isInstancedInterleavedBuffer
430
+ ) {
431
+ finding(
432
+ 'ATLAS_UV_DOMAIN',
433
+ 'Named atlas binding requires matching finite per-vertex UV0 and positions.',
434
+ path,
435
+ primary,
436
+ );
437
+ continue;
438
+ }
439
+ const count = index?.count ?? position.count;
440
+ const drawStart = geometry.drawRange.start,
441
+ drawCount = geometry.drawRange.count;
442
+ if (
443
+ !Number.isSafeInteger(count) ||
444
+ !Number.isSafeInteger(drawStart) ||
445
+ drawStart < 0 ||
446
+ (drawCount !== Infinity && (!Number.isSafeInteger(drawCount) || drawCount < 0)) ||
447
+ geometry.groups.length > ATLAS_QA_LIMITS.bindings
448
+ ) {
449
+ partial();
450
+ continue;
451
+ }
452
+ const drawEnd = Math.min(count, drawStart + drawCount);
453
+ const ranges = Array.isArray(mesh.material)
454
+ ? geometry.groups
455
+ : [{ start: 0, count, materialIndex: 0 }];
456
+ for (const entry of entries) {
457
+ const region = entry.p.manifest.regions.find((r) => r.name === binding.region);
458
+ if (entry.p.manifest.layoutHash !== primary.p.manifest.layoutHash || !region) {
459
+ finding(
460
+ 'ATLAS_BINDING_IDENTITY',
461
+ 'Companion atlas does not share the named primary layout and region.',
462
+ path,
463
+ entry,
464
+ );
465
+ continue;
466
+ }
467
+ if (entry.texture.channel !== 0) {
468
+ finding(
469
+ 'ATLAS_BINDING_TRANSFORM',
470
+ 'Named atlas remap binds UV0; the texture selects another UV channel.',
471
+ path,
472
+ entry,
473
+ );
474
+ continue;
475
+ }
476
+ const texture = entry.texture;
477
+ const matrix = texture.matrixAutoUpdate
478
+ ? new THREE.Matrix3().setUvTransform(
479
+ texture.offset.x,
480
+ texture.offset.y,
481
+ texture.repeat.x,
482
+ texture.repeat.y,
483
+ texture.rotation,
484
+ texture.center.x,
485
+ texture.center.y,
486
+ )
487
+ : texture.matrix.clone();
488
+ if (!matrix.elements.every(Number.isFinite)) {
489
+ finding('ATLAS_BINDING_TRANSFORM', 'Atlas texture transform is nonfinite.', path, entry);
490
+ continue;
491
+ }
492
+ const center = binding.mode === 'texel-center' ? 0.5 : 0;
493
+ const minX = (region.content.x + center) / entry.p.manifest.width,
494
+ maxX = (region.content.x + region.content.width - center) / entry.p.manifest.width;
495
+ const minY = (region.content.y + center) / entry.p.manifest.height,
496
+ maxY = (region.content.y + region.content.height - center) / entry.p.manifest.height;
497
+ // Four Float32 ulps at the unit domain cover representable boundary roundoff.
498
+ const tolerance = 4 * 2 ** -23;
499
+ let invalid = false;
500
+ const point = new THREE.Vector2();
501
+ for (const range of ranges) {
502
+ if ((range.materialIndex ?? 0) !== entry.materialIndex) continue;
503
+ if (
504
+ !Number.isSafeInteger(range.start) ||
505
+ range.start < 0 ||
506
+ !Number.isSafeInteger(range.count) ||
507
+ range.count < 0 ||
508
+ range.start + range.count > count
509
+ ) {
510
+ partial();
511
+ continue;
512
+ }
513
+ const rangeStart = Math.max(drawStart, range.start),
514
+ intersectionEnd = Math.min(drawEnd, range.start + range.count);
515
+ const rangeEnd = rangeStart + Math.floor(Math.max(0, intersectionEnd - rangeStart) / 3) * 3;
516
+ for (let i = rangeStart; i < rangeEnd; i++) {
517
+ if (result.uvVerticesChecked >= ATLAS_QA_LIMITS.uvVisits) {
518
+ partial();
519
+ break;
520
+ }
521
+ result.uvVerticesChecked++;
522
+ const vertex = index ? index.getX(i) : i;
523
+ if (!Number.isSafeInteger(vertex) || vertex < 0 || vertex >= uv.count) {
524
+ invalid = true;
525
+ break;
526
+ }
527
+ const u = uv.getX(vertex),
528
+ v = uv.getY(vertex);
529
+ point.set(u, v).applyMatrix3(matrix);
530
+ if (
531
+ !Number.isFinite(u) ||
532
+ !Number.isFinite(v) ||
533
+ !Number.isFinite(point.x) ||
534
+ !Number.isFinite(point.y)
535
+ ) {
536
+ invalid = true;
537
+ break;
538
+ }
539
+ const eu = representationError(uv, u),
540
+ ev = representationError(uv, v),
541
+ e = matrix.elements;
542
+ const toleranceX = tolerance + Math.abs(e[0]!) * eu + Math.abs(e[3]!) * ev;
543
+ const toleranceY = tolerance + Math.abs(e[1]!) * eu + Math.abs(e[4]!) * ev;
544
+ // Excess uncertainty is unmeasured, not an arbitrarily enlarged acceptance epsilon.
545
+ if (
546
+ toleranceX > Math.max(1 / entry.p.manifest.width, maxX - minX) / 2 ||
547
+ toleranceY > Math.max(1 / entry.p.manifest.height, maxY - minY) / 2
548
+ ) {
549
+ partial();
550
+ break;
551
+ }
552
+ if (
553
+ !Number.isFinite(point.x) ||
554
+ !Number.isFinite(point.y) ||
555
+ point.x < minX - toleranceX ||
556
+ point.x > maxX + toleranceX ||
557
+ point.y < minY - toleranceY ||
558
+ point.y > maxY + toleranceY
559
+ ) {
560
+ invalid = true;
561
+ break;
562
+ }
563
+ }
564
+ if (invalid) break;
565
+ }
566
+ if (invalid)
567
+ finding(
568
+ 'ATLAS_UV_DOMAIN',
569
+ 'Drawn effective UV0 escapes the named atlas content domain or is nonfinite.',
570
+ path,
571
+ entry,
572
+ );
573
+ }
574
+ }
575
+ if (result.findings.length >= ATLAS_QA_LIMITS.findings - 1) partial();
576
+ if (result.truncated && encountered)
577
+ result.findings.push({
578
+ code: 'ATLAS_INSPECTION_PARTIAL',
579
+ disposition: 'observe',
580
+ dimension: 'exportIntegrity',
581
+ profile: 'material.atlas.source',
582
+ message:
583
+ 'Atlas source inspection did not cover every declared binding or drawn UV because bounded work, decoded dimensions or association was unavailable.',
584
+ measurement: {
585
+ name: 'atlasSourceCoverage',
586
+ actual: 'partial',
587
+ breakdown: {
588
+ nodes: result.nodesChecked,
589
+ textures: result.texturesChecked,
590
+ bindings: result.bindingsChecked,
591
+ uvVisits: result.uvVerticesChecked,
592
+ },
593
+ },
594
+ });
595
+ if (!result.truncated) result.coverage = encountered ? 'complete' : 'none';
596
+ return result;
597
+ }
package/src/qa/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from './architecture';
9
9
  export * from './architecture-repairs';
10
10
  export * from './architecture-corpus';
11
11
  export * from './character';
12
+ export * from './skin-integrity';
12
13
  export * from './character-advisory';
13
14
  export * from './character-repairs';
14
15
  export * from './character-vehicle-corpus';