@volter/editor-threejs 0.5.210 → 0.5.212

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 (133) hide show
  1. package/NOTICE +7 -2
  2. package/README.md +8 -13
  3. package/contributions/animation-mixers.service.ts +2 -4
  4. package/contributions/animation-timeline.utility.tsx +1 -2
  5. package/contributions/three.page.ts +50 -0
  6. package/dist-node/serving.mjs +84 -3
  7. package/dist-node/shared-three.mjs +145 -0
  8. package/package.json +296 -16
  9. package/serving/animation-stamp.ts +1 -1
  10. package/serving/index.ts +22 -5
  11. package/serving/model-import-conversion.ts +6 -3
  12. package/serving/shared-three.ts +176 -0
  13. package/src/adapter/constraint.ts +1 -18
  14. package/src/adapter/object3d-authoring-subject.ts +1 -12
  15. package/src/adapter/reflection-probe.ts +1 -9
  16. package/src/adapter/three-contract.ts +6 -60
  17. package/src/animation/live-mixers.ts +0 -4
  18. package/src/api.ts +7 -0
  19. package/src/asset-loaders.ts +2 -178
  20. package/src/capture/photograph-sampling.ts +333 -0
  21. package/src/capture/scene.ts +1 -1
  22. package/src/ecs/object-marks.ts +6 -14
  23. package/src/ecs/user-data.ts +3 -138
  24. package/src/kit/animation/three-clips-subject.ts +19 -42
  25. package/src/kit/asset-compare-core.ts +161 -0
  26. package/src/kit/asset-compare.ts +8 -27
  27. package/src/kit/asset-preview-command.ts +14 -19
  28. package/src/kit/asset-preview-framing.ts +8 -67
  29. package/src/kit/asset-preview.ts +109 -342
  30. package/src/kit/asset-workflow/model-inspection.ts +11 -154
  31. package/src/kit/asset-workflow/preview-resource-lifetime.ts +25 -0
  32. package/src/kit/authoring/component-instance-root.ts +3 -39
  33. package/src/kit/authoring/live-object-transform.ts +1 -30
  34. package/src/kit/authoring/object3d-document-session-registry.ts +7 -15
  35. package/src/kit/authoring/object3d-document-session.ts +471 -208
  36. package/src/kit/authoring/shell-viewport-policy.ts +18 -24
  37. package/src/kit/authoring/source-object3d-authoring-adapter.ts +6 -48
  38. package/src/kit/authoring/three-projection-core.ts +11 -40
  39. package/src/kit/authoring/viewport-pick-context.ts +5 -12
  40. package/src/kit/authoring/viewport-raycast.ts +15 -93
  41. package/src/kit/authoring/world-hidden-viewport.ts +8 -61
  42. package/src/kit/capture-camera-pose.ts +68 -0
  43. package/src/kit/collaboration-presence.ts +41 -0
  44. package/src/kit/components/CameraInfo.tsx +4 -6
  45. package/src/kit/components/InspectorObjectPreview.tsx +3 -30
  46. package/src/kit/components/Object3DDocumentToolbar.css +75 -0
  47. package/src/kit/components/Object3DDocumentToolbar.tsx +18 -84
  48. package/src/kit/components/Object3DDocumentViewport.tsx +5 -8
  49. package/src/kit/components/StageHost.css +40 -0
  50. package/src/kit/components/StageHost.tsx +181 -404
  51. package/src/kit/components/StageOverlays.tsx +1 -2
  52. package/src/kit/components/StatsOverlay.tsx +5 -17
  53. package/src/kit/components/ViewportFurniture.tsx +27 -154
  54. package/src/kit/components/ViewportOverlay.tsx +13 -17
  55. package/src/kit/components/ViewportOverlaysMenu.tsx +177 -0
  56. package/src/kit/components/ViewportShadingMenu.tsx +2 -42
  57. package/src/kit/components/ViewportViewMenu.tsx +7 -18
  58. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +4 -13
  59. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +5 -5
  60. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +24 -102
  61. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +6 -6
  62. package/src/kit/components/asset-viewers/Object3DPreview.tsx +10 -45
  63. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +2 -2
  64. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +11 -10
  65. package/src/kit/components/asset-viewers/shader-source.ts +144 -0
  66. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +2 -14
  67. package/src/kit/components/object3d-contribution-surfaces.tsx +1 -1
  68. package/src/kit/components/stage-keyboard.tsx +6 -12
  69. package/src/kit/components/stage-overlay-set.tsx +11 -20
  70. package/src/kit/components/stage-presence-markers.ts +9 -32
  71. package/src/kit/components/stage-transform-chrome.ts +4 -4
  72. package/src/kit/components/stage-transform-tools.tsx +7 -14
  73. package/src/kit/components/standard-viewport-dressing.ts +47 -167
  74. package/src/kit/components/world-root-binding.ts +2 -7
  75. package/src/kit/constraint-helper.ts +1 -1
  76. package/src/kit/document-renderer-session.ts +114 -0
  77. package/src/kit/editor-presentation-activity.ts +58 -0
  78. package/src/kit/editor-shell-store.ts +31 -403
  79. package/src/kit/editor-viewport.ts +476 -1762
  80. package/src/kit/entity-lod.ts +3 -16
  81. package/src/kit/entity-object.ts +1 -39
  82. package/src/kit/hierarchy-mark-reader.ts +13 -30
  83. package/src/kit/instanced-presentation.ts +1 -47
  84. package/src/kit/live-gesture-lock.ts +35 -0
  85. package/src/kit/live-module-source.ts +12 -52
  86. package/src/kit/model-thumbnail.ts +7 -35
  87. package/src/kit/play-camera-flight.ts +7 -16
  88. package/src/kit/projection/three.ts +13 -366
  89. package/src/kit/retained-document-states.ts +91 -0
  90. package/src/kit/scene-document-viewport.ts +3 -3
  91. package/src/kit/scene-framing.ts +11 -152
  92. package/src/kit/scene-view-fog.ts +2 -47
  93. package/src/kit/spatial-handle-visuals.ts +19 -28
  94. package/src/kit/stories/three-story-model.ts +5 -33
  95. package/src/kit/three-canvas-render.ts +3 -4
  96. package/src/kit/three-hierarchy-row-media.ts +3 -11
  97. package/src/kit/three-inspection-media.ts +6 -6
  98. package/src/kit/three-integration.ts +18 -18
  99. package/src/kit/three-state.ts +2 -19
  100. package/src/kit/three-viewport/camera-fit.ts +2 -6
  101. package/src/kit/three-viewport/selection-brackets.ts +5 -20
  102. package/src/kit/three-viewport/selection-outline.ts +4 -56
  103. package/src/kit/three-viewport/skeleton-helper.ts +1 -32
  104. package/src/kit/three-viewport/source-color.ts +1 -33
  105. package/src/kit/three-viewport/studio-environment.ts +4 -55
  106. package/src/kit/three-viewport-presentation.ts +23 -0
  107. package/src/kit/transport-strip.tsx +147 -0
  108. package/src/kit/viewport-actions.ts +8 -50
  109. package/src/kit/viewport-authoring-policy.ts +5 -52
  110. package/src/kit/viewport-commands.ts +28 -57
  111. package/src/kit/viewport-hotkeys.ts +29 -31
  112. package/src/kit/viewport-status-facet.ts +4 -5
  113. package/src/kit/viewport-surface-status.tsx +44 -0
  114. package/src/loader.ts +8 -71
  115. package/src/object3d-contributions.ts +18 -34
  116. package/src/viewport/clip-planes.ts +4 -32
  117. package/src/viewport/content-bounds.ts +13 -142
  118. package/src/viewport/editor-layers.ts +4 -26
  119. package/src/viewport/environment.ts +1 -1
  120. package/src/viewport/preview-renderer.ts +7 -45
  121. package/src/viewport/renderer-ownership.ts +14 -62
  122. package/src/viewport-api.ts +14 -33
  123. package/src/viewport-door.ts +16 -86
  124. package/contributions/three-integration.service.ts +0 -13
  125. package/src/adapter/hierarchy-marks.ts +0 -156
  126. package/src/adapter/ingest/scene-capture.ts +0 -843
  127. package/src/adapter/ingest/structural-ids.ts +0 -139
  128. package/src/adapter/ingest/visible-capture-window.ts +0 -302
  129. package/src/adapter/renderer-config.ts +0 -115
  130. package/src/asset-parse-error.ts +0 -29
  131. package/src/host-hierarchy-objects.ts +0 -31
  132. package/src/kit/authoring/design-time-settle.ts +0 -343
  133. package/src/kit/camera-authoring.ts +0 -175
