@instruktlabs/kiln 1.2.1 → 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 (219) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +86 -1
  4. package/README.md +18 -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/install.md +7 -6
  18. package/docs/material-library.md +9 -5
  19. package/docs/migration.md +16 -0
  20. package/docs/procedural-surfaces-v3.md +276 -0
  21. package/docs/projects-and-live-review.md +1 -1
  22. package/docs/rendering.md +41 -0
  23. package/docs/runtime.md +3 -3
  24. package/docs/sdk.md +55 -2
  25. package/docs/tools-operations.md +2 -2
  26. package/docs/tools.md +3 -3
  27. package/lib/asset-materials-node.d.ts +3 -3
  28. package/lib/asset-materials-node.js +29 -33
  29. package/lib/assets-node.d.ts +1 -1
  30. package/lib/assets-node.js +11 -4
  31. package/lib/assets.d.ts +4 -2
  32. package/lib/assets.js +61 -16
  33. package/lib/atlas-provenance.d.ts +86 -0
  34. package/lib/atlas-provenance.js +448 -0
  35. package/lib/character.d.ts +11 -2
  36. package/lib/character.js +90 -2
  37. package/lib/community-exporter.d.ts +3 -2
  38. package/lib/community-exporter.js +139 -3
  39. package/lib/discovery/helper-contracts.js +161 -7
  40. package/lib/discovery/helper-specs.js +49 -4
  41. package/lib/discovery/shapes.js +20 -7
  42. package/lib/engine-identity.d.ts +1 -1
  43. package/lib/engine-identity.js +1 -1
  44. package/lib/evaluator/authoring-diagnostic.d.ts +1 -1
  45. package/lib/evaluator/authoring-diagnostic.js +12 -0
  46. package/lib/evaluator/protocol.d.ts +2 -2
  47. package/lib/evaluator/protocol.js +16 -6
  48. package/lib/evaluator/subprocess.js +2 -2
  49. package/lib/exporter-node.js +9 -1
  50. package/lib/geometry-budget.d.ts +7 -0
  51. package/lib/geometry-budget.js +8 -0
  52. package/lib/gltf-texture-binding.d.ts +6 -0
  53. package/lib/gltf-texture-binding.js +54 -0
  54. package/lib/material-authoring-node.d.ts +6 -0
  55. package/lib/material-authoring-node.js +49 -0
  56. package/lib/material-authoring-schema.d.ts +871 -0
  57. package/lib/material-authoring-schema.js +310 -0
  58. package/lib/material-export.d.ts +5 -0
  59. package/lib/material-export.js +21 -0
  60. package/lib/material-library-node.d.ts +34 -8
  61. package/lib/material-library-node.js +691 -11
  62. package/lib/material-library.d.ts +3704 -4
  63. package/lib/material-library.js +272 -0
  64. package/lib/mcp-core.d.ts +1 -1
  65. package/lib/pinned-material-map.d.ts +33 -0
  66. package/lib/pinned-material-map.js +1 -0
  67. package/lib/portable-material-runtime.d.ts +5 -0
  68. package/lib/portable-material-runtime.js +160 -0
  69. package/lib/portable-sampler-runtime.d.ts +4 -0
  70. package/lib/portable-sampler-runtime.js +26 -0
  71. package/lib/portable-sampler.d.ts +11 -0
  72. package/lib/portable-sampler.js +42 -0
  73. package/lib/primitives.d.ts +15 -3
  74. package/lib/primitives.js +24 -4
  75. package/lib/procedural-material-v2.d.ts +2 -0
  76. package/lib/procedural-material-v2.js +2 -1
  77. package/lib/procedural-material-v3.d.ts +72 -0
  78. package/lib/procedural-material-v3.js +162 -0
  79. package/lib/procedural-texture-v2-compiler.d.ts +31 -0
  80. package/lib/procedural-texture-v2-compiler.js +172 -0
  81. package/lib/procedural-texture-v3-contract.d.ts +125 -0
  82. package/lib/procedural-texture-v3-contract.js +333 -0
  83. package/lib/procedural-texture-v3.d.ts +12 -0
  84. package/lib/procedural-texture-v3.js +165 -0
  85. package/lib/procedural-texture.d.ts +9 -31
  86. package/lib/procedural-texture.js +43 -170
  87. package/lib/project-bundle-node.d.ts +1 -1
  88. package/lib/project-bundle-node.js +70 -13
  89. package/lib/project-bundle.d.ts +91 -3
  90. package/lib/project-bundle.js +48 -7
  91. package/lib/qa/atlas.d.ts +21 -0
  92. package/lib/qa/atlas.js +474 -0
  93. package/lib/qa/index.d.ts +1 -0
  94. package/lib/qa/index.js +1 -0
  95. package/lib/qa/material.js +2 -0
  96. package/lib/qa/skin-integrity.d.ts +18 -0
  97. package/lib/qa/skin-integrity.js +221 -0
  98. package/lib/render-service-host.js +4 -7
  99. package/lib/render.d.ts +3 -3
  100. package/lib/render.js +108 -16
  101. package/lib/skin-binding.d.ts +20 -0
  102. package/lib/skin-binding.js +144 -0
  103. package/lib/texture-atlas-contract.d.ts +97 -0
  104. package/lib/texture-atlas-contract.js +414 -0
  105. package/lib/texture-atlas-pixels.d.ts +73 -0
  106. package/lib/texture-atlas-pixels.js +290 -0
  107. package/lib/texture-atlas.d.ts +10 -0
  108. package/lib/texture-atlas.js +143 -0
  109. package/lib/texture-bake.d.ts +3 -0
  110. package/lib/texture-bake.js +66 -11
  111. package/lib/texture-resolver.d.ts +22 -0
  112. package/lib/texture-resolver.js +24 -0
  113. package/lib/tools/materials.js +8 -7
  114. package/lib/tools/registry.js +5 -16
  115. package/lib/tools/review.js +1 -1
  116. package/lib/uv-data.d.ts +32 -0
  117. package/lib/uv-data.js +172 -0
  118. package/lib/uv-shapes.js +3 -0
  119. package/lib/uv.d.ts +9 -3
  120. package/lib/uv.js +53 -82
  121. package/lib/views/camera.d.ts +3 -3
  122. package/lib/views/camera.js +32 -28
  123. package/lib/views/face-policy.d.ts +9 -0
  124. package/lib/views/face-policy.js +16 -0
  125. package/lib/views/glb.js +218 -24
  126. package/lib/views/pose.d.ts +7 -1
  127. package/lib/views/pose.js +63 -12
  128. package/lib/views/raster.d.ts +6 -2
  129. package/lib/views/raster.js +49 -37
  130. package/lib/views/scene-copy.d.ts +6 -0
  131. package/lib/views/scene-copy.js +73 -0
  132. package/lib/views/scene-raster.js +2 -1
  133. package/lib/views/triangle-coverage.d.ts +8 -0
  134. package/lib/views/triangle-coverage.js +58 -0
  135. package/lib/workspace-node.js +7 -3
  136. package/lib/workspace.d.ts +2 -2
  137. package/package.json +3 -1
  138. package/plugin.json +1 -1
  139. package/plugins/kiln-engine/.claude-plugin/plugin.json +1 -1
  140. package/plugins/kiln-engine/README.md +3 -3
  141. package/plugins/kiln-engine/package-provenance.json +6 -6
  142. package/plugins/kiln-engine/plugin.json +1 -1
  143. package/plugins/kiln-engine/runtime.json +1 -1
  144. package/skills/kiln-author-asset/SKILL.md +1 -1
  145. package/skills/kiln-author-asset/references/camera-cli.md +13 -0
  146. package/skills/kiln-author-asset/references/geometry-recipes.md +134 -0
  147. package/skills/kiln-author-asset/references/projects-and-materials.md +32 -2
  148. package/skills/kiln-compose-scene/references/runtime-scenes.md +19 -0
  149. package/skills/kiln-qa-asset/references/integration-checks.md +37 -2
  150. package/src/asset-materials-node.ts +38 -45
  151. package/src/assets-node.ts +22 -5
  152. package/src/assets.ts +61 -20
  153. package/src/atlas-provenance.ts +522 -0
  154. package/src/character.ts +107 -2
  155. package/src/community-exporter.ts +143 -3
  156. package/src/discovery/helper-contracts.ts +188 -7
  157. package/src/discovery/helper-specs.ts +69 -4
  158. package/src/discovery/shapes.ts +25 -9
  159. package/src/engine-identity.ts +1 -1
  160. package/src/evaluator/authoring-diagnostic.ts +19 -1
  161. package/src/evaluator/protocol.ts +19 -9
  162. package/src/evaluator/subprocess.ts +2 -2
  163. package/src/exporter-node.ts +12 -1
  164. package/src/generated/mcp-manifest.json +3 -3
  165. package/src/geometry-budget.ts +8 -0
  166. package/src/gltf-texture-binding.ts +60 -0
  167. package/src/material-authoring-node.ts +68 -0
  168. package/src/material-authoring-schema.ts +343 -0
  169. package/src/material-export.ts +25 -0
  170. package/src/material-library-node.ts +840 -16
  171. package/src/material-library.ts +348 -5
  172. package/src/pinned-material-map.ts +43 -0
  173. package/src/portable-material-runtime.ts +200 -0
  174. package/src/portable-sampler-runtime.ts +40 -0
  175. package/src/portable-sampler.ts +54 -0
  176. package/src/primitives.ts +98 -4
  177. package/src/procedural-material-v2.ts +2 -1
  178. package/src/procedural-material-v3.ts +224 -0
  179. package/src/procedural-texture-v2-compiler.ts +234 -0
  180. package/src/procedural-texture-v3-contract.ts +437 -0
  181. package/src/procedural-texture-v3.ts +220 -0
  182. package/src/procedural-texture.ts +61 -229
  183. package/src/project-bundle-node.ts +106 -14
  184. package/src/project-bundle.ts +61 -18
  185. package/src/project-cli.ts +29 -17
  186. package/src/qa/atlas.ts +597 -0
  187. package/src/qa/index.ts +1 -0
  188. package/src/qa/material.ts +2 -0
  189. package/src/qa/skin-integrity.ts +291 -0
  190. package/src/render-service-host.ts +4 -6
  191. package/src/render.ts +133 -15
  192. package/src/skin-binding.ts +225 -0
  193. package/src/texture-atlas-contract.ts +558 -0
  194. package/src/texture-atlas-pixels.ts +456 -0
  195. package/src/texture-atlas.ts +185 -0
  196. package/src/texture-bake.ts +88 -10
  197. package/src/texture-resolver.ts +55 -0
  198. package/src/tools/materials.ts +15 -11
  199. package/src/tools/registry.ts +5 -15
  200. package/src/tools/review.ts +1 -1
  201. package/src/uv-data.ts +238 -0
  202. package/src/uv-shapes.ts +3 -0
  203. package/src/uv.ts +73 -128
  204. package/src/viewer/app.ts +37 -0
  205. package/src/viewer/live-panel.ts +57 -1
  206. package/src/viewer/material-preview-panel.ts +140 -0
  207. package/src/viewer/material-preview.ts +138 -0
  208. package/src/viewer/materials-panel.ts +34 -5
  209. package/src/viewer/navigation.ts +2 -0
  210. package/src/views/camera.ts +37 -40
  211. package/src/views/face-policy.ts +20 -0
  212. package/src/views/glb.ts +294 -17
  213. package/src/views/pose.ts +71 -13
  214. package/src/views/raster.ts +73 -46
  215. package/src/views/scene-copy.ts +86 -0
  216. package/src/views/scene-raster.ts +2 -1
  217. package/src/views/triangle-coverage.ts +78 -0
  218. package/src/workspace-node.ts +11 -3
  219. package/src/workspace.ts +2 -2
