@volter/editor-threejs 0.5.211 → 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
@@ -5,7 +5,6 @@ export function perspectiveDistanceToFitBox(
5
5
  box: THREE.Box3,
6
6
  camera: THREE.PerspectiveCamera,
7
7
  direction: THREE.Vector3,
8
- padding = 1.15,
9
8
  ): number {
10
9
  if (box.isEmpty()) return 3;
11
10
  const viewDirection = direction.clone().normalize();
@@ -33,9 +32,6 @@ export function perspectiveDistanceToFitBox(
33
32
  halfHeight / Math.tan(verticalHalfFov),
34
33
  halfWidth / Math.tan(horizontalHalfFov),
35
34
  );
36
- // The floor is the camera's own near plane, not a metre count: a 3 m floor
37
- // framed every prop under two metres at the same distance, so a 0.5 m
38
- // mushroom opened as a thumb-sized thing in the middle of the viewport
39
- // and Frame did nothing (measured 2026-09-06: fit 0.62 m, answer 3.0 m).
40
- return Math.max(fitDistance * padding + halfDepth, camera.near * 4, 0.01);
35
+ // Use the camera’s near plane as the minimum fit distance so small props can fill the viewport.
36
+ return Math.max(fitDistance * 1.15 + halfDepth, camera.near * 4, 0.01);
41
37
  }
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Drawn with three's fat-line addons (`LineSegments2` + `LineSegmentsGeometry`
11
11
  * + `LineMaterial`) so the stroke is a real screen-space width rather than the
12
- * driver's 1px `gl.LINES`, which is the other half of why the old box vanished.
12
+ * driver's 1px `gl.LINES`, which vanishes at small sizes.
13
13
  * The screen-space width needs `LineMaterial.resolution`; nothing here or in
14
14
  * the viewport sets it, because `LineSegments2.onBeforeRender` already pushes
15
15
  * the live renderer viewport into that uniform on every draw. A second writer
@@ -33,16 +33,7 @@ import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js
33
33
  /** Stroke width in CSS pixels (`LineMaterial` screen-space units). */
34
34
  export const SELECTION_BRACKET_LINEWIDTH = 3;
35
35
 
36
- /**
37
- * How far the cage stands OFF the entity, as a fraction of each axis extent.
38
- *
39
- * Owner ask (2026-08-11): "the corner highlights are exactly ON the model. I
40
- * frequently do not see it." Drawn on the exact AABB, a bracket on anything
41
- * box-shaped is coplanar with the surface it is supposed to be marking — it
42
- * z-fights, it takes the object's own shading, and on a flat face it simply
43
- * disappears into the edge it lies along. The cage has to float clear of the
44
- * silhouette to read as a separate thing.
45
- */
36
+ /** Stand selection brackets clear of each axis extent to keep them visible above coplanar surfaces. */
46
37
  export const BRACKET_STANDOFF_FRACTION = 0.05;
47
38
 
48
39
  /** World-unit floor for that stand-off, so a tiny prop still gets clearance. */
@@ -60,11 +51,7 @@ export const BRACKET_ARM_MIN = 0.08;
60
51
  /** World-unit ceiling, so a terrain-scale box does not draw multi-meter arms. */
61
52
  export const BRACKET_ARM_MAX = 3;
62
53
 
63
- /**
64
- * Hard cap as a fraction of the extent. Without it the clamped MIN would make
65
- * arms from opposite corners meet on a small box — which is a full box outline
66
- * again, i.e. exactly the shape we are moving away from.
67
- */
54
+ /** Cap bracket arms below half the extent so opposite corners remain separate. */
68
55
  export const BRACKET_ARM_EXTENT_CAP = 0.45;
69
56
 