@@ -13,12 +13,11 @@ import type {
13
13
  } from '@volter/sdk';
14
14
  import { EDITOR_LAYER, isEditorOwnedObject } from '@volter/editor-threejs/viewport/editor-layers';
15
15
  import { markHostRenderer } from '@volter/editor-threejs/viewport/renderer-ownership';
16
- import { isBuiltInternal } from '@volter/editor-threejs/adapter/hierarchy-marks';
17
16
  import { loadSplat } from '@volter/editor-threejs/asset-loaders';
18
17
  import { hasUserData } from '@volter/editor-threejs/ecs/user-data';
19
18
  import { gltfLoader } from '@volter/editor-threejs/loader';
20
19
  import * as THREE from 'three';
21
- import { forwardYawRadians, parseForwardVector } from '@volter/sdk/kit/asset-compare-core';
20
+ import { forwardYawRadians, parseForwardVector } from './asset-compare-core';
22
21
  import {
23
22
  ASSET_PREVIEW_PADDING,
24
23
  boneZoomCenter,
@@ -39,37 +38,22 @@ import {
39
38
  turntableViewBasis,
40
39
  unionOrthographicFrames,
41
40
  } from './asset-preview-framing';
42
- import { drawBitmapLabel, measureBitmapLabel } from '@volter/sdk/kit/bitmap-label';
41
+ import { drawBitmapLabel, measureBitmapLabel } from '@volter/sdk/bitmap-label';
43
42
  import {
44
43
  applyStudioEnvironment,
45
44
  disposeStudioEnvironment,
46
45
  STUDIO_AMBIENT_WITH_ENVIRONMENT,
47
46
  } from './three-viewport/studio-environment';
48
47
 
49
- export const ASSET_PREVIEW_VIEWS = ['front', 'right', 'top', 'perspective'] as const;
50
- export type AssetPreviewView = (typeof ASSET_PREVIEW_VIEWS)[number];
48
+ const ASSET_PREVIEW_VIEWS = ['front', 'right', 'top', 'perspective'] as const;
49
+ type AssetPreviewView = (typeof ASSET_PREVIEW_VIEWS)[number];
51
50
  export type AssetPreviewBackground = 'neutral' | 'transparent';
52
51
 
53
- /**
54
- * Where an ENTITY capture is staged.
55
- *
56
- * `'lab'` — the neutral Asset Lab stage {@link captureObjectAssetPreview}
57
- * renders: an isolated, yaw-normalized snapshot under fixed studio lighting,
58
- * identical whatever the entity's surroundings are. It is the default
59
- * everywhere.
60
- *
61
- * `'scene'` — {@link captureSceneStageAssetPreview}: the entity photographed
62
- * WHERE IT STANDS, in the live scene, under the scene's own lighting, with
63
- * the editor's own furniture excluded. Entity captures only — a model loaded
64
- * from an asset path does not stand anywhere.
65
- */
66
- export type AssetPreviewStage = 'lab' | 'scene';
67
-
68
- export interface AssetPreviewOptions {
52
+ interface AssetPreviewOptions {
69
53
  width?: number;
70
54
  height?: number;
71
55
  background?: AssetPreviewBackground;
72
- /** ONE view from a chosen angle instead of the fixed four — see the SDK's
56
+ /** ONE view from a chosen angle instead of the fixed view sheet — see the SDK's
73
57
  * {@link AssetPreviewCameraChoice} for the angle conventions. */
74
58
  camera?: AssetPreviewCameraChoice;
75
59
  /** Sample a named clip at a time on the capture's disposable snapshot
@@ -77,7 +61,7 @@ export interface AssetPreviewOptions {
77
61
  pose?: AssetPreviewPose;
78
62
  }
79
63
 
80
- export interface AssetPreviewImage {
64
+ interface AssetPreviewImage {
81
65
  /** One of the four fixed views, or `below` — the underside tile the lab
82
66
  * stage adds to its sheet (a cap's gills, a barrel's floor). */
83
67
  view: AssetPreviewView | 'below';
@@ -85,26 +69,15 @@ export interface AssetPreviewImage {
85
69
  mimeType: 'image/png';
86
70
  }
87
71
 
88
- /**
89
- * How the captured subject was oriented relative to its AUTHORED coordinates.
90
- *
91
- * `faceFrontSubject` yaw-normalizes a subject so the fixed front camera
92
- * photographs its declared front. For a rig persisting `forward = [0,0,-1]`
93
- * that is a PI yaw — correct photography whose projection maps authored +X to
94
- * screen-LEFT in the front view. Applied silently, that read as "the front
95
- * view mirrors world X" and cost a cold-round agent its geometry reasoning
96
- * (round #4, 2026-08-30), so the yaw is now REPORTED here and stamped onto
97
- * the sheet's own pixels ({@link renderFourViewCapture}'s axis markers) —
98
- * never inferred, never silent.
99
- */
100
- export interface AssetPreviewOrientation {
72
+ /** The yaw applied to align the captured subject’s authored forward with the front camera, also printed on the sheet’s axis markers. */
73
+ interface AssetPreviewOrientation {
101
74
  /** The subject's declared forward, `[0,0,1]` when it declares none. */
102
75
  forward: [number, number, number];
103
76
  /** Yaw applied to face the front camera; 0 means authored axes = world axes. */
104
77
  yawDegrees: number;
105
78
  }
106
79
 
107
- export interface AssetPreviewCapture {
80
+ interface AssetPreviewCapture {
108
81
  width: number;
109
82
  height: number;
110
83
  orientation: AssetPreviewOrientation;
@@ -117,33 +90,12 @@ export interface AssetPreviewCapture {
117
90
  };
118
91
  }
119
92
 
120
- export interface AssetPreviewFraming {
93
+ interface AssetPreviewFraming {
121
94
  center: THREE.Vector3;
122
95
  size: THREE.Vector3;
123
96
  radius: number;
124
97
  }
125
98
 
126
- /**
127
- * The shot-set contract is declared ONCE, in `@volter/sdk`
128
- * (`packages/sdk/src/types.ts`), because it crosses the editor relay:
129
- * a capability tool authors a set, the CLI carries it over, and THIS module is
130
- * the generic capture engine that renders it. Importing rather than
131
- * re-declaring is what makes `parseShotSetDefinition`'s return annotation
132
- * below a real drift check against the wire type — see the SDK block's own
133
- * comment for the contract and for what a pose step's `radians` is measured
134
- * from.
135
- */
136
- export type {
137
- AssetPreviewShotSetDefinition,
138
- AssetPreviewShotWarning,
139
- LabeledShotSetCapture,
140
- ShotSetPoseMorph,
141
- ShotSetPoseRotation,
142
- ShotSetPoseStep,
143
- ShotSetPoseTranslation,
144
- ShotSetShot,
145
- } from '@volter/sdk';
146
-
147
99
  /** Definitions cross the editor relay as untyped JSON from project-registered
148
100
  * tools; capped so one capture stays within the relay's request budget. */
149
101
  const MAX_SHOT_SET_SHOTS = 24;
@@ -259,8 +211,8 @@ export function parseShotSetDefinition(value: unknown): AssetPreviewShotSetDefin
259
211
  for (const [poseName, steps] of Object.entries(poses)) {
260
212
  if (!Array.isArray(steps) || !steps.every(isPoseStep)) {
261
213
  fail(
262
- `pose ${JSON.stringify(poseName)} must be an array of {bone, axis, radians} ` +
263
- 'and/or {morph, influence}.',
214
+ `pose ${JSON.stringify(poseName)} must be an array of {bone, axis, radians}, ` +
215
+ '{bone, axis, meters}, and/or {morph, influence}.',
264
216
  );
265
217
  }
266
218
  }
@@ -289,17 +241,15 @@ export function parseShotSetDefinition(value: unknown): AssetPreviewShotSetDefin
289
241
  return definition as AssetPreviewShotSetDefinition;
290
242
  }
291
243
 
292
- export const SOURCE_REVIEW_SHOT_LABELS = [
293
- 'front',
294
- 'back',
295
- 'left',
296
- 'right',
297
- 'quarter-left',
298
- 'quarter-right',
299
- ] as const;
300
- export type SourceReviewShotLabel = (typeof SOURCE_REVIEW_SHOT_LABELS)[number];
244
+ type SourceReviewShotLabel =
245
+ | 'front'
246
+ | 'back'
247
+ | 'left'
248
+ | 'right'
249
+ | 'quarter-left'
250
+ | 'quarter-right';
301
251
 
302
- export interface SourceReviewShotSetCapture {
252
+ interface SourceReviewShotSetCapture {
303
253
  width: number;
304
254
  height: number;
305
255
  shots: Array<{ label: SourceReviewShotLabel; base64: string; mimeType: 'image/png' }>;
@@ -318,8 +268,6 @@ type MorphableObject = THREE.Object3D & {
318
268
 
319
269
  const DEFAULT_SIZE = 512;
320
270
  const MIN_SIZE = 64;
321
- // Four views plus the 2x2 sheet cross the editor relay as base64 JSON. At
322
- // 1024px even incompressible RGBA data remains below its 50 MB request limit.
323
271
  const MAX_SIZE = 1024;
324
272
  const NEUTRAL_COLOR = 0x20242a;
325
273
  const MODEL_FETCH_TIMEOUT_MS = 25_000;
@@ -335,21 +283,12 @@ export function checkedDimension(value: number | undefined): number {
335
283
 
336
284
  export function pngBase64(canvas: HTMLCanvasElement): string {
337
285
  const dataUrl = canvas.toDataURL('image/png');
338
- const comma = dataUrl.indexOf(',');
339
- return comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
286
+ return dataUrl.slice(dataUrl.indexOf(',') + 1);
340
287
  }
341
288
 
342
289
  /**
343
- * The node a preview must snapshot for `object`: itself, unless a SkinnedMesh
344
- * under it binds to bones OUTSIDE it — then the nearest ancestor that owns
345
- * both the mesh and every bone, the skeleton root the refusal in
346
- * `resolveExternalBones` names. A skinned mesh sits BESIDE its Armature in
347
- * three's own glTF layout (`Scene > [SkinnedMesh, Armature > bones]`), so a
348
- * click on a character lands on the mesh, and the mesh alone cannot be drawn
349
- * without the bones that pose it: every Humanoid exhibit's Inspector preview
350
- * was the red refusal listing all 68 bones (runhuman pass 143; traced live —
351
- * selecting the rig root renders a clean bind pose). A bone whose chain never
352
- * meets `object`'s stays external, so the honest refusal still fires there.
290
+ * Choose an ancestor containing the selected object and its referenced bones where their
291
+ * hierarchies meet. Unrelated bones remain external for resolveExternalBones to refuse.
353
292
  */
354
293
  export function assetPreviewSubject(object: THREE.Object3D): THREE.Object3D {
355
294
  const inside = new Set<THREE.Object3D>();
@@ -386,14 +325,10 @@ export function assetPreviewSubject(object: THREE.Object3D): THREE.Object3D {
386
325
  * snapshot shares immutable geometry/material resources.
387
326
  */
388
327
  export function createAssetPreviewSnapshot(source: THREE.Object3D): THREE.Object3D {
389
- // A skinned mesh can point at bones that are not in the selected subtree —
390
- // in practice because the model was duplicated with `Object3D.clone()`
391
- // instead of `SkeletonUtils.clone()`, which copies the bone nodes but leaves
392
- // every clone's `skeleton` referencing the ORIGINAL bones. Where the subtree
393
- // owns a same-named bone for each of them (it does for exactly that mount),
394
- // the snapshot binds to its own copies — the pose those copies carry is the
395
- // one the mount intended. Anything left unresolved is refused BY NAME below,
396
- // because binding to a bone that is not there would be an invented pose.
328
+ // A skinned mesh can reference bones outside the selected subtree. Resolve
329
+ // each reference to an unambiguous same-named bone owned by the subtree, then
330
+ // bind the snapshot to its own copy of that pose. Unresolved references are
331
+ // refused by name rather than inventing a pose from absent bones.
397
332
  const sourceNodes = new Set<THREE.Object3D>();
398
333
  source.traverse((object) => {
399
334
  sourceNodes.add(object);
@@ -415,13 +350,6 @@ export function createAssetPreviewSnapshot(source: THREE.Object3D): THREE.Object
415
350
  // scene; descendant visibility remains authored and the source is untouched.
416
351
  snapshot.visible = true;
417
352
 
418
- // Editor-only helpers are presentation siblings/children, never asset data.
419
- const helpers: THREE.Object3D[] = [];
420
- snapshot.traverse((object) => {
421
- if (object !== snapshot && object.userData['editorHelper']) helpers.push(object);
422
- });
423
- for (const helper of helpers) helper.parent?.remove(helper);
424
-
425
353
  return snapshot;
426
354
  }
427
355
 
@@ -446,18 +374,7 @@ function objectWorldMatrix(object: THREE.Object3D): THREE.Matrix4 {
446
374
  return world;
447
375
  }
448
376
 
449
- /**
450
- * For every bone a skinned mesh in `source` references from OUTSIDE `source`,
451
- * the same-named bone inside it — or a thrown refusal naming what could not be
452
- * resolved. An empty map means the subtree's skeletons are already complete.
453
- *
454
- * The unresolvable case is real (a bone with no name, or one whose owner truly
455
- * lives above the selection), and it stays an error: the alternative is a
456
- * picture of a mesh bound to bones the caller never selected. What changed is
457
- * that the error now says WHICH mesh and WHICH bones, because "Select the
458
- * complete skeleton root" with nothing named is a dead end — there is no way
459
- * to act on it from the terminal that produced it.
460
- */
377
+ /** Resolve every external skeleton reference to a same-named bone inside source. Return an empty map for a complete subtree; refuse with mesh and bone names when a reference cannot resolve. */
461
378
  function resolveExternalBones(
462
379
  source: THREE.Object3D,
463
380
  sourceNodes: ReadonlySet<THREE.Object3D>,
@@ -485,14 +402,8 @@ function resolveExternalBones(
485
402
  const meshName = skinned.name || skinned.uuid;
486
403
  for (const bone of skinned.skeleton.bones) {
487
404
  if (bone && sourceNodes.has(bone)) continue;
488
- // Substitution is a per-BONE fact, so an answer found for one mesh holds
489
- // for every other mesh referencing the same bone object — and they do:
490
- // GLTFLoader gives every primitive of one skin a SkinnedMesh bound to the
491
- // SAME Skeleton, which is the ordinary body+outfit character. Reading
492
- // this as "already handled, nothing to do here" and falling through to
493
- // the unresolved ledger refused exactly those models, with a sentence
494
- // ("owns no bone of the same name") that the first mesh had just
495
- // disproved.
405
+ // Bone substitution is shared across meshes referencing the same bone object, including glTF
406
+ // primitives bound to one skeleton.
496
407
  if (bone && substitutes.has(bone)) continue;
497
408
  const candidates = bone ? (nameIndex().get(bone.name) ?? []) : [];
498
409
  if (candidates.length === 1) {
@@ -509,10 +420,7 @@ function resolveExternalBones(
509
420
  });
510
421
 
511
422
  if (unresolved.size > 0) {
512
- // Two refusals, and they are NOT the same fact: nothing of that name to
513
- // bind to, versus several and no way to tell which was meant. Reporting
514
- // one as the other is how an un-actionable error survives a fix that was
515
- // supposed to remove it.
423
+ // Distinguish a missing same-named bone from several ambiguous candidates in the refusal.
516
424
  const detail = [...unresolved.values()]
517
425
  .map(({ mesh, bone, sameNamed }) =>
518
426
  sameNamed > 1
@@ -543,7 +451,7 @@ function resolveExternalBones(
543
451
  */
544
452
  function cloneRenderableHierarchy(
545
453
  source: THREE.Object3D,
546
- boneSubstitutes: ReadonlyMap<THREE.Bone, THREE.Bone> = new Map(),
454
+ boneSubstitutes: ReadonlyMap<THREE.Bone, THREE.Bone>,
547
455
  ): THREE.Object3D {
548
456
  const cloneBySource = new Map<THREE.Object3D, THREE.Object3D>();
549
457
  const skinnedPairs: Array<[THREE.SkinnedMesh, THREE.SkinnedMesh]> = [];
@@ -760,7 +668,7 @@ export function createOwnedObjectPreviewSnapshot(
760
668
 
761
669
  export function measureAssetPreview(
762
670
  object: THREE.Object3D,
763
- view: AssetPreviewView = 'front',
671
+ view: AssetPreviewView,
764
672
  ): AssetPreviewFraming {
765
673
  resolveWorldMatricesLikeRenderer(object);
766
674
  const box = readOnlyRenderableBounds(object, view);
@@ -774,33 +682,7 @@ export function measureAssetPreview(
774
682
  };
775
683
  }
776
684
 
777
- /**
778
- * Bring a subtree's world matrices to exactly the state the RENDERER will
779
- * compute for it, then measure from that.
780
- *
781
- * This is not interchangeable with `updateWorldMatrix`, and the difference
782
- * is measurable rather than stylistic: `SkinnedMesh` OVERRIDES
783
- * `updateMatrixWorld` to refresh `bindMatrixInverse` from its current world
784
- * matrix (attached bind mode), and `getVertexPosition` — how this module
785
- * reads skinned geometry — runs the vertex through
786
- * `bindMatrixInverse * boneWorld * boneInverse * bindMatrix`. Measure after
787
- * `updateWorldMatrix` alone and `bindMatrixInverse` is still the value from
788
- * before the subject was re-parented (for example under
789
- * {@link faceFrontSubject}'s yaw pivot), so the mesh's own transform gets
790
- * applied TWICE to every skinned vertex while the render applies it once.
791
- *
792
- * A yaw pivot of PI (our generated rigs persist `forward = [0,0,-1]`)
793
- * therefore mirrored the measured bounds in Z: the size stayed right and the
794
- * CENTRE flipped sign, which is invisible when the camera looks down Z
795
- * (front/back) and pushes the subject bodily out of frame when it looks
796
- * down X (left/right). That is the whole of the "long body runs off the
797
- * frame edge at yaw +/-PI/2" defect.
798
- *
799
- * `updateMatrixWorld(force)` is used deliberately in preference to
800
- * `updateWorldMatrix`: it honours `matrixWorldAutoUpdate` exactly as the
801
- * renderer's own `scene.updateMatrixWorld()` does, so a snapshot that pins
802
- * an exact composed matrix is measured the way it is drawn.
803
- */
685
+ /** Update matrices through the renderer’s updateMatrixWorld path before measuring, including SkinnedMesh.bindMatrixInverse and matrixWorldAutoUpdate. */
804
686
  function resolveWorldMatricesLikeRenderer(object: THREE.Object3D): void {
805
687
  object.parent?.updateWorldMatrix(true, false);
806
688
  object.updateMatrixWorld(true);
@@ -832,7 +714,6 @@ function measureProjectedSpans(
832
714
  bases: readonly OrthographicViewBasis[],
833
715
  ): ProjectedSpan[] {
834
716
  const spans = bases.map(() => createProjectedSpan());
835
- if (bases.length === 0) return spans;
836
717
  resolveWorldMatricesLikeRenderer(root);
837
718
  forEachRenderableVertex(root, bases[0]!, false, (point) => {
838
719
  for (let index = 0; index < bases.length; index++) {
@@ -867,7 +748,6 @@ function measureShotFrameCoverage(
867
748
  ): { framed: number[]; total: number } {
868
749
  const framed = frames.map(() => 0);
869
750
  let total = 0;
870
- if (frames.length === 0) return { framed, total };
871
751
  resolveWorldMatricesLikeRenderer(subject);
872
752
  const spans = frames.map(() => createProjectedSpan());
873
753
  let open = -1;
@@ -918,14 +798,6 @@ function forEachRenderableVertex(
918
798
  let primitive = 0;
919
799
  const visit = (object: THREE.Object3D): void => {
920
800
  if (!object.visible) return;
921
- // A live scene subject (`captureSceneStageAssetPreview`) carries its game's
922
- // implementation as real children — a world-space particle renderer at
923
- // identity, a pooled batch — and framing on those puts the subject in a
924
- // corner of its own portrait. Subtree-scoped, same rule as the selection
925
- // cage (`content-bounds.ts`); a loaded model file carries no marks, so the
926
- // asset lanes are unaffected. The ROOT is always measured: a caller who
927
- // named it meant it.
928
- if (object !== root && isBuiltInternal(object)) return;
929
801
  if (hasUserData(object, 'gaussianSplat')) {
930
802
  splatBounds.copy((object as SplatMesh).getBoundingBox()).applyMatrix4(object.matrixWorld);
931
803
  const splatPrimitive = primitive++;
@@ -1108,12 +980,7 @@ function orthographicCamera(
1108
980
 
1109
981
  if (view === 'right') {
1110
982
  horizontal = size.z;
1111
- } else {
1112
- if (view === 'top') {
1113
- horizontal = size.x;
1114
- vertical = size.z;
1115
- }
1116
- }
983
+ } else if (view === 'top') vertical = size.z;
1117
984
 
1118
985
  const frame = fitOrthographicFrame(horizontal / 2, vertical / 2, aspect);
1119
986
  return createOrthographicShotCamera(
@@ -1187,7 +1054,7 @@ function assetViewBasis(view: AssetPreviewView): {
1187
1054
  return { direction, right, up };
1188
1055
  }
1189
1056
 
1190
- export function createAssetPreviewCamera(
1057
+ function createAssetPreviewCamera(
1191
1058
  view: AssetPreviewView,
1192
1059
  framing: AssetPreviewFraming,
1193
1060
  aspect: number,
@@ -1207,22 +1074,7 @@ export function createPerspectiveAssetPreviewCamera(
1207
1074
  return perspectiveCamera(framing, aspect, direction);
1208
1075
  }
1209
1076
 
1210
- /**
1211
- * The turntable cameras for one staged subject, framed from what each yaw
1212
- * ACTUALLY sees.
1213
- *
1214
- * Every yaw of a subject is measured separately (an oriented projection of
1215
- * the rendered vertices onto that yaw's screen axes), each shot is centred
1216
- * on its own projection, and the SCALE is the union across the set's yaws —
1217
- * see {@link unionOrthographicFrames} for why the union rather than a
1218
- * per-shot exact fit. The depth budget still comes from the subject's own
1219
- * bounding radius, which no yaw can exceed.
1220
- *
1221
- * The predecessor derived every yaw from one world-axis-aligned box, which
1222
- * both over-framed a bent, elongated subject and — because it centred every
1223
- * yaw on that box's centre — placed the subject wrong the moment the
1224
- * measured box and the drawn subject disagreed.
1225
- */
1077
+ /** Frame each turntable yaw from its projected rendered vertices, centring each view independently and sharing the union scale. Use the subject radius for depth. */
1226
1078
  function turntableCameras(
1227
1079
  subject: THREE.Object3D,
1228
1080
  framing: AssetPreviewFraming,
@@ -1250,13 +1102,13 @@ function turntableCameras(
1250
1102
  return cameras;
1251
1103
  }
1252
1104
 
1253
- /** Six source-review views aligned to the asset's declared semantic forward
1254
- * rather than mislabeled world axes. This is bounding-box framing only: it
1255
- * does not require or modify a skeleton. */
1105
+ /** Six source-review views aligned to the asset's declared semantic forward.
1106
+ * Cardinal views share a scale fitted to projected rendered vertices; quarter
1107
+ * views fit the bounds. No skeleton is required or modified. */
1256
1108
  export function captureSourceReviewShotSetAssetPreview(
1257
1109
  source: THREE.Object3D,
1258
1110
  forward: readonly [number, number, number],
1259
- options: AssetPreviewOptions = {},
1111
+ options: AssetPreviewOptions,
1260
1112
  ): SourceReviewShotSetCapture {
1261
1113
  const width = checkedDimension(options.width);
1262
1114
  const height = checkedDimension(options.height);
@@ -1361,23 +1213,9 @@ export function captureSourceReviewShotSetAssetPreview(
1361
1213
  }
1362
1214
 
1363
1215
  /**
1364
- * A tight orthographic close-up that frames every given world-space anchor
1365
- * point (plus a margin proportional to the WHOLE MODEL's own bounding
1366
- * radius — never a fixed reference-human height, so a goblin's crop stays
1367
- * goblin-scaled). A single anchor (head, one shoulder) yields a fixed-size
1368
- * crop around that point; two anchors that are naturally far apart in a
1369
- * T-pose rig (both hands, both feet) still get a camera wide/tall enough to
1370
- * hold both — the historical fixed-height-zoom defect the line-up harness's
1371
- * own `zoom=head` fix (B5.1) already had to correct once, generalized here
1372
- * to "the crop must actually contain what it claims to frame" for every
1373
- * region, not just proportional scaling.
1374
- *
1375
- * The crop is taken from the shot's own `yaw` (the turntable convention;
1376
- * omitted means 0, the front camera this used to be hardcoded to). An angle
1377
- * is not decoration on a long subject: a junction whose axis runs down the
1378
- * body's length — a tail root, a hip chain, a sail seen edge-on — has the
1379
- * body itself standing between the front camera and the anchor, so the crop
1380
- * frames the right point and photographs the wrong thing.
1216
+ * Frame every world-space bone anchor with an orthographic close-up. Scale the margin and minimum
1217
+ * crop size by the whole model’s bounding radius, and expand the crop to contain separated anchors.
1218
+ * Use the shot’s yaw with the turntable convention; omitted yaw selects the front view.
1381
1219
  */
1382
1220
  function boneZoomCamera(
1383
1221
  anchors: readonly THREE.Vector3[],
@@ -1398,11 +1236,8 @@ function boneZoomCamera(
1398
1236
  );
1399
1237
  }
1400
1238
 
1401
- /** Locate every bone a shot-set definition requires on the loaded GLB's OWN
1402
- * skeleton (never a body-engine assumption). Throws loudly, naming exactly
1403
- * which joints are missing, when the model has no rig at all or is missing
1404
- * a required joint — no silent fallback to a generic bounding-box zoom for
1405
- * a set that promised skeleton-anchored framing. */
1239
+ /** Locate every required bone on the source's skeleton. Refuse absent rigs
1240
+ * and missing joints by name before framing a skeleton-anchored shot. */
1406
1241
  function findRequiredSkeletonBones(
1407
1242
  root: THREE.Object3D,
1408
1243
  definition: AssetPreviewShotSetDefinition,
@@ -1417,13 +1252,13 @@ function findRequiredSkeletonBones(
1417
1252
  const hint = definition.rigRequirementHint ? ` (${definition.rigRequirementHint})` : '';
1418
1253
  if (required.length > 0 && bonesByName.size === 0) {
1419
1254
  throw new Error(
1420
- `Asset preview '--shots ${definition.name}' requires a rigged GLB${hint}, but this ` +
1255
+ `Asset preview shot set '${definition.name}' requires a rigged model${hint}, but this ` +
1421
1256
  `model has no skeleton at all. Required joints: ${required.join(', ')}.`,
1422
1257
  );
1423
1258
  }
1424
1259
  if (missing.length > 0) {
1425
1260
  throw new Error(
1426
- `Asset preview '--shots ${definition.name}' requires joints this GLB's skeleton does ` +
1261
+ `Asset preview shot set '${definition.name}' requires joints this model's skeleton does ` +
1427
1262
  `not have${hint}. Missing joint(s): ${missing.join(', ')}.`,
1428
1263
  );
1429
1264
  }
@@ -1432,8 +1267,8 @@ function findRequiredSkeletonBones(
1432
1267
 
1433
1268
  /**
1434
1269
  * Apply one of the definition's named poses to a snapshot in place — bone
1435
- * rotations on the skeleton, morph influences on whatever meshes declare
1436
- * them.
1270
+ * rotations/translations on the skeleton, morph influences on meshes that
1271
+ * declare them.
1437
1272
  *
1438
1273
  * BOTH halves degrade rather than throw, for the same reason: a pose may
1439
1274
  * touch a joint or a morph beyond the definition's `requiredBones` (a rig can
@@ -1479,9 +1314,7 @@ function applyShotSetPose(
1479
1314
  });
1480
1315
  }
1481
1316
 
1482
- /** First valid `userData.forward` in the tree (our baked rigs persist it on
1483
- * the rig root, which sits below the GLTF scene root), else glTF's +Z.
1484
- * Shared with the compare mode (`asset-compare.ts` re-exports it). */
1317
+ /** Read the first valid `userData.forward` in the subtree, defaulting to glTF's +Z. */
1485
1318
  export function readModelForward(root: THREE.Object3D): [number, number, number] {
1486
1319
  let found: [number, number, number] | null = null;
1487
1320
  root.traverse((object) => {
@@ -1491,19 +1324,9 @@ export function readModelForward(root: THREE.Object3D): [number, number, number]
1491
1324
  return found ?? [0, 0, 1];
1492
1325
  }
1493
1326
 
1494
- /**
1495
- * Face-the-camera normalization (the compare path's forward detection,
1496
- * applied to the labeled shot sets): wrap a snapshot in a pivot yawed so the
1497
- * model's own forward faces +Z — the direction every 'front' camera looks
1498
- * from. Models without forward extras keep the glTF +Z convention (yaw 0, an
1499
- * exact no-op), so arbitrary GLBs render exactly as before; our generated
1500
- * humanoids persist `userData.forward = [0,0,-1]` and previously had their
1501
- * BACK photographed by every front-labeled shot.
1502
- *
1503
- * `forward` must be read from the SOURCE object (the compare path does the
1504
- * same): render snapshots deliberately copy presentation state only, so the
1505
- * forward extras never survive onto the snapshot itself.
1506
- */
1327
+ /** Yaw a snapshot so the model’s forward faces the front camera at +Z. Read forward from the
1328
+ * source object because snapshots copy presentation state without project extras. Missing
1329
+ * forward extras use glTF’s +Z convention and need no rotation. */
1507
1330
  function faceFrontSubject(
1508
1331
  snapshot: THREE.Object3D,
1509
1332
  forward: readonly [number, number, number],
@@ -1528,15 +1351,9 @@ interface StagedShotSubject {
1528
1351
  }
1529
1352
 
1530
1353
  /**
1531
- * The Asset Lab's studio stage: the subject, a hemisphere ambient, a key and a
1532
- * fill. The IBL that completes it is applied SEPARATELY
1533
- * ({@link applyStudioEnvironment}), because a PMREM bake needs a live
1534
- * renderer and every caller here builds its scene before its renderer.
1535
- *
1536
- * The hemisphere is scaled down by {@link STUDIO_AMBIENT_WITH_ENVIRONMENT}:
1537
- * with an environment map in the scene it is the second, cruder ambient term,
1538
- * and at its historical 1.8 the two together flattened the subject. Key and
1539
- * fill are untouched — they still own the form.
1354
+ * Build the Asset Lab studio scene with hemisphere, key, fill and underside lights.
1355
+ * applyStudioEnvironment adds the renderer-dependent IBL separately. Scale the
1356
+ * hemisphere by STUDIO_AMBIENT_WITH_ENVIRONMENT to balance its ambient term with the IBL.
1540
1357
  */
1541
1358
  function createPreviewScene(snapshot: THREE.Object3D, framing: AssetPreviewFraming): THREE.Scene {
1542
1359
  const scene = new THREE.Scene();
@@ -1552,11 +1369,7 @@ function createPreviewScene(snapshot: THREE.Object3D, framing: AssetPreviewFrami
1552
1369
  .copy(framing.center)
1553
1370
  .addScaledVector(new THREE.Vector3(-1, 0.5, -0.7), framing.radius * 2);
1554
1371
  scene.add(fill);
1555
- // An UNDER fill, weak and warm, so a shot from below (`--elevation -60`:
1556
- // gills under a cap, a barrel's closed bottom) reads as a surface instead
1557
- // of a black disc. Measured on the blind modeling bench (round 10): an
1558
- // agent spent three looks and a probe deciding whether an unlit underside
1559
- // was closed. Tops are unchanged — this light sees only downward faces.
1372
+ // A weak warm fill from below makes downward-facing surfaces visible in underside shots.
1560
1373
  const under = new THREE.DirectionalLight(0xfff1dc, 0.45);
1561
1374
  under.position
1562
1375
  .copy(framing.center)
@@ -1596,7 +1409,7 @@ if (import.meta.hot) import.meta.hot.dispose(disposeObjectCaptureRenderer);
1596
1409
 
1597
1410
  export function captureObjectAssetPreview(
1598
1411
  source: THREE.Object3D,
1599
- options: AssetPreviewOptions = {},
1412
+ options: AssetPreviewOptions,
1600
1413
  ): AssetPreviewCapture {
1601
1414
  let containsSplat = false;
1602
1415
  source.traverse((object) => {
@@ -1657,8 +1470,7 @@ export function captureObjectAssetPreview(
1657
1470
  createAssetPreviewCamera(view, measureAssetPreview(subject, view), width / height),
1658
1471
  );
1659
1472
  }
1660
- // The underside, from 30° round and 60° below — the frame the look used
1661
- // to take as a separate second capture and write as `below.png`.
1473
+ // Add an underside view from 30° azimuth and −60° elevation.
1662
1474
  const below = perspectiveCamera(
1663
1475
  framing,
1664
1476
  width / height,
@@ -1725,7 +1537,7 @@ function applyClipPose(snapshot: THREE.Object3D, pose: AssetPreviewPose): void {
1725
1537
  `Asset preview pose time must be a finite number of seconds >= 0, got ${pose.timeSeconds}.`,
1726
1538
  );
1727
1539
  }
1728
- const clips = snapshot.animations ?? [];
1540
+ const clips = snapshot.animations;
1729
1541
  const clip = clips.find((candidate) => candidate.name === pose.clip);
1730
1542
  if (!clip) {
1731
1543
  const available = clips.map((candidate) => `'${candidate.name}'`).join(', ');
@@ -1807,9 +1619,8 @@ function renderFreeCameraCapture(
1807
1619
  /**
1808
1620
  * The axis marker for one orthographic view: which AUTHORED axis points
1809
1621
  * screen-right after the subject's face-front yaw, spelled as `+X>` (that
1810
- * axis points right) or `<+X` (it points left). `null` for the perspective
1811
- * view, and for the rare non-cardinal yaw — where the marker instead names
1812
- * the yaw itself so nothing is silently askew.
1622
+ * axis points right) or `<+X` (it points left). Perspective views return null;
1623
+ * non-cardinal orientations name their yaw.
1813
1624
  */
1814
1625
  function authoredAxisMarker(
1815
1626
  view: AssetPreviewView,
@@ -1832,14 +1643,7 @@ function authoredAxisMarker(
1832
1643
  }
1833
1644
 
1834
1645
  /**
1835
- * Render the four fixed views and assemble the labeled 2x2 contact sheet.
1836
- *
1837
- * Extracted verbatim from {@link captureObjectAssetPreview}'s own loop (its
1838
- * only caller until the scene stage below): the two stages differ in WHAT
1839
- * they photograph — an isolated snapshot on a studio stage versus the entity
1840
- * standing in the live scene — never in how the sheet is assembled, so a
1841
- * caller comparing a lab and a scene capture is comparing subjects rather
1842
- * than two independently-drifting sheet builders.
1646
+ * Render four fixed views and a 2×2 contact sheet, or a 3×2 sheet when the lab includes its underside view.
1843
1647
  */
1844
1648
  function renderFourViewCapture(
1845
1649
  renderer: THREE.WebGLRenderer,
@@ -1848,10 +1652,7 @@ function renderFourViewCapture(
1848
1652
  width: number,
1849
1653
  height: number,
1850
1654
  orientation: AssetPreviewOrientation,
1851
- /** A fifth camera, the UNDERSIDE, drawn as the sheet's fifth tile (3 × 2).
1852
- * Every blind modeling session read the sheet and then `below.png` as a
1853
- * second image, ten seconds of generation per look; one sheet that carries
1854
- * the underside is one read (2026-09-06). */
1655
+ /** The underside camera occupies the fifth tile of the 3 × 2 contact sheet. */
1855
1656
  below?: THREE.Camera,
1856
1657
  ): AssetPreviewCapture {
1857
1658
  const views: AssetPreviewImage[] = [];
@@ -1874,8 +1675,7 @@ function renderFourViewCapture(
1874
1675
  }
1875
1676
  renderer.setSize(width, height, false);
1876
1677
  tiles.forEach((view, index) => {
1877
- const camera = view === 'below' ? below : cameras.get(view);
1878
- if (!camera) return;
1678
+ const camera = view === 'below' ? below! : cameras.get(view)!;
1879
1679
  renderer.render(scene, camera);
1880
1680
  views.push({
1881
1681
  view,
@@ -1934,7 +1734,7 @@ function renderFourViewCapture(
1934
1734
  * thing here that is not obviously self-restoring: it is undone in `finally`,
1935
1735
  * so a capture that throws mid-render still hands the human back their grid
1936
1736
  * and gizmos. The renderer is this function's own (created and destroyed
1937
- * here, exactly as every other capture in this module does) — the editor's
1737
+ * here) — the editor's
1938
1738
  * viewport renderer and camera are never borrowed, so there is nothing of
1939
1739
  * theirs to put back.
1940
1740
  *
@@ -1944,7 +1744,7 @@ function renderFourViewCapture(
1944
1744
  export function captureSceneStageAssetPreview(
1945
1745
  source: THREE.Object3D,
1946
1746
  scene: THREE.Scene,
1947
- options: AssetPreviewOptions = {},
1747
+ options: AssetPreviewOptions,
1948
1748
  ): AssetPreviewCapture {
1949
1749
  let containsSplat = false;
1950
1750
  source.traverse((object) => {
@@ -2016,7 +1816,7 @@ export function captureSceneStageAssetPreview(
2016
1816
  }
2017
1817
 
2018
1818
  /** One shot, staged and framed but not yet rendered. */
2019
- export interface PlannedShot {
1819
+ interface PlannedShot {
2020
1820
  label: string;
2021
1821
  camera: THREE.Camera;
2022
1822
  scene: THREE.Scene;
@@ -2027,7 +1827,7 @@ export interface PlannedShot {
2027
1827
 
2028
1828
  /** Everything {@link captureShotSetAssetPreview} needs before it touches a
2029
1829
  * GPU, plus the snapshots that staging allocated. */
2030
- export interface ShotSetCapturePlan {
1830
+ interface ShotSetCapturePlan {
2031
1831
  shots: PlannedShot[];
2032
1832
  warnings: AssetPreviewShotWarning[];
2033
1833
  dispose(): void;
@@ -2059,28 +1859,19 @@ function emptyFrameWarning(
2059
1859
  ...(shot.view === 'bone-zoom' ? { bones: [...shot.bones] } : {}),
2060
1860
  ...(shot.pose === undefined ? {} : { pose: shot.pose }),
2061
1861
  message:
2062
- `Asset preview '--shots ${definitionName}' shot ${JSON.stringify(shot.label)} (${aimed}` +
1862
+ `Asset preview shot set '${definitionName}' shot ${JSON.stringify(shot.label)} (${aimed}` +
2063
1863
  `${posed}) framed NONE of the subject's ${totalPrimitives} rendered faces: the image is ` +
2064
1864
  `background only and proves nothing about the asset. ${cause}`,
2065
1865
  };
2066
1866
  }
2067
1867
 
2068
1868
  /**
2069
- * Stage and frame a labeled shot set against the MODEL'S OWN skeleton and
2070
- * bounds (no assumed proportions), WITHOUT rendering: turntable angles and
2071
- * tight bone-anchored zooms, per the project-supplied definition. Shots that
2072
- * name a pose are staged on a disposable POSED snapshot (per named pose) so
2073
- * the rest-pose shots and the loaded source model are never affected. Zoom
2074
- * `spanFraction`s size each crop relative to the whole model's own bounding
2075
- * radius — a short "goblin" rig gets a goblin-scaled crop, never a fixed
2076
- * reference-human height.
2077
- *
2078
- * Split out of {@link captureShotSetAssetPreview} so that everything the
2079
- * capture DECIDES — which snapshot, which camera, and whether a shot frames
2080
- * any geometry at all — is reachable without a WebGL context, for the same
2081
- * reason `asset-preview-framing.ts` exists. The caller owns `dispose()`.
1869
+ * Stage and frame a labeled shot set using the model’s skeleton and bounds without rendering. Each
1870
+ * named pose uses a disposable snapshot; rest shots and the loaded source remain unchanged.
1871
+ * Turntable angles and bone crops use the supplied definition, with spanFraction scaled to the
1872
+ * whole model’s radius. The caller owns dispose().
2082
1873
  */
2083
- export function planShotSetCapture(
1874
+ function planShotSetCapture(
2084
1875
  source: THREE.Object3D,
2085
1876
  definition: AssetPreviewShotSetDefinition,
2086
1877
  aspect: number,
@@ -2094,11 +1885,8 @@ export function planShotSetCapture(
2094
1885
  const snapshot = createAssetPreviewSnapshot(source);
2095
1886
  snapshots.push(snapshot);
2096
1887
  const bonesByName = findRequiredSkeletonBones(snapshot, definition);
2097
- // Yaw-normalize the subject to face the front camera (the compare
2098
- // path's forward detection — see {@link faceFrontSubject}): turntable
2099
- // labels AND the +Z-fixed zoom crops are only true to the model's
2100
- // facing once its forward actually points at them. Bone anchors are
2101
- // read AFTER the wrap, so the zoom framing follows the rotation.
1888
+ // Normalize forward before framing so turntable labels and yaw-based bone crops use the model’s
1889
+ // facing. Read bone anchors after that rotation.
2102
1890
  const forward = readModelForward(source);
2103
1891
  const subject = faceFrontSubject(snapshot, forward);
2104
1892
  const framing = measureAssetPreview(subject, 'perspective');
@@ -2112,7 +1900,7 @@ export function planShotSetCapture(
2112
1900
  const poseStages = new Map<string, StagedShotSubject>();
2113
1901
  for (const shot of definition.shots) {
2114
1902
  if (shot.pose === undefined || poseStages.has(shot.pose)) continue;
2115
- const steps = definition.poses?.[shot.pose] ?? [];
1903
+ const steps = definition.poses![shot.pose]!;
2116
1904
  const poseSnapshot = createAssetPreviewSnapshot(source);
2117
1905
  snapshots.push(poseSnapshot);
2118
1906
  const poseBones = findRequiredSkeletonBones(poseSnapshot, definition);
@@ -2140,13 +1928,13 @@ export function planShotSetCapture(
2140
1928
  for (const pose of new Set(
2141
1929
  definition.shots
2142
1930
  .filter((shot) => shot.view === 'turntable')
2143
- .map((shot) => shot.pose ?? undefined),
1931
+ .map((shot) => shot.pose),
2144
1932
  )) {
2145
1933
  const staged = stageFor(pose);
2146
1934
  const yaws = [
2147
1935
  ...new Set(
2148
1936
  definition.shots
2149
- .filter((shot) => shot.view === 'turntable' && (shot.pose ?? undefined) === pose)
1937
+ .filter((shot) => shot.view === 'turntable' && (shot.pose) === pose)
2150
1938
  .map((shot) => (shot as Extract<ShotSetShot, { view: 'turntable' }>).yaw),
2151
1939
  ),
2152
1940
  ];
@@ -2167,28 +1955,19 @@ export function planShotSetCapture(
2167
1955
  if (shot.view === 'turntable') {
2168
1956
  return {
2169
1957
  label: shot.label,
2170
- camera: turntableByStage.get(shot.pose ?? undefined)!.get(shot.yaw)!,
1958
+ camera: turntableByStage.get(shot.pose)!.get(shot.yaw)!,
2171
1959
  scene: staged.scene,
2172
1960
  stage: staged,
2173
1961
  basis: turntableViewBasis(shot.yaw),
2174
1962
  };
2175
1963
  }
2176
- // Bone-zoom anchors are read from the REST snapshot's bones, for
2177
- // every shot including posed ones — and for an EXPRESSION STRIP
2178
- // (several poses framed on the same head) that is the point, not a
2179
- // limitation: one anchor set means one camera, so the frames differ
2180
- // only by the face. A pose that MOVED its own anchor would want the
2181
- // pose snapshot's bone map instead; no definition does that today,
2182
- // and a strip never should.
2183
- //
2184
- // A zoom on multiple bones (both hands, both feet) sizes the crop to
2185
- // actually contain every anchor, not just apply a fixed fraction of
2186
- // the model's radius — see {@link boneZoomCamera}'s doc comment.
1964
+ // Read bone-zoom anchors from the rest snapshot so expression-strip poses share a camera.
1965
+ // Multiple anchors expand the crop to contain them all; see boneZoomCamera.
2187
1966
  const missingAnchor = shot.bones.find((bone) => !bonesByName.has(bone));
2188
1967
  if (missingAnchor !== undefined) {
2189
1968
  throw new Error(
2190
- `Asset preview '--shots ${definition.name}' shot ` +
2191
- `${JSON.stringify(shot.label)} zooms on a joint this GLB's skeleton does not ` +
1969
+ `Asset preview shot set '${definition.name}' shot ` +
1970
+ `${JSON.stringify(shot.label)} zooms on a joint this model's skeleton does not ` +
2192
1971
  `have: ${missingAnchor}. List it in the definition's requiredBones or fix the rig.`,
2193
1972
  );
2194
1973
  }
@@ -2273,7 +2052,7 @@ export function planShotSetCapture(
2273
2052
  export function captureShotSetAssetPreview(
2274
2053
  source: THREE.Object3D,
2275
2054
  definition: AssetPreviewShotSetDefinition,
2276
- options: AssetPreviewOptions = {},
2055
+ options: AssetPreviewOptions,
2277
2056
  ): LabeledShotSetCapture {
2278
2057
  const width = checkedDimension(options.width);
2279
2058
  const height = checkedDimension(options.height);
@@ -2298,7 +2077,9 @@ export function captureShotSetAssetPreview(
2298
2077
  activeRenderer.setSize(width, height, false);
2299
2078
  // A pose gets its own staged scene, so every scene in the plan is lit —
2300
2079
  // once, off the renderer's single cached bake.
2301
- for (const planned of plan.shots) applyStudioEnvironment(planned.scene, activeRenderer);
2080
+ for (const shotScene of new Set(plan.shots.map((shot) => shot.scene))) {
2081
+ applyStudioEnvironment(shotScene, activeRenderer);
2082
+ }
2302
2083
 
2303
2084
  const shots: LabeledShotSetCapture['shots'] = [];
2304
2085
  const columns = 2;
@@ -2372,7 +2153,7 @@ function projectModelUrl(rawAssetPath: string): URL {
2372
2153
  if (decodedPath.split('/').some((segment) => segment === '.' || segment === '..')) {
2373
2154
  throw new Error('Asset Editor model paths cannot traverse outside the project root.');
2374
2155
  }
2375
- const projectOrigin = globalThis.location?.origin ?? 'http://localhost';
2156
+ const projectOrigin = globalThis.location.origin;
2376
2157
  const url = new URL(assetPath, projectOrigin);
2377
2158
  if (url.origin !== projectOrigin || !url.pathname.toLowerCase().match(/\.(?:glb|gltf|spz)$/)) {
2378
2159
  throw new Error(
@@ -2487,7 +2268,7 @@ function projectDependencyUrl(uri: string, sourceUrl: URL): URL {
2487
2268
 
2488
2269
  function dependencyMimeType(resource: ExternalGltfResource, url: URL): string {
2489
2270
  if (typeof resource.mimeType === 'string') return resource.mimeType;
2490
- const extension = url.pathname.split('.').pop()?.toLowerCase();
2271
+ const extension = url.pathname.split('.').pop()!.toLowerCase();
2491
2272
  if (extension === 'png') return 'image/png';
2492
2273
  if (extension === 'jpg' || extension === 'jpeg') return 'image/jpeg';
2493
2274
  if (extension === 'webp') return 'image/webp';
@@ -2525,17 +2306,8 @@ async function inlineExternalGltfResources(
2525
2306
  return JSON.stringify(document);
2526
2307
  }
2527
2308
 
2528
- /**
2529
- * Parse a base64 GLB that travelled IN the relay command, rather than one
2530
- * fetched from the project origin.
2531
- *
2532
- * EXTRACTED VERBATIM from `asset-compare.ts`'s private `parseRefGlb` (the
2533
- * compare mode has shipped reference GLBs over the wire this way since B8.4);
2534
- * `label` is the one addition, so each caller keeps its own error wording
2535
- * while there is a single decoder. The bound is the SAME 64 MiB the fetched
2536
- * path enforces (`MAX_MODEL_BYTES`) — bytes that arrive over the relay are no
2537
- * cheaper to rasterize than bytes fetched over HTTP.
2538
- */
2309
+ /** Parse a base64 GLB carried by the relay command. The caller's label names
2310
+ * decoding failures. MAX_MODEL_BYTES bounds both relay and fetched models. */
2539
2311
  export function parseGlbBytesModel(base64: string, label: string): Promise<THREE.Object3D> {
2540
2312
  let bytes: Uint8Array<ArrayBuffer>;
2541
2313
  try {
@@ -2564,18 +2336,17 @@ export function parseGlbBytesModel(base64: string, label: string): Promise<THREE
2564
2336
  }
2565
2337
 
2566
2338
  /**
2567
- * The Asset Lab's four-view capture of a model that exists only as BYTES — no
2568
- * project path, no live entity. This is the rasterization half of the
2569
- * "module path" look (`project.bake.preview`): a Node host builds a project
2570
- * module's `Object3D`, exports it to an in-memory GLB, and hands those bytes
2571
- * here, because Node has no GPU and the editor session does.
2339
+ * The Asset Lab's contact-sheet capture of a model that exists only as BYTES — no
2340
+ * project path, no live entity. A Node host that built an `Object3D` exports
2341
+ * it to an in-memory GLB and hands those bytes here, because Node has no GPU
2342
+ * and the editor session does.
2572
2343
  *
2573
2344
  * Deliberately the `'lab'` stage and nothing else — bytes stand nowhere, so
2574
2345
  * there is no scene to stage them in.
2575
2346
  */
2576
2347
  export async function captureGlbBytesAssetPreview(
2577
2348
  glbBase64: string,
2578
- options: AssetPreviewOptions = {},
2349
+ options: AssetPreviewOptions,
2579
2350
  ): Promise<AssetPreviewCapture> {
2580
2351
  const model = await parseGlbBytesModel(glbBase64, 'Asset preview GLB bytes');
2581
2352
  try {
@@ -2586,16 +2357,15 @@ export async function captureGlbBytesAssetPreview(
2586
2357
  }
2587
2358
 
2588
2359
  /**
2589
- * The same bytes, photographed as a LABELED SHOT SET instead of the four
2590
- * views — what the editor's `screenshot <module> --orbit <n>` command renders. A shot set
2591
- * stages the subject itself, exactly the way the asset-path lane's
2360
+ * The same bytes, photographed as a LABELED SHOT SET. A shot set stages the
2361
+ * subject itself, exactly the way the asset-path lane's
2592
2362
  * {@link captureShotSetModelPreview} does, so bytes are no less valid a
2593
2363
  * subject here than a project GLB is.
2594
2364
  */
2595
2365
  export async function captureShotSetGlbBytesPreview(
2596
2366
  glbBase64: string,
2597
2367
  definition: AssetPreviewShotSetDefinition,
2598
- options: AssetPreviewOptions = {},
2368
+ options: AssetPreviewOptions,
2599
2369
  ): Promise<LabeledShotSetCapture> {
2600
2370
  const model = await parseGlbBytesModel(glbBase64, 'Asset preview GLB bytes');
2601
2371
  try {
@@ -2648,11 +2418,8 @@ export async function loadProjectAssetModelSource(
2648
2418
  }
2649
2419
  }
2650
2420
 
2651
- export async function loadProjectAssetModel(
2652
- assetPath: string,
2653
- externalSignal?: AbortSignal,
2654
- ): Promise<THREE.Object3D> {
2655
- return (await loadProjectAssetModelSource(assetPath, externalSignal)).root;
2421
+ export async function loadProjectAssetModel(assetPath: string): Promise<THREE.Object3D> {
2422
+ return (await loadProjectAssetModelSource(assetPath)).root;
2656
2423
  }
2657
2424
 
2658
2425
  /** Release a model returned by {@link loadProjectAssetModel}. */
@@ -2677,7 +2444,7 @@ export function disposeProjectAssetModel(root: THREE.Object3D): void {
2677
2444
 
2678
2445
  export async function captureModelAssetPreview(
2679
2446
  assetPath: string,
2680
- options: AssetPreviewOptions = {},
2447
+ options: AssetPreviewOptions,
2681
2448
  ): Promise<AssetPreviewCapture> {
2682
2449
  const model = await loadProjectAssetModel(assetPath);
2683
2450
  try {
@@ -2692,7 +2459,7 @@ export async function captureModelAssetPreview(
2692
2459
  export async function captureSourceReviewShotSetModelPreview(
2693
2460
  assetPath: string,
2694
2461
  forward: readonly [number, number, number],
2695
- options: AssetPreviewOptions = {},
2462
+ options: AssetPreviewOptions,
2696
2463
  ): Promise<SourceReviewShotSetCapture> {
2697
2464
  const model = await loadProjectAssetModel(assetPath);
2698
2465
  try {
@@ -2784,14 +2551,14 @@ async function captureSplatAssetPreview(
2784
2551
  }
2785
2552
  }
2786
2553
 
2787
- /** The `--shots <set>` asset-path entry point — loads the project GLB the
2554
+ /** The shot set's asset-path entry point — loads the project model the
2788
2555
  * same bounded/validated way {@link captureModelAssetPreview} does, then
2789
2556
  * renders the definition's labeled shot set against ITS OWN skeleton
2790
2557
  * (see {@link captureShotSetAssetPreview}). */
2791
2558
  export async function captureShotSetModelPreview(
2792
2559
  assetPath: string,
2793
2560
  definition: AssetPreviewShotSetDefinition,
2794
- options: AssetPreviewOptions = {},
2561
+ options: AssetPreviewOptions,
2795
2562
  ): Promise<LabeledShotSetCapture> {
2796
2563
  const model = await loadProjectAssetModel(assetPath);
2797
2564
  try {