@@ -1,6 +1,9 @@
1
1
  import { Vector3, Matrix4, Euler, Group } from 'three';
2
2
  import { collectTriangles, compositingOrder, measureBounds, orbitDir } from "./raster.js";
3
3
  import { resolveBackdrop } from "./background.js";
4
+ import { cloneReviewScene } from "./scene-copy.js";
5
+ import { visibleNormalSign } from "./face-policy.js";
6
+ import { prepareTriangleCoverage } from "./triangle-coverage.js";
4
7
  const vec = (a) => new Vector3(...a);
5
8
  const tuple = (v) => [v.x || 0, v.y || 0, v.z || 0];
6
9
  /** Common near-misses for camera keys, named in the unknown-key error. */
@@ -301,7 +304,11 @@ export function resolveAssetCamera(root, shot = {}) {
301
304
  const padding = request.padding ?? 1.2;
302
305
  const fit = cameraFromBounds(bounds, tuple(direction), padding, tuple(up), measureBounds(root));
303
306
  if (request.projection === 'orthographic')
304
- camera = { ...fit, near: request.near ?? fit.near, far: request.far ?? fit.far };
307
+ camera = {
308
+ ...fit,
309
+ near: request.near ?? fit.near,
310
+ far: request.far ?? fit.far,
311
+ };
305
312
  else {
306
313
  const fov = finite(request.fovDeg ?? 50, 'fovDeg');
307
314
  if (fov <= 0 || fov >= 180)
@@ -375,9 +382,9 @@ export async function withCameraVisibility(root, shot, run) {
375
382
  * A derivative GLB
376
383
  * serialized from a scene with hidden nodes (authored, or isolated by
377
384
  * {@link withCameraVisibility}) would ship them to the GPU. Every node with
378
- * `visible = false` leaves with its subtree, which is what three draws. Returns the root
379
- * itself when nothing is hidden, else a pruned clone; the caller's scene is never mutated and
380
- * restores `.visible` itself.
385
+ * `visible = false` removes drawing from its subtree. Required skeleton ancestors remain
386
+ * as transforms without drawing payloads. Returns the root itself when nothing is hidden,
387
+ * else an independent pruned hierarchy/skeleton; the caller restores `.visible` itself.
381
388
  */
382
389
  export function withoutHiddenMeshes(root) {
383
390
  if (root.visible === false) {
@@ -394,15 +401,7 @@ export function withoutHiddenMeshes(root) {
394
401
  });
395
402
  if (!any)
396
403
  return root;
397
- const copy = root.clone(true);
398
- const drop = [];
399
- copy.traverse((node) => {
400
- if (hidden(node))
401
- drop.push(node);
402
- });
403
- for (const node of drop)
404
- node.removeFromParent();
405
- return copy;
404
+ return cloneReviewScene(root, { omitHidden: true });
406
405
  }
407
406
  /** Flat geometry renderer with exact orthographic/perspective projection and near/far clipping. */
408
407
  export function rasterizeCamera(root, input, size = 384, backfaceCull = true, backdrop) {
@@ -415,6 +414,7 @@ export function rasterizeCamera(root, input, size = 384, backfaceCull = true, ba
415
414
  for (let i = 0; i < size * size; i++)
416
415
  out.set(bg, i * 3);
417
416
  const depth = new Float64Array(size * size).fill(Infinity);
417
+ const weights = new Float64Array(3);
418
418
  const clip = (points, plane, near) => {
419
419
  const result = [];
420
420
  for (let i = 0; i < points.length; i++) {
@@ -422,8 +422,13 @@ export function rasterizeCamera(root, input, size = 384, backfaceCull = true, ba
422
422
  const insideA = near ? a.z >= plane : a.z <= plane, insideB = near ? b.z >= plane : b.z <= plane;
423
423
  if (insideA)
424
424
  result.push(a);
425
- if (insideA !== insideB)
426
- result.push(a.clone().lerp(b, (plane - a.z) / (b.z - a.z)));
425
+ if (insideA !== insideB) {
426
+ // Shared intersections must round identically for either edge direction.
427
+ const low = a.z < b.z ? a : b, high = a.z < b.z ? b : a;
428
+ const intersection = low.clone().lerp(high, (plane - low.z) / (high.z - low.z));
429
+ intersection.z = plane;
430
+ result.push(intersection);
431
+ }
427
432
  }
428
433
  return result;
429
434
  };
@@ -438,14 +443,14 @@ export function rasterizeCamera(root, input, size = 384, backfaceCull = true, ba
438
443
  .clone()
439
444
  .sub(world[0])
440
445
  .cross(world[2].clone().sub(world[0]))
441
- .normalize();
442
- if (backfaceCull &&
443
- !tri.doubleSided &&
444
- normal.dot(camera.projection === 'perspective' ? position.clone().sub(world[0]) : z) <= 0)
446
+ .normalize()
447
+ .multiplyScalar(tri.windingParity);
448
+ const normalSign = visibleNormalSign(normal.dot(camera.projection === 'perspective' ? position.clone().sub(world[0]) : z), tri.side, backfaceCull);
449
+ if (normalSign === 0)
445
450
  continue;
446
451
  if (tri.alpha <= 0)
447
452
  continue;
448
- const light = Math.min(1, 0.25 + 1.1 * Math.max(0, normal.dot(key)));
453
+ const light = Math.min(1, 0.25 + 1.1 * Math.max(0, normal.dot(key) * normalSign));
449
454
  const color = tri.color.map((c) => Math.round(srgb(c * light) * 255));
450
455
  let polygon = world.map((p) => {
451
456
  const d = p.clone().sub(position);
@@ -464,17 +469,16 @@ export function rasterizeCamera(root, input, size = 384, backfaceCull = true, ba
464
469
  ];
465
470
  });
466
471
  const [a, b, c] = screen;
467
- const area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
468
- if (Math.abs(area) < 1e-9)
472
+ const triangle = prepareTriangleCoverage(a[0], a[1], b[0], b[1], c[0], c[1], size);
473
+ if (!triangle)
469
474
  continue;
470
- for (let py = Math.max(0, Math.floor(Math.min(...screen.map((s) => s[1])))); py <= Math.min(size - 1, Math.ceil(Math.max(...screen.map((s) => s[1])))); py++)
471
- for (let px = Math.max(0, Math.floor(Math.min(...screen.map((s) => s[0])))); px <= Math.min(size - 1, Math.ceil(Math.max(...screen.map((s) => s[0])))); px++) {
472
- const w0 = ((b[0] - a[0]) * (py + 0.5 - a[1]) - (b[1] - a[1]) * (px + 0.5 - a[0])) / area, w1 = ((c[0] - b[0]) * (py + 0.5 - b[1]) - (c[1] - b[1]) * (px + 0.5 - b[0])) / area, w2 = 1 - w0 - w1;
473
- if (w0 < 0 || w1 < 0 || w2 < 0)
475
+ for (let py = triangle.minY; py <= triangle.maxY; py++)
476
+ for (let px = triangle.minX; px <= triangle.maxX; px++) {
477
+ if (!triangle.sample(px + 0.5, py + 0.5, weights))
474
478
  continue;
475
479
  const d = camera.projection === 'perspective'
476
- ? 1 / (w1 / p[0].z + w2 / p[1].z + w0 / p[2].z)
477
- : w1 * p[0].z + w2 * p[1].z + w0 * p[2].z;
480
+ ? 1 / (weights[0] / p[0].z + weights[1] / p[1].z + weights[2] / p[2].z)
481
+ : weights[0] * p[0].z + weights[1] * p[1].z + weights[2] * p[2].z;
478
482
  const index = py * size + px;
479
483
  if (d >= depth[index])
480
484
  continue;
@@ -0,0 +1,9 @@
1
+ /** Standard material sides, kept numeric for cross-realm and flat GLB inputs. */
2
+ export type SurfaceSide = 0 | 1 | 2;
3
+ /** Match Three's affine front-face convention; a singular transform does not flip.
4
+ * Collapsed triangles are discarded by rasterization. No inverse is needed. */
5
+ export declare function worldWindingParity(m: ArrayLike<number>): 1 | -1;
6
+ /** Facing uses deformed triangle winding corrected for the mesh world transform.
7
+ * Zero means culled; +/-1 orients the visible flat lighting normal. Disabling
8
+ * culling is a diagnostic two-sided override, matching GPU review derivatives. */
9
+ export declare function visibleNormalSign(facing: number, side: SurfaceSide, cull: boolean): -1 | 0 | 1;
@@ -0,0 +1,16 @@
1
+ /** Match Three's affine front-face convention; a singular transform does not flip.
2
+ * Collapsed triangles are discarded by rasterization. No inverse is needed. */
3
+ export function worldWindingParity(m) {
4
+ const determinant = m[0] * (m[5] * m[10] - m[9] * m[6]) -
5
+ m[4] * (m[1] * m[10] - m[9] * m[2]) +
6
+ m[8] * (m[1] * m[6] - m[5] * m[2]);
7
+ return determinant < 0 ? -1 : 1;
8
+ }
9
+ /** Facing uses deformed triangle winding corrected for the mesh world transform.
10
+ * Zero means culled; +/-1 orients the visible flat lighting normal. Disabling
11
+ * culling is a diagnostic two-sided override, matching GPU review derivatives. */
12
+ export function visibleNormalSign(facing, side, cull) {
13
+ if (cull && ((side === 0 && facing <= 0) || (side === 1 && facing >= 0)))
14
+ return 0;
15
+ return facing < 0 ? -1 : 1;
16
+ }
package/lib/views/glb.js CHANGED
@@ -1,11 +1,14 @@
1
1
  /** GLB-native input adapter for the deterministic CPU geometry-flat renderer. */
2
2
  import { useCubicSpline } from "../animation-cubic.js";
3
+ import { gltfTextureBindingKey, restoreGltfTextureBinding } from "../gltf-texture-binding.js";
4
+ import { isCubicSplineTrack } from "../animation-spline.js";
3
5
  import { createGltfIO } from "../gltf-io.js";
4
6
  import { listLodChainNodes } from "../lod-export.js";
5
7
  import { isHiddenGltfNode } from "../metrics.js";
6
8
  import { registerReviewLodChains } from "./lod.js";
9
+ import { atlasDocumentTable, expandAtlasBinding, restoreAtlasTextureProvenance, validateAtlasDocumentProvenance, } from "../atlas-provenance.js";
7
10
  import { Primitive, } from '@gltf-transform/core';
8
- import { BufferAttribute, BufferGeometry, Group, Matrix4, Mesh, MeshStandardMaterial, NoColorSpace, SRGBColorSpace, Texture, AnimationClip, QuaternionKeyframeTrack, VectorKeyframeTrack, Quaternion, Vector3, InterpolateDiscrete, InterpolateLinear, } from 'three';
11
+ import { BufferAttribute, BufferGeometry, Group, Matrix4, Mesh, Bone, SkinnedMesh, Skeleton, MeshStandardMaterial, NoColorSpace, SRGBColorSpace, Texture, AnimationClip, QuaternionKeyframeTrack, NumberKeyframeTrack, PropertyBinding, VectorKeyframeTrack, Quaternion, Vector3, InterpolateDiscrete, InterpolateLinear, } from 'three';
9
12
  export const GLB_GEOMETRY_FLAT_REASON = {
10
13
  TEXTURE_SAMPLING_UNSUPPORTED: 'GLB_FLAT_TEXTURE_SAMPLING_UNSUPPORTED',
11
14
  KTX2_SAMPLING_UNSUPPORTED: 'GLB_FLAT_KTX2_SAMPLING_UNSUPPORTED',
@@ -91,10 +94,18 @@ function reviewClipsFromExtras(extras) {
91
94
  throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', 'Invalid animation review track.');
92
95
  }
93
96
  const property = track.name.slice(track.name.lastIndexOf('.') + 1);
94
- const Track = property === 'quaternion' ? QuaternionKeyframeTrack : VectorKeyframeTrack;
97
+ const morph = /^morphTargetInfluences(?:\[[^\]]+\])?$/.test(property);
98
+ const Track = morph
99
+ ? NumberKeyframeTrack
100
+ : property === 'quaternion'
101
+ ? QuaternionKeyframeTrack
102
+ : VectorKeyframeTrack;
95
103
  if (track.interpolation === 'CUBICSPLINE') {
104
+ const tuple = morph ? track.values.length / track.times.length : cubicTuple(property);
96
105
  if (track.times.length < 2 ||
97
- track.values.length !== track.times.length * cubicTuple(property)) {
106
+ track.values.length !== track.times.length * tuple ||
107
+ !Number.isInteger(tuple / 3) ||
108
+ tuple < 3) {
98
109
  throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', 'Invalid animation review track.');
99
110
  }
100
111
  return useCubicSpline(new Track(track.name, track.times, track.values));
@@ -182,7 +193,7 @@ function flatMaterial(material) {
182
193
  ?.getEmissiveStrength() ?? 1,
183
194
  };
184
195
  }
185
- function preserveTexture(source, usage, cache) {
196
+ function preserveTexture(source, usage, cache, info, atlas) {
186
197
  if (!source)
187
198
  return null;
188
199
  const encoded = source.getImage();
@@ -194,14 +205,21 @@ function preserveTexture(source, usage, cache) {
194
205
  byUsage = new Map();
195
206
  cache.set(source, byUsage);
196
207
  }
197
- const existing = byUsage.get(usage);
208
+ const reference = info?.getExtras()['kilnAtlasReference'];
209
+ const cacheKey = `${usage}:${gltfTextureBindingKey(info)}`;
210
+ const existing = byUsage.get(cacheKey);
198
211
  if (existing)
199
212
  return existing;
200
213
  const texture = new Texture();
201
214
  texture.name = source.getName();
202
215
  texture.colorSpace = usage === 'color' ? SRGBColorSpace : NoColorSpace;
203
216
  texture.userData.encoded = { mime, bytes: Uint8Array.from(encoded) };
204
- byUsage.set(usage, texture);
217
+ texture.flipY = false;
218
+ if (info)
219
+ restoreGltfTextureBinding(texture, info);
220
+ if (reference && info && atlas)
221
+ restoreAtlasTextureProvenance(texture, info, atlas);
222
+ byUsage.set(cacheKey, texture);
205
223
  return texture;
206
224
  }
207
225
  function noteMaterialTextures(material, reasons) {
@@ -257,6 +275,7 @@ export async function loadGlbGeometryFlatScene(bytes) {
257
275
  catch (error) {
258
276
  throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', `Could not parse final GLB bytes: ${error instanceof Error ? error.message : String(error)}`);
259
277
  }
278
+ await validateAtlasDocumentProvenance(document);
260
279
  const scene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0];
261
280
  if (!scene)
262
281
  throw new GlbGeometryFlatError('GLB_FLAT_NO_SCENE', 'Final GLB has no scene.');
@@ -274,18 +293,24 @@ export async function loadGlbGeometryFlatScene(bytes) {
274
293
  };
275
294
  for (const child of scene.listChildren())
276
295
  visit(child);
296
+ // Skinning needs the joint hierarchy and inverse bind matrices. Keep the
297
+ // historical flat adapter for rigid scenes, but share the review loader for
298
+ // a posed derivative instead of silently rasterizing its bind-pose positions.
299
+ if ([...sceneNodes].some((node) => node.getSkin() ||
300
+ node
301
+ .getMesh()
302
+ ?.listPrimitives()
303
+ .some((p) => p.listTargets().length))) {
304
+ await validateAtlasDocumentProvenance(document);
305
+ return reviewSceneFromDocument(document);
306
+ }
277
307
  for (const node of sceneNodes) {
278
308
  const mesh = node.getMesh();
279
309
  if (!mesh)
280
310
  continue;
281
- if (node.getSkin())
282
- reasons.add(GLB_GEOMETRY_FLAT_REASON.SKIN_DEFORMATION_UNSUPPORTED);
283
311
  const instances = instanceMatrices(node);
284
312
  instanceCount += instances.count;
285
313
  for (const primitive of mesh.listPrimitives()) {
286
- if (primitive.listTargets().length > 0) {
287
- reasons.add(GLB_GEOMETRY_FLAT_REASON.MORPH_TARGETS_UNSUPPORTED);
288
- }
289
314
  const positionAccessor = primitive.getAttribute('POSITION');
290
315
  if (positionAccessor?.getElementSize() !== 3)
291
316
  continue;
@@ -370,6 +395,10 @@ export async function loadGlbReviewScene(bytes) {
370
395
  catch {
371
396
  throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', 'Could not parse final GLB bytes.');
372
397
  }
398
+ await validateAtlasDocumentProvenance(document);
399
+ return reviewSceneFromDocument(document);
400
+ }
401
+ function reviewSceneFromDocument(document) {
373
402
  const sourceScene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0];
374
403
  if (!sourceScene)
375
404
  throw new GlbGeometryFlatError('GLB_FLAT_NO_SCENE', 'Final GLB has no scene.');
@@ -377,14 +406,35 @@ export async function loadGlbReviewScene(bytes) {
377
406
  const root = new Group();
378
407
  root.name = sourceScene.getName() || 'Scene';
379
408
  const nodeMap = new Map();
409
+ const atlas = atlasDocumentTable(document);
380
410
  const textureCache = new Map();
411
+ const morphMeshes = new Map();
412
+ // Morph primitives need unique, binding-safe names: the historical ':primitive'
413
+ // suffix is a namespace separator to Three.PropertyBinding. Parent names stay intact.
414
+ const usedNames = new Set(document
415
+ .getRoot()
416
+ .listNodes()
417
+ .map((node) => node.getName()));
418
+ const nodeIds = new Map(document
419
+ .getRoot()
420
+ .listNodes()
421
+ .map((node, index) => [node, index]));
422
+ let morphValues = 0;
423
+ const morphError = (detail) => {
424
+ throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', `Invalid morph review data: ${detail}`);
425
+ };
426
+ const joints = new Set(document
427
+ .getRoot()
428
+ .listSkins()
429
+ .flatMap((skin) => skin.listJoints()));
430
+ const pendingSkins = [];
381
431
  let meshCount = 0;
382
432
  let instanceCount = 0;
383
433
  const buildNode = (source) => {
384
434
  const existing = nodeMap.get(source);
385
435
  if (existing)
386
436
  return existing;
387
- const target = new Group();
437
+ const target = joints.has(source) ? new Bone() : new Group();
388
438
  target.name = source.getName();
389
439
  target.position.fromArray(source.getTranslation());
390
440
  target.quaternion.fromArray(source.getRotation());
@@ -396,14 +446,27 @@ export async function loadGlbReviewScene(bytes) {
396
446
  nodeMap.set(source, target);
397
447
  const sourceMesh = source.getMesh();
398
448
  if (sourceMesh) {
449
+ const targetCount = sourceMesh.listPrimitives()[0]?.listTargets().length ?? 0;
450
+ if (sourceMesh.listPrimitives().some((p) => p.listTargets().length !== targetCount))
451
+ morphError('primitives must have equal target counts.');
452
+ const nodeWeights = source.getWeights(), meshWeights = sourceMesh.getWeights();
453
+ for (const weights of [nodeWeights, meshWeights])
454
+ if (weights.length && (weights.length !== targetCount || !weights.every(Number.isFinite)))
455
+ morphError('default weight count or values.');
456
+ const weights = nodeWeights.length
457
+ ? nodeWeights
458
+ : meshWeights.length
459
+ ? meshWeights
460
+ : Array(targetCount).fill(0);
461
+ // glTF Transform decodes extras.targetNames into PrimitiveTarget names.
462
+ const targetNames = sourceMesh
463
+ .listPrimitives()[0]
464
+ ?.listTargets()
465
+ .map((target, index) => target.getName() || String(index)) ?? [];
399
466
  target.userData.kilnGlbMeshNode = true;
400
- if (source.getSkin())
401
- reasons.add(GLB_GEOMETRY_FLAT_REASON.SKIN_DEFORMATION_UNSUPPORTED);
402
467
  const matrices = localInstanceMatrices(source);
403
468
  instanceCount += matrices.length;
404
469
  for (const [primitiveIndex, primitive] of sourceMesh.listPrimitives().entries()) {
405
- if (primitive.listTargets().length)
406
- reasons.add(GLB_GEOMETRY_FLAT_REASON.MORPH_TARGETS_UNSUPPORTED);
407
470
  const position = primitive.getAttribute('POSITION');
408
471
  if (position?.getElementSize() !== 3)
409
472
  continue;
@@ -416,6 +479,9 @@ export async function loadGlbReviewScene(bytes) {
416
479
  noteMaterialTextures(material, reasons);
417
480
  const flat = flatMaterial(material);
418
481
  const geometry = new BufferGeometry();
482
+ const atlasBinding = primitive.getExtras()['kilnAtlasBinding'];
483
+ if (atlasBinding !== undefined && atlas)
484
+ geometry.userData.kilnAtlasBinding = expandAtlasBinding(atlasBinding, atlas);
419
485
  geometry.setAttribute('position', new BufferAttribute(Float32Array.from(decodedAccessor(position)), 3));
420
486
  const normal = primitive.getAttribute('NORMAL');
421
487
  if (normal) {
@@ -430,6 +496,59 @@ export async function loadGlbReviewScene(bytes) {
430
496
  geometry.setAttribute('tangent', new BufferAttribute(Float32Array.from(decodedAccessor(tangent)), 4));
431
497
  }
432
498
  geometry.setIndex(new BufferAttribute(indices, 1));
499
+ if (targetCount) {
500
+ const targets = primitive.listTargets();
501
+ for (const target of targets)
502
+ for (const semantic of target.listSemantics()) {
503
+ const attribute = target.getAttribute(semantic);
504
+ if (!primitive.getAttribute(semantic) ||
505
+ attribute.getCount() !== position.getCount() ||
506
+ ((semantic === 'POSITION' || semantic === 'NORMAL' || semantic === 'TANGENT') &&
507
+ attribute.getElementSize() !== 3))
508
+ morphError(`target attribute ${semantic} does not match the base primitive.`);
509
+ if (semantic !== 'POSITION' && semantic !== 'NORMAL')
510
+ reasons.add(GLB_GEOMETRY_FLAT_REASON.MORPH_TARGETS_UNSUPPORTED);
511
+ }
512
+ const semantics = targets.some((target) => target.getAttribute('NORMAL'))
513
+ ? ['POSITION', 'NORMAL']
514
+ : ['POSITION'];
515
+ // Bound zero-filled missing attributes as well as decoded ones: a tiny
516
+ // file with many omitted deltas must not expand without limit.
517
+ morphValues += targetCount * position.getCount() * 3 * semantics.length;
518
+ if (morphValues > 16_777_216)
519
+ morphError('expanded morph attributes exceed the 64 MiB review budget.');
520
+ geometry.morphTargetsRelative = true;
521
+ for (const semantic of semantics) {
522
+ const attributes = targets.map((target) => {
523
+ const attribute = target.getAttribute(semantic);
524
+ const values = attribute
525
+ ? Float32Array.from(decodedAccessor(attribute))
526
+ : new Float32Array(position.getCount() * 3);
527
+ if (!values.every(Number.isFinite))
528
+ morphError(`nonfinite ${semantic} displacement.`);
529
+ return new BufferAttribute(values, 3);
530
+ });
531
+ if (semantic === 'POSITION')
532
+ geometry.morphAttributes.position = attributes;
533
+ else
534
+ geometry.morphAttributes.normal = attributes;
535
+ }
536
+ }
537
+ if (source.getSkin()) {
538
+ const skinIndices = primitive.getAttribute('JOINTS_0');
539
+ const skinWeights = primitive.getAttribute('WEIGHTS_0');
540
+ if (!skinIndices ||
541
+ !skinWeights ||
542
+ skinIndices.getElementSize() !== 4 ||
543
+ skinWeights.getElementSize() !== 4 ||
544
+ skinIndices.getCount() !== position.getCount() ||
545
+ skinWeights.getCount() !== position.getCount())
546
+ throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', `Invalid skin attributes on ${source.getName()}.`);
547
+ if (primitive.getAttribute('JOINTS_1') || primitive.getAttribute('WEIGHTS_1'))
548
+ throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', `Skin review supports four influences per vertex; ${source.getName()} has additional joint/weight sets.`);
549
+ geometry.setAttribute('skinIndex', new BufferAttribute(Uint16Array.from(decodedAccessor(skinIndices)), 4));
550
+ geometry.setAttribute('skinWeight', new BufferAttribute(Float32Array.from(decodedAccessor(skinWeights)), 4));
551
+ }
433
552
  const threeMaterial = new MeshStandardMaterial({
434
553
  opacity: flat.opacity,
435
554
  transparent: flat.alphaMode === 'BLEND',
@@ -443,22 +562,41 @@ export async function loadGlbReviewScene(bytes) {
443
562
  threeMaterial.emissiveIntensity = flat.emissiveIntensity;
444
563
  if (material) {
445
564
  threeMaterial.userData = { ...material.getExtras() };
446
- threeMaterial.map = preserveTexture(material.getBaseColorTexture(), 'color', textureCache);
447
- threeMaterial.normalMap = preserveTexture(material.getNormalTexture(), 'data', textureCache);
565
+ threeMaterial.map = preserveTexture(material.getBaseColorTexture(), 'color', textureCache, material.getBaseColorTextureInfo(), atlas);
566
+ threeMaterial.normalMap = preserveTexture(material.getNormalTexture(), 'data', textureCache, material.getNormalTextureInfo(), atlas);
448
567
  threeMaterial.normalScale.setScalar(material.getNormalScale());
449
- const metallicRoughness = preserveTexture(material.getMetallicRoughnessTexture(), 'data', textureCache);
568
+ const metallicRoughness = preserveTexture(material.getMetallicRoughnessTexture(), 'data', textureCache, material.getMetallicRoughnessTextureInfo(), atlas);
450
569
  threeMaterial.metalnessMap = metallicRoughness;
451
570
  threeMaterial.roughnessMap = metallicRoughness;
452
- threeMaterial.emissiveMap = preserveTexture(material.getEmissiveTexture(), 'color', textureCache);
453
- threeMaterial.aoMap = preserveTexture(material.getOcclusionTexture(), 'data', textureCache);
571
+ threeMaterial.emissiveMap = preserveTexture(material.getEmissiveTexture(), 'color', textureCache, material.getEmissiveTextureInfo(), atlas);
572
+ threeMaterial.aoMap = preserveTexture(material.getOcclusionTexture(), 'data', textureCache, material.getOcclusionTextureInfo(), atlas);
454
573
  threeMaterial.aoMapIntensity = material.getOcclusionStrength();
455
574
  }
456
575
  for (const [index, matrix] of matrices.entries()) {
457
- const mesh = new Mesh(geometry, threeMaterial);
576
+ const mesh = source.getSkin()
577
+ ? new SkinnedMesh(geometry, threeMaterial)
578
+ : new Mesh(geometry, threeMaterial);
579
+ if (mesh instanceof SkinnedMesh)
580
+ pendingSkins.push({ source, mesh });
458
581
  // A deduplicated mesh resource can serve many distinctly named nodes.
459
582
  // QA repair hints must identify this owning node, not the shared resource.
460
583
  const baseName = source.getName() || sourceMesh.getName() || 'Mesh';
461
584
  mesh.name = `${baseName}:primitive-${primitiveIndex}${matrices.length === 1 ? '' : `:instance-${index}`}`;
585
+ if (targetCount) {
586
+ let bindingName = `${PropertyBinding.sanitizeNodeName(baseName)}_morph_${nodeIds.get(source)}_${primitiveIndex}_${index}`;
587
+ while (usedNames.has(bindingName))
588
+ bindingName += '_';
589
+ usedNames.add(bindingName);
590
+ mesh.name = bindingName;
591
+ mesh.morphTargetInfluences = [...weights];
592
+ if (Array.isArray(targetNames) &&
593
+ targetNames.length === targetCount &&
594
+ targetNames.every((name) => typeof name === 'string'))
595
+ mesh.morphTargetDictionary = Object.fromEntries(targetNames.map((name, index) => [name, index]));
596
+ const siblings = morphMeshes.get(source) ?? [];
597
+ siblings.push(mesh);
598
+ morphMeshes.set(source, siblings);
599
+ }
462
600
  mesh.matrixAutoUpdate = false;
463
601
  mesh.matrix.copy(matrix);
464
602
  target.add(mesh);
@@ -484,6 +622,28 @@ export async function loadGlbReviewScene(bytes) {
484
622
  base: nodeMap.get(base),
485
623
  levels: lower.some((level) => nodeMap.has(level)) ? [] : lower.map(buildNode),
486
624
  })));
625
+ for (const { source, mesh } of pendingSkins) {
626
+ const skin = source.getSkin();
627
+ const bones = skin.listJoints().map((joint) => nodeMap.get(joint));
628
+ const inverse = skin.getInverseBindMatrices();
629
+ if (!bones.length ||
630
+ bones.some((bone) => !(bone instanceof Bone)) ||
631
+ (inverse && (inverse.getElementSize() !== 16 || inverse.getCount() < bones.length)))
632
+ throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', `Invalid or unavailable skin joints/bind matrices on ${source.getName()}.`);
633
+ const indices = mesh.geometry.getAttribute('skinIndex');
634
+ const weights = mesh.geometry.getAttribute('skinWeight');
635
+ for (let i = 0; i < indices.array.length; i++)
636
+ if (indices.array[i] >= bones.length ||
637
+ !Number.isFinite(weights.array[i]) ||
638
+ weights.array[i] < 0)
639
+ throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', `Invalid skin joint index or weight on ${source.getName()}.`);
640
+ const matrices = bones.map((_, index) => new Matrix4().fromArray(inverse ? inverse.getElement(index, []) : new Matrix4().elements));
641
+ // glTF's inverse bind already maps mesh-local positions into joint space.
642
+ // Identity bindMatrix and AttachedBindMode cancel the mesh world transform,
643
+ // matching GLTFLoader even under translated/scaled mesh ancestors.
644
+ mesh.bind(new Skeleton(bones, matrices), new Matrix4());
645
+ }
646
+ root.updateMatrixWorld(true);
487
647
  const nativeClips = document
488
648
  .getRoot()
489
649
  .listAnimations()
@@ -494,7 +654,7 @@ export async function loadGlbReviewScene(bytes) {
494
654
  const input = sampler?.getInput()?.getArray();
495
655
  const output = sampler?.getOutput()?.getArray();
496
656
  const property = path === 'translation' ? 'position' : path === 'rotation' ? 'quaternion' : path;
497
- if (!node || !property || property === 'weights' || !input || !output)
657
+ if (!node || !property || !input || !output)
498
658
  return [];
499
659
  const interpolation = sampler.getInterpolation();
500
660
  if (interpolation !== 'STEP' &&
@@ -502,6 +662,25 @@ export async function loadGlbReviewScene(bytes) {
502
662
  interpolation !== 'CUBICSPLINE') {
503
663
  throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', `Animation review does not support ${interpolation} interpolation; use LINEAR, STEP or CUBICSPLINE.`);
504
664
  }
665
+ if (property === 'weights') {
666
+ const targets = morphMeshes.get(node);
667
+ if (!targets?.length)
668
+ return [];
669
+ const width = targets[0].morphTargetInfluences.length;
670
+ const values = decodedAccessor(sampler.getOutput());
671
+ if (input.length < 1 ||
672
+ input.length > REVIEW_CLIP_LIMITS.samplesPerTrack ||
673
+ values.length > REVIEW_CLIP_LIMITS.valuesPerTrack ||
674
+ values.length !==
675
+ input.length * width * (interpolation === 'CUBICSPLINE' ? 3 : 1) ||
676
+ !Array.from(input).every((v, i) => Number.isFinite(v) && v >= 0 && (i === 0 || v > input[i - 1])) ||
677
+ !Array.from(values).every(Number.isFinite))
678
+ morphError('animation weight sampler width or values.');
679
+ return targets.map((mesh) => {
680
+ const track = new NumberKeyframeTrack(`${mesh.name}.morphTargetInfluences`, input, values, interpolation === 'STEP' ? InterpolateDiscrete : InterpolateLinear);
681
+ return interpolation === 'CUBICSPLINE' ? useCubicSpline(track) : track;
682
+ });
683
+ }
505
684
  const Track = path === 'rotation' ? QuaternionKeyframeTrack : VectorKeyframeTrack;
506
685
  if (interpolation === 'CUBICSPLINE') {
507
686
  if (input.length < 2 || output.length !== input.length * cubicTuple(property)) {
@@ -513,7 +692,22 @@ export async function loadGlbReviewScene(bytes) {
513
692
  new Track(`${node.getName()}.${property}`, input, output, interpolation === 'STEP' ? InterpolateDiscrete : InterpolateLinear),
514
693
  ];
515
694
  })), animation.getExtras().kilnLoopIntent));
516
- const clips = reviewClipsFromExtras(sourceScene.getExtras()) ?? nativeClips;
695
+ const extrasClips = reviewClipsFromExtras(sourceScene.getExtras());
696
+ if (extrasClips)
697
+ for (const clip of extrasClips)
698
+ clip.tracks = clip.tracks.flatMap((track) => {
699
+ const dot = track.name.lastIndexOf('.'), property = track.name.slice(dot + 1);
700
+ if (!/^morphTargetInfluences(?:\[[^\]]+\])?$/.test(property))
701
+ return [track];
702
+ const sources = [...morphMeshes.keys()].filter((node) => node.getName() === track.name.slice(0, dot));
703
+ if (sources.length !== 1)
704
+ return [track]; // Preserve unresolved/ambiguous author evidence.
705
+ return morphMeshes.get(sources[0]).map((mesh) => {
706
+ const copy = new NumberKeyframeTrack(`${mesh.name}.${property}`, track.times, track.values, track.getInterpolation());
707
+ return isCubicSplineTrack(track) ? useCubicSpline(copy) : copy;
708
+ });
709
+ });
710
+ const clips = extrasClips ?? nativeClips;
517
711
  return {
518
712
  root,
519
713
  clips,
@@ -1,6 +1,9 @@
1
1
  /** Minimal duck-typed view of a Three.js node we pose. */
2
2
  interface DuckNode {
3
3
  name?: string;
4
+ uuid?: string;
5
+ morphTargetInfluences?: number[];
6
+ morphTargetDictionary?: Record<string, number>;
4
7
  position?: {
5
8
  set(x: number, y: number, z: number): void;
6
9
  };
@@ -31,7 +34,7 @@ export interface DuckClip {
31
34
  kilnLoopIntent?: unknown;
32
35
  };
33
36
  }
34
- type Prop = 'position' | 'quaternion' | 'scale';
37
+ type Prop = 'position' | 'quaternion' | 'scale' | 'morphTargetInfluences';
35
38
  interface PreparedTrack {
36
39
  nodeName: string;
37
40
  prop: Prop;
@@ -40,6 +43,9 @@ interface PreparedTrack {
40
43
  /** One value of `stride` per key; a CUBICSPLINE key is [in-tangent, value, out-tangent]. */
41
44
  values: number[];
42
45
  interpolation: 'LINEAR' | 'STEP' | 'CUBICSPLINE';
46
+ /** Original spelling retained for unresolved-track evidence. */
47
+ sourceName?: string;
48
+ morphIndex?: number;
43
49
  }
44
50
  /** A clip prepared for repeated time-sampling: parsed tracks + a derived duration. */
45
51
  export interface PreparedClip {