@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
package/src/views/glb.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  /** GLB-native input adapter for the deterministic CPU geometry-flat renderer. */
2
2
  import { useCubicSpline } from '../animation-cubic';
3
+ import { gltfTextureBindingKey, restoreGltfTextureBinding } from '../gltf-texture-binding';
4
+ import { isCubicSplineTrack } from '../animation-spline';
3
5
  import { createGltfIO } from '../gltf-io';
4
6
  import { listLodChainNodes } from '../lod-export';
5
7
  import { isHiddenGltfNode } from '../metrics';
6
8
  import { registerReviewLodChains } from './lod';
9
+ import {
10
+ atlasDocumentTable,
11
+ expandAtlasBinding,
12
+ restoreAtlasTextureProvenance,
13
+ validateAtlasDocumentProvenance,
14
+ type AtlasProvenanceTableV1,
15
+ } from '../atlas-provenance';
7
16
 
8
17
  import {
9
18
  Primitive,
@@ -18,6 +27,9 @@ import {
18
27
  Group,
19
28
  Matrix4,
20
29
  Mesh,
30
+ Bone,
31
+ SkinnedMesh,
32
+ Skeleton,
21
33
  MeshStandardMaterial,
22
34
  NoColorSpace,
23
35
  SRGBColorSpace,
@@ -25,6 +37,8 @@ import {
25
37
  type Object3D,
26
38
  AnimationClip,
27
39
  QuaternionKeyframeTrack,
40
+ NumberKeyframeTrack,
41
+ PropertyBinding,
28
42
  VectorKeyframeTrack,
29
43
  Quaternion,
30
44
  Vector3,
@@ -188,11 +202,19 @@ function reviewClipsFromExtras(extras: unknown): AnimationClip[] | undefined {
188
202
  throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', 'Invalid animation review track.');
189
203
  }
190
204
  const property = track.name.slice(track.name.lastIndexOf('.') + 1);
191
- const Track = property === 'quaternion' ? QuaternionKeyframeTrack : VectorKeyframeTrack;
205
+ const morph = /^morphTargetInfluences(?:\[[^\]]+\])?$/.test(property);
206
+ const Track = morph
207
+ ? NumberKeyframeTrack
208
+ : property === 'quaternion'
209
+ ? QuaternionKeyframeTrack
210
+ : VectorKeyframeTrack;
192
211
  if (track.interpolation === 'CUBICSPLINE') {
212
+ const tuple = morph ? track.values.length / track.times.length : cubicTuple(property);
193
213
  if (
194
214
  track.times.length < 2 ||
195
- track.values.length !== track.times.length * cubicTuple(property)
215
+ track.values.length !== track.times.length * tuple ||
216
+ !Number.isInteger(tuple / 3) ||
217
+ tuple < 3
196
218
  ) {
197
219
  throw new GlbGeometryFlatError(
198
220
  'GLB_FLAT_PARSE_FAILED',
@@ -309,7 +331,9 @@ function flatMaterial(material: Material | null): FlatMaterial {
309
331
  function preserveTexture(
310
332
  source: GltfTexture | null,
311
333
  usage: 'color' | 'data',
312
- cache: Map<GltfTexture, Map<'color' | 'data', Texture>>,
334
+ cache: Map<GltfTexture, Map<string, Texture>>,
335
+ info?: import('@gltf-transform/core').TextureInfo | null,
336
+ atlas?: AtlasProvenanceTableV1,
313
337
  ): Texture | null {
314
338
  if (!source) return null;
315
339
  const encoded = source.getImage();
@@ -320,13 +344,18 @@ function preserveTexture(
320
344
  byUsage = new Map();
321
345
  cache.set(source, byUsage);
322
346
  }
323
- const existing = byUsage.get(usage);
347
+ const reference = info?.getExtras()['kilnAtlasReference'];
348
+ const cacheKey = `${usage}:${gltfTextureBindingKey(info)}`;
349
+ const existing = byUsage.get(cacheKey);
324
350
  if (existing) return existing;
325
351
  const texture = new Texture();
326
352
  texture.name = source.getName();
327
353
  texture.colorSpace = usage === 'color' ? SRGBColorSpace : NoColorSpace;
328
354
  texture.userData.encoded = { mime, bytes: Uint8Array.from(encoded) };
329
- byUsage.set(usage, texture);
355
+ texture.flipY = false;
356
+ if (info) restoreGltfTextureBinding(texture, info);
357
+ if (reference && info && atlas) restoreAtlasTextureProvenance(texture, info, atlas);
358
+ byUsage.set(cacheKey, texture);
330
359
  return texture;
331
360
  }
332
361
 
@@ -415,6 +444,7 @@ export async function loadGlbGeometryFlatScene(
415
444
  `Could not parse final GLB bytes: ${error instanceof Error ? error.message : String(error)}`,
416
445
  );
417
446
  }
447
+ await validateAtlasDocumentProvenance(document);
418
448
  const scene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0];
419
449
  if (!scene) throw new GlbGeometryFlatError('GLB_FLAT_NO_SCENE', 'Final GLB has no scene.');
420
450
 
@@ -430,16 +460,29 @@ export async function loadGlbGeometryFlatScene(
430
460
  };
431
461
  for (const child of scene.listChildren()) visit(child);
432
462
 
463
+ // Skinning needs the joint hierarchy and inverse bind matrices. Keep the
464
+ // historical flat adapter for rigid scenes, but share the review loader for
465
+ // a posed derivative instead of silently rasterizing its bind-pose positions.
466
+ if (
467
+ [...sceneNodes].some(
468
+ (node) =>
469
+ node.getSkin() ||
470
+ node
471
+ .getMesh()
472
+ ?.listPrimitives()
473
+ .some((p) => p.listTargets().length),
474
+ )
475
+ ) {
476
+ await validateAtlasDocumentProvenance(document);
477
+ return reviewSceneFromDocument(document);
478
+ }
479
+
433
480
  for (const node of sceneNodes) {
434
481
  const mesh = node.getMesh();
435
482
  if (!mesh) continue;
436
- if (node.getSkin()) reasons.add(GLB_GEOMETRY_FLAT_REASON.SKIN_DEFORMATION_UNSUPPORTED);
437
483
  const instances = instanceMatrices(node);
438
484
  instanceCount += instances.count;
439
485
  for (const primitive of mesh.listPrimitives()) {
440
- if (primitive.listTargets().length > 0) {
441
- reasons.add(GLB_GEOMETRY_FLAT_REASON.MORPH_TARGETS_UNSUPPORTED);
442
- }
443
486
  const positionAccessor = primitive.getAttribute('POSITION');
444
487
  if (positionAccessor?.getElementSize() !== 3) continue;
445
488
  const positions = decodedAccessor(positionAccessor);
@@ -536,20 +579,55 @@ export async function loadGlbReviewScene(bytes: Uint8Array): Promise<LoadedGlbRe
536
579
  } catch {
537
580
  throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', 'Could not parse final GLB bytes.');
538
581
  }
582
+ await validateAtlasDocumentProvenance(document);
583
+ return reviewSceneFromDocument(document);
584
+ }
585
+
586
+ function reviewSceneFromDocument(
587
+ document: import('@gltf-transform/core').Document,
588
+ ): LoadedGlbReviewScene {
539
589
  const sourceScene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0];
540
590
  if (!sourceScene) throw new GlbGeometryFlatError('GLB_FLAT_NO_SCENE', 'Final GLB has no scene.');
541
591
  const reasons = new Set<GlbGeometryFlatReasonCode>();
542
592
  const root = new Group();
543
593
  root.name = sourceScene.getName() || 'Scene';
544
594
  const nodeMap = new Map<import('@gltf-transform/core').Node, Object3D>();
545
- const textureCache = new Map<GltfTexture, Map<'color' | 'data', Texture>>();
595
+ const atlas = atlasDocumentTable(document);
596
+ const textureCache = new Map<GltfTexture, Map<string, Texture>>();
597
+ const morphMeshes = new Map<import('@gltf-transform/core').Node, Mesh[]>();
598
+ // Morph primitives need unique, binding-safe names: the historical ':primitive'
599
+ // suffix is a namespace separator to Three.PropertyBinding. Parent names stay intact.
600
+ const usedNames = new Set(
601
+ document
602
+ .getRoot()
603
+ .listNodes()
604
+ .map((node) => node.getName()),
605
+ );
606
+ const nodeIds = new Map(
607
+ document
608
+ .getRoot()
609
+ .listNodes()
610
+ .map((node, index) => [node, index]),
611
+ );
612
+ let morphValues = 0;
613
+ const morphError = (detail: string): never => {
614
+ throw new GlbGeometryFlatError('GLB_FLAT_PARSE_FAILED', `Invalid morph review data: ${detail}`);
615
+ };
616
+ const joints = new Set(
617
+ document
618
+ .getRoot()
619
+ .listSkins()
620
+ .flatMap((skin) => skin.listJoints()),
621
+ );
622
+ const pendingSkins: Array<{ source: import('@gltf-transform/core').Node; mesh: SkinnedMesh }> =
623
+ [];
546
624
  let meshCount = 0;
547
625
  let instanceCount = 0;
548
626
 
549
627
  const buildNode = (source: import('@gltf-transform/core').Node): Object3D => {
550
628
  const existing = nodeMap.get(source);
551
629
  if (existing) return existing;
552
- const target = new Group();
630
+ const target = joints.has(source) ? new Bone() : new Group();
553
631
  target.name = source.getName();
554
632
  target.position.fromArray(source.getTranslation());
555
633
  target.quaternion.fromArray(source.getRotation());
@@ -560,13 +638,29 @@ export async function loadGlbReviewScene(bytes: Uint8Array): Promise<LoadedGlbRe
560
638
  nodeMap.set(source, target);
561
639
  const sourceMesh = source.getMesh();
562
640
  if (sourceMesh) {
641
+ const targetCount = sourceMesh.listPrimitives()[0]?.listTargets().length ?? 0;
642
+ if (sourceMesh.listPrimitives().some((p) => p.listTargets().length !== targetCount))
643
+ morphError('primitives must have equal target counts.');
644
+ const nodeWeights = source.getWeights(),
645
+ meshWeights = sourceMesh.getWeights();
646
+ for (const weights of [nodeWeights, meshWeights])
647
+ if (weights.length && (weights.length !== targetCount || !weights.every(Number.isFinite)))
648
+ morphError('default weight count or values.');
649
+ const weights = nodeWeights.length
650
+ ? nodeWeights
651
+ : meshWeights.length
652
+ ? meshWeights
653
+ : Array<number>(targetCount).fill(0);
654
+ // glTF Transform decodes extras.targetNames into PrimitiveTarget names.
655
+ const targetNames =
656
+ sourceMesh
657
+ .listPrimitives()[0]
658
+ ?.listTargets()
659
+ .map((target, index) => target.getName() || String(index)) ?? [];
563
660
  target.userData.kilnGlbMeshNode = true;
564
- if (source.getSkin()) reasons.add(GLB_GEOMETRY_FLAT_REASON.SKIN_DEFORMATION_UNSUPPORTED);
565
661
  const matrices = localInstanceMatrices(source);
566
662
  instanceCount += matrices.length;
567
663
  for (const [primitiveIndex, primitive] of sourceMesh.listPrimitives().entries()) {
568
- if (primitive.listTargets().length)
569
- reasons.add(GLB_GEOMETRY_FLAT_REASON.MORPH_TARGETS_UNSUPPORTED);
570
664
  const position = primitive.getAttribute('POSITION');
571
665
  if (position?.getElementSize() !== 3) continue;
572
666
  const indices = triangleIndices(primitive, position.getCount());
@@ -578,6 +672,9 @@ export async function loadGlbReviewScene(bytes: Uint8Array): Promise<LoadedGlbRe
578
672
  noteMaterialTextures(material, reasons);
579
673
  const flat = flatMaterial(material);
580
674
  const geometry = new BufferGeometry();
675
+ const atlasBinding = primitive.getExtras()['kilnAtlasBinding'];
676
+ if (atlasBinding !== undefined && atlas)
677
+ geometry.userData.kilnAtlasBinding = expandAtlasBinding(atlasBinding, atlas);
581
678
  geometry.setAttribute(
582
679
  'position',
583
680
  new BufferAttribute(Float32Array.from(decodedAccessor(position)), 3),
@@ -604,6 +701,72 @@ export async function loadGlbReviewScene(bytes: Uint8Array): Promise<LoadedGlbRe
604
701
  );
605
702
  }
606
703
  geometry.setIndex(new BufferAttribute(indices, 1));
704
+ if (targetCount) {
705
+ const targets = primitive.listTargets();
706
+ for (const target of targets)
707
+ for (const semantic of target.listSemantics()) {
708
+ const attribute = target.getAttribute(semantic)!;
709
+ if (
710
+ !primitive.getAttribute(semantic) ||
711
+ attribute.getCount() !== position.getCount() ||
712
+ ((semantic === 'POSITION' || semantic === 'NORMAL' || semantic === 'TANGENT') &&
713
+ attribute.getElementSize() !== 3)
714
+ )
715
+ morphError(`target attribute ${semantic} does not match the base primitive.`);
716
+ if (semantic !== 'POSITION' && semantic !== 'NORMAL')
717
+ reasons.add(GLB_GEOMETRY_FLAT_REASON.MORPH_TARGETS_UNSUPPORTED);
718
+ }
719
+ const semantics = targets.some((target) => target.getAttribute('NORMAL'))
720
+ ? (['POSITION', 'NORMAL'] as const)
721
+ : (['POSITION'] as const);
722
+ // Bound zero-filled missing attributes as well as decoded ones: a tiny
723
+ // file with many omitted deltas must not expand without limit.
724
+ morphValues += targetCount * position.getCount() * 3 * semantics.length;
725
+ if (morphValues > 16_777_216)
726
+ morphError('expanded morph attributes exceed the 64 MiB review budget.');
727
+ geometry.morphTargetsRelative = true;
728
+ for (const semantic of semantics) {
729
+ const attributes = targets.map((target) => {
730
+ const attribute = target.getAttribute(semantic);
731
+ const values = attribute
732
+ ? Float32Array.from(decodedAccessor(attribute))
733
+ : new Float32Array(position.getCount() * 3);
734
+ if (!values.every(Number.isFinite)) morphError(`nonfinite ${semantic} displacement.`);
735
+ return new BufferAttribute(values, 3);
736
+ });
737
+ if (semantic === 'POSITION') geometry.morphAttributes.position = attributes;
738
+ else geometry.morphAttributes.normal = attributes;
739
+ }
740
+ }
741
+ if (source.getSkin()) {
742
+ const skinIndices = primitive.getAttribute('JOINTS_0');
743
+ const skinWeights = primitive.getAttribute('WEIGHTS_0');
744
+ if (
745
+ !skinIndices ||
746
+ !skinWeights ||
747
+ skinIndices.getElementSize() !== 4 ||
748
+ skinWeights.getElementSize() !== 4 ||
749
+ skinIndices.getCount() !== position.getCount() ||
750
+ skinWeights.getCount() !== position.getCount()
751
+ )
752
+ throw new GlbGeometryFlatError(
753
+ 'GLB_FLAT_PARSE_FAILED',
754
+ `Invalid skin attributes on ${source.getName()}.`,
755
+ );
756
+ if (primitive.getAttribute('JOINTS_1') || primitive.getAttribute('WEIGHTS_1'))
757
+ throw new GlbGeometryFlatError(
758
+ 'GLB_FLAT_PARSE_FAILED',
759
+ `Skin review supports four influences per vertex; ${source.getName()} has additional joint/weight sets.`,
760
+ );
761
+ geometry.setAttribute(
762
+ 'skinIndex',
763
+ new BufferAttribute(Uint16Array.from(decodedAccessor(skinIndices)), 4),
764
+ );
765
+ geometry.setAttribute(
766
+ 'skinWeight',
767
+ new BufferAttribute(Float32Array.from(decodedAccessor(skinWeights)), 4),
768
+ );
769
+ }
607
770
  const threeMaterial = new MeshStandardMaterial({
608
771
  opacity: flat.opacity,
609
772
  transparent: flat.alphaMode === 'BLEND',
@@ -621,17 +784,23 @@ export async function loadGlbReviewScene(bytes: Uint8Array): Promise<LoadedGlbRe
621
784
  material.getBaseColorTexture(),
622
785
  'color',
623
786
  textureCache,
787
+ material.getBaseColorTextureInfo(),
788
+ atlas,
624
789
  );
625
790
  threeMaterial.normalMap = preserveTexture(
626
791
  material.getNormalTexture(),
627
792
  'data',
628
793
  textureCache,
794
+ material.getNormalTextureInfo(),
795
+ atlas,
629
796
  );
630
797
  threeMaterial.normalScale.setScalar(material.getNormalScale());
631
798
  const metallicRoughness = preserveTexture(
632
799
  material.getMetallicRoughnessTexture(),
633
800
  'data',
634
801
  textureCache,
802
+ material.getMetallicRoughnessTextureInfo(),
803
+ atlas,
635
804
  );
636
805
  threeMaterial.metalnessMap = metallicRoughness;
637
806
  threeMaterial.roughnessMap = metallicRoughness;
@@ -639,22 +808,47 @@ export async function loadGlbReviewScene(bytes: Uint8Array): Promise<LoadedGlbRe
639
808
  material.getEmissiveTexture(),
640
809
  'color',
641
810
  textureCache,
811
+ material.getEmissiveTextureInfo(),
812
+ atlas,
642
813
  );
643
814
  threeMaterial.aoMap = preserveTexture(
644
815
  material.getOcclusionTexture(),
645
816
  'data',
646
817
  textureCache,
818
+ material.getOcclusionTextureInfo(),
819
+ atlas,
647
820
  );
648
821
  threeMaterial.aoMapIntensity = material.getOcclusionStrength();
649
822
  }
650
823
  for (const [index, matrix] of matrices.entries()) {
651
- const mesh = new Mesh(geometry, threeMaterial);
824
+ const mesh = source.getSkin()
825
+ ? new SkinnedMesh(geometry, threeMaterial)
826
+ : new Mesh(geometry, threeMaterial);
827
+ if (mesh instanceof SkinnedMesh) pendingSkins.push({ source, mesh });
652
828
  // A deduplicated mesh resource can serve many distinctly named nodes.
653
829
  // QA repair hints must identify this owning node, not the shared resource.
654
830
  const baseName = source.getName() || sourceMesh.getName() || 'Mesh';
655
831
  mesh.name = `${baseName}:primitive-${primitiveIndex}${
656
832
  matrices.length === 1 ? '' : `:instance-${index}`
657
833
  }`;
834
+ if (targetCount) {
835
+ let bindingName = `${PropertyBinding.sanitizeNodeName(baseName)}_morph_${nodeIds.get(source)}_${primitiveIndex}_${index}`;
836
+ while (usedNames.has(bindingName)) bindingName += '_';
837
+ usedNames.add(bindingName);
838
+ mesh.name = bindingName;
839
+ mesh.morphTargetInfluences = [...weights];
840
+ if (
841
+ Array.isArray(targetNames) &&
842
+ targetNames.length === targetCount &&
843
+ targetNames.every((name) => typeof name === 'string')
844
+ )
845
+ mesh.morphTargetDictionary = Object.fromEntries(
846
+ targetNames.map((name, index) => [name, index]),
847
+ );
848
+ const siblings = morphMeshes.get(source) ?? [];
849
+ siblings.push(mesh);
850
+ morphMeshes.set(source, siblings);
851
+ }
658
852
  mesh.matrixAutoUpdate = false;
659
853
  mesh.matrix.copy(matrix);
660
854
  target.add(mesh);
@@ -685,6 +879,41 @@ export async function loadGlbReviewScene(bytes: Uint8Array): Promise<LoadedGlbRe
685
879
  })),
686
880
  );
687
881
 
882
+ for (const { source, mesh } of pendingSkins) {
883
+ const skin = source.getSkin()!;
884
+ const bones = skin.listJoints().map((joint) => nodeMap.get(joint));
885
+ const inverse = skin.getInverseBindMatrices();
886
+ if (
887
+ !bones.length ||
888
+ bones.some((bone) => !(bone instanceof Bone)) ||
889
+ (inverse && (inverse.getElementSize() !== 16 || inverse.getCount() < bones.length))
890
+ )
891
+ throw new GlbGeometryFlatError(
892
+ 'GLB_FLAT_PARSE_FAILED',
893
+ `Invalid or unavailable skin joints/bind matrices on ${source.getName()}.`,
894
+ );
895
+ const indices = mesh.geometry.getAttribute('skinIndex');
896
+ const weights = mesh.geometry.getAttribute('skinWeight');
897
+ for (let i = 0; i < indices.array.length; i++)
898
+ if (
899
+ indices.array[i]! >= bones.length ||
900
+ !Number.isFinite(weights.array[i]) ||
901
+ weights.array[i]! < 0
902
+ )
903
+ throw new GlbGeometryFlatError(
904
+ 'GLB_FLAT_PARSE_FAILED',
905
+ `Invalid skin joint index or weight on ${source.getName()}.`,
906
+ );
907
+ const matrices = bones.map((_, index) =>
908
+ new Matrix4().fromArray(inverse ? inverse.getElement(index, []) : new Matrix4().elements),
909
+ );
910
+ // glTF's inverse bind already maps mesh-local positions into joint space.
911
+ // Identity bindMatrix and AttachedBindMode cancel the mesh world transform,
912
+ // matching GLTFLoader even under translated/scaled mesh ancestors.
913
+ mesh.bind(new Skeleton(bones as Bone[], matrices), new Matrix4());
914
+ }
915
+ root.updateMatrixWorld(true);
916
+
688
917
  const nativeClips = document
689
918
  .getRoot()
690
919
  .listAnimations()
@@ -701,7 +930,7 @@ export async function loadGlbReviewScene(bytes: Uint8Array): Promise<LoadedGlbRe
701
930
  const output = sampler?.getOutput()?.getArray();
702
931
  const property =
703
932
  path === 'translation' ? 'position' : path === 'rotation' ? 'quaternion' : path;
704
- if (!node || !property || property === 'weights' || !input || !output) return [];
933
+ if (!node || !property || !input || !output) return [];
705
934
  const interpolation: string = sampler!.getInterpolation();
706
935
  if (
707
936
  interpolation !== 'STEP' &&
@@ -713,6 +942,33 @@ export async function loadGlbReviewScene(bytes: Uint8Array): Promise<LoadedGlbRe
713
942
  `Animation review does not support ${interpolation} interpolation; use LINEAR, STEP or CUBICSPLINE.`,
714
943
  );
715
944
  }
945
+ if (property === 'weights') {
946
+ const targets = morphMeshes.get(node);
947
+ if (!targets?.length) return [];
948
+ const width = targets[0]!.morphTargetInfluences!.length;
949
+ const values = decodedAccessor(sampler!.getOutput()!);
950
+ if (
951
+ input.length < 1 ||
952
+ input.length > REVIEW_CLIP_LIMITS.samplesPerTrack ||
953
+ values.length > REVIEW_CLIP_LIMITS.valuesPerTrack ||
954
+ values.length !==
955
+ input.length * width * (interpolation === 'CUBICSPLINE' ? 3 : 1) ||
956
+ !Array.from(input).every(
957
+ (v, i) => Number.isFinite(v) && v >= 0 && (i === 0 || v > input[i - 1]!),
958
+ ) ||
959
+ !Array.from(values).every(Number.isFinite)
960
+ )
961
+ morphError('animation weight sampler width or values.');
962
+ return targets.map((mesh) => {
963
+ const track = new NumberKeyframeTrack(
964
+ `${mesh.name}.morphTargetInfluences`,
965
+ input,
966
+ values,
967
+ interpolation === 'STEP' ? InterpolateDiscrete : InterpolateLinear,
968
+ );
969
+ return interpolation === 'CUBICSPLINE' ? useCubicSpline(track) : track;
970
+ });
971
+ }
716
972
  const Track = path === 'rotation' ? QuaternionKeyframeTrack : VectorKeyframeTrack;
717
973
  if (interpolation === 'CUBICSPLINE') {
718
974
  if (input.length < 2 || output.length !== input.length * cubicTuple(property)) {
@@ -736,7 +992,28 @@ export async function loadGlbReviewScene(bytes: Uint8Array): Promise<LoadedGlbRe
736
992
  (animation.getExtras() as { kilnLoopIntent?: unknown }).kilnLoopIntent,
737
993
  ),
738
994
  );
739
- const clips = reviewClipsFromExtras(sourceScene.getExtras()) ?? nativeClips;
995
+ const extrasClips = reviewClipsFromExtras(sourceScene.getExtras());
996
+ if (extrasClips)
997
+ for (const clip of extrasClips)
998
+ clip.tracks = clip.tracks.flatMap((track) => {
999
+ const dot = track.name.lastIndexOf('.'),
1000
+ property = track.name.slice(dot + 1);
1001
+ if (!/^morphTargetInfluences(?:\[[^\]]+\])?$/.test(property)) return [track];
1002
+ const sources = [...morphMeshes.keys()].filter(
1003
+ (node) => node.getName() === track.name.slice(0, dot),
1004
+ );
1005
+ if (sources.length !== 1) return [track]; // Preserve unresolved/ambiguous author evidence.
1006
+ return morphMeshes.get(sources[0]!)!.map((mesh) => {
1007
+ const copy = new NumberKeyframeTrack(
1008
+ `${mesh.name}.${property}`,
1009
+ track.times,
1010
+ track.values,
1011
+ track.getInterpolation(),
1012
+ );
1013
+ return isCubicSplineTrack(track) ? useCubicSpline(copy) : copy;
1014
+ });
1015
+ });
1016
+ const clips = extrasClips ?? nativeClips;
740
1017
  return {
741
1018
  root,
742
1019
  clips,
package/src/views/pose.ts CHANGED
@@ -19,6 +19,9 @@ import { cubicSegment, isCubicSplineTrack, normalizeQuaternion } from '../animat
19
19
  /** Minimal duck-typed view of a Three.js node we pose. */
20
20
  interface DuckNode {
21
21
  name?: string;
22
+ uuid?: string;
23
+ morphTargetInfluences?: number[];
24
+ morphTargetDictionary?: Record<string, number>;
22
25
  position?: { set(x: number, y: number, z: number): void };
23
26
  quaternion?: { set(x: number, y: number, z: number, w: number): void };
24
27
  scale?: { set(x: number, y: number, z: number): void };
@@ -44,7 +47,7 @@ export interface DuckClip {
44
47
  userData?: { kilnLoopIntent?: unknown };
45
48
  }
46
49
 
47
- type Prop = 'position' | 'quaternion' | 'scale';
50
+ type Prop = 'position' | 'quaternion' | 'scale' | 'morphTargetInfluences';
48
51
 
49
52
  interface PreparedTrack {
50
53
  nodeName: string;
@@ -54,6 +57,9 @@ interface PreparedTrack {
54
57
  /** One value of `stride` per key; a CUBICSPLINE key is [in-tangent, value, out-tangent]. */
55
58
  values: number[];
56
59
  interpolation: 'LINEAR' | 'STEP' | 'CUBICSPLINE';
60
+ /** Original spelling retained for unresolved-track evidence. */
61
+ sourceName?: string;
62
+ morphIndex?: number;
57
63
  }
58
64
 
59
65
  /** A clip prepared for repeated time-sampling: parsed tracks + a derived duration. */
@@ -65,7 +71,7 @@ export interface PreparedClip {
65
71
  unresolved: string[];
66
72
  }
67
73
 
68
- const PROP_STRIDE: Record<Prop, number> = { position: 3, quaternion: 4, scale: 3 };
74
+ const PROP_STRIDE = { position: 3, quaternion: 4, scale: 3 } as const;
69
75
 
70
76
  /** Find a clip by name (case-insensitive, trimmed). Returns the first exact match,
71
77
  * else the first whose name contains the query, else undefined. */
@@ -90,10 +96,11 @@ export function clipNames(clips: readonly DuckClip[]): string[] {
90
96
  * clip doesn't carry one.
91
97
  */
92
98
  export function prepareClip(root: DuckNode, clip: DuckClip): PreparedClip {
93
- const nodeNames = new Set<string>();
99
+ const nodes = new Map<string, DuckNode>();
94
100
  root.traverse?.((obj) => {
95
- const n = (obj as DuckNode).name;
96
- if (n) nodeNames.add(n);
101
+ const node = obj as DuckNode;
102
+ if (node.name && !nodes.has(node.name)) nodes.set(node.name, node);
103
+ if (node.uuid) nodes.set(node.uuid, node);
97
104
  });
98
105
 
99
106
  const tracks: PreparedTrack[] = [];
@@ -106,8 +113,11 @@ export function prepareClip(root: DuckNode, clip: DuckClip): PreparedClip {
106
113
  const dot = raw.lastIndexOf('.');
107
114
  if (dot === -1) continue;
108
115
  const nodeName = raw.slice(0, dot);
109
- const property = raw.slice(dot + 1) as Prop;
110
- if (property !== 'position' && property !== 'quaternion' && property !== 'scale') continue;
116
+ const suffix = raw.slice(dot + 1);
117
+ const morph = /^morphTargetInfluences(?:\[([^\]]+)\])?$/.exec(suffix);
118
+ const property = (morph ? 'morphTargetInfluences' : suffix) as Prop;
119
+ if (property !== 'position' && property !== 'quaternion' && property !== 'scale' && !morph)
120
+ continue;
111
121
 
112
122
  const times = track.times ? Array.from(track.times) : [];
113
123
  const values = track.values ? Array.from(track.values) : [];
@@ -125,16 +135,49 @@ export function prepareClip(root: DuckNode, clip: DuckClip): PreparedClip {
125
135
  }
126
136
  const interpolation = cubic ? 'CUBICSPLINE' : mode === 2300 ? 'STEP' : 'LINEAR';
127
137
 
128
- if (!nodeNames.has(nodeName)) unresolved.push(raw);
138
+ const node = nodes.get(nodeName);
139
+ const indexed = morph?.[1];
140
+ const morphIndex =
141
+ indexed === undefined
142
+ ? undefined
143
+ : /^\d+$/.test(indexed)
144
+ ? Number(indexed)
145
+ : node?.morphTargetDictionary?.[indexed];
146
+ let stride = morph
147
+ ? indexed === undefined
148
+ ? (node?.morphTargetInfluences?.length ?? 0)
149
+ : 1
150
+ : PROP_STRIDE[property as keyof typeof PROP_STRIDE];
151
+ let resolved = !!node;
152
+ if (morph) {
153
+ resolved =
154
+ !!node?.morphTargetInfluences?.length &&
155
+ (indexed === undefined ||
156
+ (Number.isInteger(morphIndex) &&
157
+ morphIndex! >= 0 &&
158
+ morphIndex! < node.morphTargetInfluences.length));
159
+ // Unresolved morph tracks remain visible diagnostics, but never get sampled.
160
+ if (!resolved) stride = values.length / (times.length * (cubic ? 3 : 1));
161
+ if (
162
+ !Number.isInteger(stride) ||
163
+ stride < 1 ||
164
+ values.length !== times.length * stride * (cubic ? 3 : 1) ||
165
+ !values.every(Number.isFinite) ||
166
+ !times.every((v, i) => Number.isFinite(v) && v >= 0 && (i === 0 || v > times[i - 1]!))
167
+ )
168
+ throw new Error(`Invalid morph animation values on ${raw}.`);
169
+ }
170
+ if (!resolved) unresolved.push(raw);
129
171
  if (times[times.length - 1]! > maxTime) maxTime = times[times.length - 1]!;
130
172
 
131
173
  tracks.push({
132
174
  nodeName,
133
175
  prop: property,
134
- stride: PROP_STRIDE[property],
176
+ stride,
135
177
  times,
136
178
  values,
137
179
  interpolation,
180
+ ...(morph ? { sourceName: raw, ...(morphIndex === undefined ? {} : { morphIndex }) } : {}),
138
181
  });
139
182
  }
140
183
 
@@ -228,7 +271,7 @@ function sampleTrack(track: PreparedTrack, t: number, out: number[]): void {
228
271
  }
229
272
  if (track.interpolation === 'CUBICSPLINE') {
230
273
  cubicSegment(values, stride, i, times[i]!, times[i + 1]!, t, out);
231
- if (stride === 4) normalizeQuaternion(out);
274
+ if (track.prop === 'quaternion') normalizeQuaternion(out);
232
275
  return;
233
276
  }
234
277
  if (track.interpolation === 'STEP') {
@@ -243,7 +286,7 @@ function sampleTrack(track: PreparedTrack, t: number, out: number[]): void {
243
286
  const o0 = i * stride;
244
287
  const o1 = (i + 1) * stride;
245
288
 
246
- if (stride === 4) {
289
+ if (track.prop === 'quaternion') {
247
290
  slerpFlat(quatTmp, values, o0, values, o1, a);
248
291
  out[0] = quatTmp[0]!;
249
292
  out[1] = quatTmp[1]!;
@@ -328,6 +371,12 @@ export function measureLoopClosure(root: DuckNode, clip: DuckClip): LoopClosureE
328
371
  }
329
372
  result.unassessedTracks = total - prepared.tracks.length;
330
373
  for (const track of prepared.tracks) {
374
+ // This established evidence contract measures transforms only. Weight-loop
375
+ // continuity needs a separately versioned evidence field, not a scale label.
376
+ if (track.prop === 'morphTargetInfluences') {
377
+ result.unassessedTracks++;
378
+ continue;
379
+ }
331
380
  const name = `${track.nodeName}.${track.prop}`;
332
381
  const valid =
333
382
  !prepared.unresolved.includes(name) &&
@@ -400,16 +449,25 @@ export function poseSceneAtTime(root: DuckNode, prepared: PreparedClip, t: numbe
400
449
  root.traverse?.((obj) => {
401
450
  const node = obj as DuckNode;
402
451
  if (node.name && !byName.has(node.name)) byName.set(node.name, node);
452
+ if (node.uuid) byName.set(node.uuid, node);
403
453
  });
404
454
 
405
455
  const buf: number[] = [0, 0, 0, 0];
406
456
  for (const track of prepared.tracks) {
407
457
  const node = byName.get(track.nodeName);
408
- if (!node) continue;
458
+ if (
459
+ !node ||
460
+ prepared.unresolved.includes(track.sourceName ?? `${track.nodeName}.${track.prop}`)
461
+ )
462
+ continue;
409
463
  sampleTrack(track, t, buf);
410
464
  if (track.prop === 'position') node.position?.set(buf[0]!, buf[1]!, buf[2]!);
411
465
  else if (track.prop === 'scale') node.scale?.set(buf[0]!, buf[1]!, buf[2]!);
412
- else node.quaternion?.set(buf[0]!, buf[1]!, buf[2]!, buf[3]!);
466
+ else if (track.prop === 'quaternion') node.quaternion?.set(buf[0]!, buf[1]!, buf[2]!, buf[3]!);
467
+ else if (node.morphTargetInfluences) {
468
+ if (track.morphIndex !== undefined) node.morphTargetInfluences[track.morphIndex] = buf[0]!;
469
+ else for (let i = 0; i < track.stride; i++) node.morphTargetInfluences[i] = buf[i]!;
470
+ }
413
471
  }
414
472
 
415
473
  root.updateMatrixWorld?.(true);