70
57
  /**
@@ -234,7 +221,7 @@ export class SelectionBrackets extends LineSegments2 {
234
221
  this._objectFrame = options.frame === 'object';
235
222
  // A static hierarchy's world bounds do not change because the camera did.
236
223
  // Detect the cases whose vertices can move without an editor transform
237
- // notification; those keep the old every-frame bounds refresh. Ordinary
224
+ // notification; those refresh their bounds every frame. Ordinary
238
225
  // terrain/building groups instead refresh only when their root transform
239
226
  // changes (and `_syncBoxHelpers` reconstructs them after source/store
240
227
  // changes), avoiding one recursive Box3 walk per selected subtree per RAF.
@@ -304,9 +291,7 @@ export class SelectionBrackets extends LineSegments2 {
304
291
  this._fixedExtent.setScalar(this._fixedSize);
305
292
  this._box.setFromCenterAndSize(this._worldPos, this._fixedExtent);
306
293
  } else {
307
- // Content only — a built-internal child (a world-space particle renderer
308
- // at identity, a pooled batch) would otherwise drag the cage off to
309
- // wherever its machinery lives. See `content-bounds.ts`.
294
+ // The one content walk every bounds consumer shares (`content-bounds.ts`).
310
295
  if (objectFrame) {
311
296
  this._toObject.copy(this.entityObject.matrixWorld).invert();
312
297
  contentBoundsInFrame(this.entityObject, this._toObject, this._box);
@@ -19,19 +19,7 @@ import {
19
19
  import type { EffectPass, OutlineEffect } from 'postprocessing';
20
20
  import type * as THREE from 'three';
21
21
 
22
- /**
23
- * The three postprocessing bindings {@link createThreeSelectionOutline} needs,
24
- * passed in rather than statically imported.
25
- *
26
- * `postprocessing` is ~200 kB of shaders and passes, and the surfaces this
27
- * module serves do not all want it: `editor-viewport.ts` imports only
28
- * {@link collectThreeSelectionOutlineTargets} (a pure traversal), and the
29
- * Asset Lab 3D document builds its composer on its FIRST frame, not at module
30
- * load. A static import here put the whole library in both closures
31
- * eagerly. A caller that already loaded postprocessing for its own composer
32
- * hands over its own bindings (`the world root's stage`); one that has not opens the
33
- * lazy door {@link loadThreeSelectionOutline}.
34
- */
22
+ /** Caller-supplied postprocessing bindings keep pure target traversal independent of renderer/composer loading. */
35
23
  export interface ThreeSelectionOutlineEffects {
36
24
  readonly OutlineEffect: typeof import('postprocessing').OutlineEffect;
37
25
  readonly BlendFunction: typeof import('postprocessing').BlendFunction;
@@ -70,14 +58,7 @@ export function createThreeSelectionOutline(
70
58
  // the effect's own layer and both masks cancel to no visible edge.
71
59
  effect.selectionLayer = EDITOR_SELECTION_LAYER;
72
60
  camera.layers.disable(EDITOR_SELECTION_LAYER);
73
- // A CRISP LINE NEEDS ITS COLOUR UNSCALED. postprocessing's composite multiplies the edge colour
74
- // by the edge value and uses that value again as alpha, so a partial edge darkens (Blender's
75
- // orange read brown) and a strength above one pushes the colour past itself (it read white).
76
- // Under `volterCrisp` the colour is divided back out of the edge value, leaving only the alpha
77
- // to follow the edge; the editor's own halo keeps the library's composite. And the library
78
- // draws an edge only OUTSIDE the silhouette (it scales the edge by the mask, which is zero on
79
- // the selection); Blender's detect marks the pixels on both sides of it, so a crisp line keeps
80
- // its inner half.
61
+ // Crisp outlines retain unscaled colour on both sides of the silhouette; the halo uses the library composite.
81
62
  const shader = effect.getFragmentShader();
82
63
  const stock = 'vec3 color=edge.x*visibleEdgeColor+edge.y*hiddenEdgeColor;';
83
64
  const outsideOnly = 'edge*=(edgeStrength*mask.x*pulse);';
@@ -94,12 +75,7 @@ export function createThreeSelectionOutline(
94
75
  // Any `{ value }` is a uniform to three; no runtime three import in this module.
95
76
  effect.uniforms.set('volterCrisp', { value: 0 } as THREE.Uniform<number>);
96
77
  }
97
- // THE EDITOR'S OWN OVERLAYS DO NOT HIDE THE SELECTION. The effect measures what stands in
98
- // front of the selected object with a depth pass under an override material, which writes
99
- // depth for everything the camera sees — the floor grid included, though it writes none
100
- // itself — so the grid hid the outline's lower half wherever it crossed the object (measured
101
- // on Unity's look). Its layer is off the camera for the effect's update only; the effect
102
- // saves and restores the camera's mask around its own mask pass.
78
+ // Exclude editor overlay layers from the outline effect’s depth update, restoring the camera mask around that pass.
103
79
  const update = effect.update.bind(effect);
104
80
  effect.update = (renderer, inputBuffer, deltaTime) => {
105
81
  const current = (effect as unknown as { camera: THREE.Camera }).camera;
@@ -116,17 +92,7 @@ export function createThreeSelectionOutline(
116
92
  return effect;
117
93
  }
118
94
 
119
- /**
120
- * ONE EFFECT PER RENDERER, REUSED. `OutlineEffect` constructs its `Selection`
121
- * with a render layer from a module-wide counter that warns "Layer out of
122
- * range, resetting to 2" on its 30th construction in a page; the editor
123
- * overrides that layer, but built one effect per document session and per
124
- * world pipeline rebuild, the warning sat on every session's console as
125
- * unresolved work. A released effect goes back to its renderer's pool — its
126
- * render targets belong to that renderer's context — and the next surface on
127
- * that renderer re-points it at its own scene and camera. Constructions are
128
- * bounded by the surfaces open at once.
129
- */
95
+ /** Reuse released effects within their renderer context. Clear selection on release and rebind scene/camera on acquire. */
130
96
  const pooled = new WeakMap<object, OutlineEffect[]>();
131
97
 
132
98
  /** An outline for `renderer`: a released one re-pointed at `scene` and `camera`, or a new one. */
@@ -165,24 +131,6 @@ export function releaseThreeSelectionOutline(
165
131
  pooled.set(renderer, free);
166
132
  }
167
133
 
168
- /** THE LAZY DOOR: load `postprocessing` on demand and build the effect. For a
169
- * caller with no composer of its own, so the library arrives with the
170
- * silhouette rather than with the surface. Repeat calls are free — the module
171
- * is cached after the first. */
172
- export async function loadThreeSelectionOutline(
173
- scene: THREE.Scene,
174
- camera: THREE.Camera,
175
- colors: OutlineColors,
176
- ): Promise<OutlineEffect> {
177
- const { BlendFunction, KernelSize, OutlineEffect } = await import('postprocessing');
178
- return createThreeSelectionOutline(
179
- { BlendFunction, KernelSize, OutlineEffect },
180
- scene,
181
- camera,
182
- colors,
183
- );
184
- }
185
-
186
134
  type OutlineColors = {
187
135
  readonly visible: number;
188
136
  readonly hidden: number;
@@ -1,5 +1,5 @@
1
1
  import { EDITOR_LAYER } from '@volter/editor-threejs/viewport/editor-layers';
2
- import { getObjectMark, setObjectMark } from '@volter/editor-threejs/ecs/object-marks';
2
+ import { setObjectMark } from '@volter/editor-threejs/ecs/object-marks';
3
3
  import * as THREE from 'three';
4
4
 
5
5
  const SKELETON_HELPER_TYPE = 'skeletons';
@@ -28,34 +28,3 @@ export function styleEditorSkeletonHelper(helper: THREE.SkeletonHelper): void {
28
28
  setObjectMark(helper, 'editorHelperType', SKELETON_HELPER_TYPE);
29
29
  helper.traverse((child) => child.layers.set(EDITOR_LAYER));
30
30
  }
31
-
32
- export function ensureSkeletonHelper(root: THREE.Object3D): THREE.SkeletonHelper | null {
33
- let existing: THREE.SkeletonHelper | null = null;
34
- let hasSkinnedMesh = false;
35
- root.traverse((object) => {
36
- if (getObjectMark(object, 'editorHelperType') === SKELETON_HELPER_TYPE) {
37
- existing = object as THREE.SkeletonHelper;
38
- }
39
- if ((object as THREE.SkinnedMesh).isSkinnedMesh) hasSkinnedMesh = true;
40
- });
41
- if (existing || !hasSkinnedMesh) return existing;
42
-
43
- const helper = new THREE.SkeletonHelper(root);
44
- if (helper.bones.length === 0) {
45
- helper.dispose();
46
- return null;
47
- }
48
- styleEditorSkeletonHelper(helper);
49
-
50
- // SkeletonHelper normally expects to be a scene-root sibling. Keeping it
51
- // under the entity makes lifecycle cleanup automatic, so use entity-local
52
- // coordinates instead of applying the entity world transform twice.
53
- helper.matrix = new THREE.Matrix4();
54
- helper.matrixAutoUpdate = false;
55
- helper.name = 'EditorSkeletonHelper';
56
- helper.visible = Boolean(getObjectMark(root, 'skeletonVisible'));
57
- setObjectMark(helper, 'skeletonEnabled', helper.visible);
58
- root.add(helper);
59
- root.updateMatrixWorld(true);
60
- return helper;
61
- }
@@ -1,38 +1,6 @@
1
1
  import * as THREE from 'three';
2
2
 
3
- /**
4
- * The colour to give a TONE-MAPPED material so the screen shows the palette's
5
- * exact sRGB hex — the inverse of three's ACES fit (the input and output
6
- * matrices and the RRT/ODT rational, `tonemapping_pars_fragment`) through the
7
- * exposure; Linear divides by it; no tone mapping passes through; any other
8
- * operator is left as-is.
9
- *
10
- * WHICH SURFACES ACTUALLY WANT IT, measured on the live Model stage
11
- * (`document-script` over the session's scene, beside a `capture-editor-chrome`
12
- * frame of the same instant — the chrome door serves the PRESENTED frame, so
13
- * the two are the same pixels):
14
- *
15
- * - `scene.background` as a `THREE.Color`: **no**. three does not shade a
16
- * colour background, it CLEARS to it (`WebGLBackground.setClear` →
17
- * `glClearColor`), so no fragment shader and no tone map ever touches it.
18
- * Measured: the scene held `#494949` — this function's inversion of the
19
- * palette's `#3f3f3f` — and the screen read `#494949` exactly, luminance
20
- * 73 against Blender's 63. The inversion was compensating for a transform
21
- * that does not run, and that alone is what made the stage light.
22
- * - the floor grid (`editor-floor-grid`, a `ShaderMaterial` with
23
- * `toneMapped: true`): **yes**. Measured: `uColor` held `#575757` — this
24
- * function's inversion of the palette's `#545454` — and the 1 m line read
25
- * 83–84 on screen, i.e. the operator ran and landed it on the palette.
26
- * - the floor axes (`LineSegments2`/`LineMaterial`, `toneMapped: false`):
27
- * **no**. `toneMapped: false` is honoured per material (three compiles the
28
- * tone-map chunk per program, not as a composer output pass), so the
29
- * inverted colour reached the screen raw and over-saturated: `#ee234d`
30
- * where Blender's X axis is `#cb293f`.
31
- *
32
- * So the operator runs PER MATERIAL here, not in a final composer pass: the
33
- * grid's `toneMapped: true` was mapped and the axes' `toneMapped: false` was
34
- * not, in the same frame, through the same composer.
35
- */
3
+ /** Invert the current tone-mapping operator for a material that must display an exact palette sRGB colour. Apply at the material because other materials in the same composer may disable tone mapping. */
36
4
  export function toneMappedSourceColor(
37
5
  hex: number,
38
6
  renderer: THREE.WebGLRenderer | undefined,
@@ -1,35 +1,4 @@
1
- /**
2
- * The LOOK LANES' neutral studio IBL — `scene.environment` for every surface
3
- * that photographs an asset instead of displaying it: the Asset Lab capture
4
- * path (`asset-preview.ts`: the model-file lane, the editor's `screenshot <module>` command
5
- * through `project.bake.preview`, labeled shot sets, source-review sheets,
6
- * splats) and the asset-browser thumbnails (`model-thumbnail.ts`).
7
- *
8
- * Why it exists: a `MeshStandardMaterial` with `metalness: 1` has NO diffuse
9
- * term — everything it shows is reflected environment. Lit by directional
10
- * lights alone it renders BLACK with a pinprick specular, so iron reads as
11
- * painted plastic, and the measured cost of that (cold barrel, 2026-08-29) is
12
- * that agents detune `metalness` to survive the screenshot — the look lane
13
- * silently teaching a wrong material value into shipped assets.
14
- *
15
- * The bake is three's own `RoomEnvironment` through `PMREMGenerator`, reused
16
- * verbatim from the document viewport's dressing
17
- * ({@link createStandardEnvironment}) so a capture and the open document light
18
- * the same subject the same way. Procedural: no HDRI binary, deterministic
19
- * frame to frame.
20
- *
21
- * Two rules this module exists to hold:
22
- *
23
- * - **`environment` only, never `background`.** Each lane keeps its own
24
- * backdrop (the Asset Lab's neutral clear colour, or transparent). An IBL
25
- * that also painted the background would change every existing frame's
26
- * composition, not just its materials.
27
- * - **One bake per renderer.** A PMREM target is GL-context-bound, so it
28
- * cannot be shared across renderers, and baking it per SCENE would pay for
29
- * it once per shot in a 24-frame orbit. The cache is keyed by renderer and
30
- * freed through {@link disposeStudioEnvironment}, which every caller that
31
- * disposes its renderer must call — a PMREM render target leaks otherwise.
32
- */
1
+ /** Provide neutral studio IBL for asset captures and thumbnails so metallic surfaces have an environment to reflect. Cache per renderer and dispose through disposeStudioEnvironment. */
33
2
 
34
3
  import {
35
4
  createStandardEnvironment,
@@ -38,33 +7,13 @@ import {
38
7
  import type * as THREE from 'three';
39
8
 
40
9
  /**
41
- * How hard the IBL pushes against the lane's existing key/fill rig.
42
- *
43
- * The key and fill are UNCHANGED — they still own the form, the direction of
44
- * the shading and the highlight. This is a reflection budget, not a second key.
45
- *
46
- * The blend was picked by reading before/after pairs of the barrel GLB and a
47
- * metalness sweep in the module lane, holding the wood constant and asking how
48
- * little environment buys back the metal:
49
- * - 1.0 with the ambient untouched: the iron reads, the wood lifts and flattens
50
- * — the shadow side of the staves fills in and the stave-to-stave tone
51
- * variation collapses;
52
- * - 0.55 with {@link STUDIO_AMBIENT_WITH_ENVIRONMENT} at 0.35: metal good,
53
- * wood still visibly brighter and lower-contrast than the historical frame;
54
- * - 0.45 with the ambient at 0.25 (shipped): the wood frame is indistinguishable
55
- * from the historical one at full scale — same warmth, same grain, same
56
- * terminator — while every hoop gains a gradient across the band and the
57
- * bottom hoop stops reading as a shadow.
10
+ * Environment-light intensity relative to the studio’s key and fill rig.
58
11
  */
59
12
  export const STUDIO_ENVIRONMENT_INTENSITY = 0.45;
60
13
 
61
14
  /**
62
- * The lanes' ambient fill is REDUCED when the IBL is present: a
63
- * hemisphere/ambient light is a crude stand-in for exactly what the environment
64
- * now does properly, and leaving both at full strength double-pays the ambient
65
- * term and washes the subject. Each lane multiplies its own historical ambient
66
- * intensity by this, so the total ambient energy lands near where it was and
67
- * only its DIRECTIONALITY improves.
15
+ * Scale ambient fill when environment lighting is present so both sources do not duplicate the
16
+ * ambient term.
68
17
  */
69
18
  export const STUDIO_AMBIENT_WITH_ENVIRONMENT = 0.25;
70
19
 
@@ -0,0 +1,23 @@
1
+ export type ThreeViewportProjection = 'perspective' | 'orthographic';
2
+
3
+ export interface ThreeViewportPresentationState {
4
+ readonly projection: ThreeViewportProjection;
5
+ }
6
+
7
+ let state: ThreeViewportPresentationState = { projection: 'perspective' };
8
+ const listeners = new Set<() => void>();
9
+
10
+ export function threeViewportPresentation(): ThreeViewportPresentationState {
11
+ return state;
12
+ }
13
+
14
+ export function subscribeThreeViewportPresentation(listener: () => void): () => void {
15
+ listeners.add(listener);
16
+ return () => listeners.delete(listener);
17
+ }
18
+
19
+ export function setThreeViewportProjection(projection: ThreeViewportProjection): void {
20
+ if (state.projection === projection) return;
21
+ state = { ...state, projection };
22
+ for (const listener of listeners) listener();
23
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Draw controls for the stage’s transport when a subject is attached. Read every value from its
3
+ * subscribed snapshot; this component owns no clock or local playhead. Blender’s Timeline offers
4
+ * another view of the same transport handle.
5
+ */
6
+
7
+ import {
8
+ EditorIcon,
9
+ editorIcons,
10
+ IconButton,
11
+ Text,
12
+ TextInput,
13
+ themeVars,
14
+ } from '@volter/sdk/widgets';
15
+ import { useCallback, useSyncExternalStore } from 'react';
16
+ import type { StageTransportHandle } from '@volter/sdk/host';
17
+
18
+ /** The speeds the strip offers. Not a free number field: a speed is a viewing
19
+ * choice with a few useful values, and a text box invites 0.37. */
20
+ const SPEEDS = [0.25, 0.5, 1, 2] as const;
21
+
22
+ function formatSeconds(seconds: number, fps: number): string {
23
+ const frame = Math.round(seconds * fps);
24
+ return `${seconds.toFixed(2)}s · f${frame}`;
25
+ }
26
+
27
+ export function TransportStrip({ transport }: { transport: StageTransportHandle | null }) {
28
+ const snapshot = useSyncExternalStore(
29
+ useCallback(
30
+ (listener: () => void) => (transport ? transport.subscribe(listener) : () => {}),
31
+ [transport],
32
+ ),
33
+ useCallback(() => transport?.snapshot() ?? null, [transport]),
34
+ );
35
+
36
+ if (!transport || !snapshot || snapshot.activeSubject === null) return null;
37
+
38
+ const playing = snapshot.playbackState === 'playing';
39
+ const { start, end, fps, loop } = snapshot.range;
40
+ const active = snapshot.subjects.find((s) => s.id === snapshot.activeSubject);
41
+ // Two different pickers, and they are not the same question: SUBJECTS are
42
+ // the several things this stage can show at a time, CLIPS are the several
43
+ // animations one subject has. A Blender action is a subject with no clips.
44
+ const subjects = snapshot.subjects.length > 1 ? snapshot.subjects : null;
45
+ const clips = transport.activeClips();
46
+
47
+ return (
48
+ <section
49
+ aria-label="Transport"
50
+ data-testid="stage-transport-strip"
51
+ style={{
52
+ display: 'flex',
53
+ alignItems: 'center',
54
+ gap: 'var(--volter-space-2)',
55
+ padding: 'var(--volter-space-1) var(--volter-space-3)',
56
+ borderTop: `var(--volter-stroke-resting) solid ${themeVars.boundary.default}`,
57
+ background: themeVars.surface.raised,
58
+ }}
59
+ >
60
+ <IconButton
61
+ size="compact"
62
+ aria-label={playing ? 'Pause' : 'Play'}
63
+ onClick={() => (playing ? transport.pause() : transport.play())}
64
+ >
65
+ <EditorIcon icon={playing ? editorIcons.transport.pause : editorIcons.transport.play} />
66
+ </IconButton>
67
+ <IconButton
68
+ size="compact"
69
+ aria-label="Stop"
70
+ onClick={() => transport.stop()}
71
+ >
72
+ <EditorIcon icon={editorIcons.transport.stop} />
73
+ </IconButton>
74
+
75
+ <TextInput
76
+ aria-label="Playhead"
77
+ type="range"
78
+ min={start}
79
+ max={end}
80
+ step={1 / fps}
81
+ value={snapshot.time}
82
+ onChange={(event) => transport.seek(Number(event.target.value))}
83
+ style={{ flex: 1 }}
84
+ />
85
+
86
+ <Text as="span" variant="caption">
87
+ {formatSeconds(snapshot.time, fps)}
88
+ </Text>
89
+
90
+ {subjects ? (
91
+ <select
92
+ aria-label="Subject"
93
+ value={snapshot.activeSubject}
94
+ onChange={(event) => transport.setActiveSubject(event.target.value)}
95
+ >
96
+ {subjects.map((subject) => (
97
+ <option key={subject.id} value={subject.id}>
98
+ {subject.label}
99
+ </option>
100
+ ))}
101
+ </select>
102
+ ) : null}
103
+
104
+ {clips ? (
105
+ <select
106
+ aria-label="Clip"
107
+ onChange={(event) => transport.setActiveClip(event.target.value)}
108
+ >
109
+ {clips.map((clip) => (
110
+ <option key={clip.id} value={clip.id}>
111
+ {clip.label}
112
+ </option>
113
+ ))}
114
+ </select>
115
+ ) : null}
116
+
117
+ <label style={{ display: 'flex', alignItems: 'center', gap: 'var(--volter-space-1)' }}>
118
+ <input
119
+ type="checkbox"
120
+ checked={loop}
121
+ onChange={(event) => transport.setLoop(event.target.checked)}
122
+ />
123
+ <Text as="span" variant="caption">
124
+ Loop
125
+ </Text>
126
+ </label>
127
+
128
+ <select
129
+ aria-label="Speed"
130
+ value={String(snapshot.timeScale)}
131
+ onChange={(event) => transport.setTimeScale(Number(event.target.value))}
132
+ >
133
+ {SPEEDS.map((speed) => (
134
+ <option key={speed} value={String(speed)}>
135
+ {speed}×
136
+ </option>
137
+ ))}
138
+ </select>
139
+
140
+ {active ? (
141
+ <Text as="span" variant="caption">
142
+ {active.label}
143
+ </Text>
144
+ ) : null}
145
+ </section>
146
+ );
147
+ }
@@ -6,57 +6,16 @@
6
6
  * stage's own store: the same stage its keys drive (`viewport-hotkeys.ts`).
7
7
  */
8
8
  import type { ActionContribution, ContributedAction } from '@volter/sdk/chrome';
9
- import {
10
- alignCameraToViewport,
11
- cameraAuthoringPresentation,
12
- currentCameraAuthoringSubject,
13
- leaveCameraView,
14
- pilotCamera,
15
- subscribeCameraAuthoring,
16
- toggleCameraPreviewPin,
17
- viewThroughCamera,
18
- } from './camera-authoring';
19
- import { registerContributedActions } from '@volter/sdk/kit/chrome-registry';
9
+ import { registerContributedActions } from '@volter/sdk/chrome-registry';
20
10
  import type { EditorShellStore } from './editor-shell-store';
21
11
  import { object3DDocumentSession } from './authoring/object3d-document-session-registry';
22
- import { requestTransformMode } from '@volter/sdk/kit/transform-mode-request';
23
- import { activeWorkspaceDocumentId } from '@volter/sdk/kit/workspace-document-registry';
12
+ import { requestTransformMode } from '@volter/sdk/transform-mode-request';
13
+ import { activeWorkspaceDocumentId } from '@volter/sdk/workspace-document-registry';
24
14
  import {
25
15
  setViewGridVisible,
26
16
  viewGridVisible,
27
17
  viewPresentationBinding,
28
- } from '@volter/sdk/kit/viewport-presentation';
29
-
30
- function cameraActions(): ContributedAction[] {
31
- const presentation = cameraAuthoringPresentation();
32
- if (presentation.view) {
33
- return [{ id: 'camera.exitView', label: 'Exit Camera View', execute: leaveCameraView }];
34
- }
35
- const subject = currentCameraAuthoringSubject();
36
- if (!subject) return [];
37
- return [
38
- {
39
- id: 'camera.viewThrough',
40
- label: `View Through ${subject.name}`,
41
- execute: () => viewThroughCamera(subject),
42
- },
43
- ...(subject.canAuthorPose
44
- ? [
45
- { id: 'camera.pilot', label: `Pilot ${subject.name}`, execute: () => pilotCamera(subject) },
46
- {
47
- id: 'camera.alignToView',
48
- label: `Align ${subject.name} to Current View`,
49
- execute: () => alignCameraToViewport(subject),
50
- },
51
- ]
52
- : []),
53
- {
54
- id: 'camera.pinPreview',
55
- label: presentation.previewPinned ? 'Unpin Camera Preview' : 'Pin Camera Preview',
56
- execute: toggleCameraPreviewPin,
57
- },
58
- ];
59
- }
18
+ } from '@volter/sdk/viewport-presentation';
60
19
 
61
20
  function activeObject3DDocumentSession() {
62
21
  const documentId = activeWorkspaceDocumentId();
@@ -84,7 +43,7 @@ export function registerViewportActions(store: EditorShellStore): () => void {
84
43
  id: 'toggle.grid',
85
44
  label: 'Toggle Grid',
86
45
  execute: () => {
87
- // The ACTIVE view's grid switch, one per view (`kit/viewport-presentation`).
46
+ // The ACTIVE view's grid switch, one per view (`@volter/sdk/viewport-presentation`).
88
47
  const viewId = activeWorkspaceDocumentId();
89
48
  if (viewId && viewPresentationBinding(viewId)) setViewGridVisible(viewId, !viewGridVisible(viewId));
90
49
  },
@@ -120,9 +79,8 @@ export function registerViewportActions(store: EditorShellStore): () => void {
120
79
  { id: 'view.camera', label: 'Camera View', shortcut: 'view.camera', execute: () => store.shell.toggleCameraView() },
121
80
  { id: 'focus.selected', label: 'Focus Selected', shortcut: 'viewport.frameSelection', execute: () => store.shell.focusOnSelection() },
122
81
  ];
123
- const contribution: ActionContribution = {
124
- actions: () => [...fixed, ...cameraActions()],
125
- subscribe: subscribeCameraAuthoring,
126
- };
82
+ // Supply a live action set for each bound stage. Resolve it when the palette opens so repeated
83
+ // stage registrations do not claim the same fixed ids.
84
+ const contribution: ActionContribution = { actions: () => fixed };
127
85
  return registerContributedActions(contribution);
128
86
  }