@volter/editor-threejs 0.5.211 → 0.5.213
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.
- package/NOTICE +7 -2
- package/README.md +8 -13
- package/contributions/animation-mixers.service.ts +2 -4
- package/contributions/animation-timeline.utility.tsx +1 -2
- package/contributions/three.page.ts +50 -0
- package/dist-node/serving.mjs +84 -3
- package/dist-node/shared-three.mjs +145 -0
- package/package.json +296 -16
- package/serving/animation-stamp.ts +1 -1
- package/serving/index.ts +22 -5
- package/serving/model-import-conversion.ts +6 -3
- package/serving/shared-three.ts +176 -0
- package/src/adapter/constraint.ts +1 -18
- package/src/adapter/object3d-authoring-subject.ts +1 -12
- package/src/adapter/reflection-probe.ts +1 -9
- package/src/adapter/three-contract.ts +6 -60
- package/src/animation/live-mixers.ts +0 -4
- package/src/api.ts +7 -0
- package/src/asset-loaders.ts +2 -178
- package/src/capture/photograph-sampling.ts +333 -0
- package/src/capture/scene.ts +1 -1
- package/src/ecs/object-marks.ts +6 -14
- package/src/ecs/user-data.ts +3 -138
- package/src/kit/animation/three-clips-subject.ts +19 -42
- package/src/kit/asset-compare-core.ts +161 -0
- package/src/kit/asset-compare.ts +8 -27
- package/src/kit/asset-preview-command.ts +14 -19
- package/src/kit/asset-preview-framing.ts +8 -67
- package/src/kit/asset-preview.ts +109 -342
- package/src/kit/asset-workflow/model-inspection.ts +11 -154
- package/src/kit/asset-workflow/preview-resource-lifetime.ts +25 -0
- package/src/kit/authoring/component-instance-root.ts +3 -39
- package/src/kit/authoring/live-object-transform.ts +1 -30
- package/src/kit/authoring/object3d-document-session-registry.ts +7 -15
- package/src/kit/authoring/object3d-document-session.ts +471 -208
- package/src/kit/authoring/shell-viewport-policy.ts +18 -24
- package/src/kit/authoring/source-object3d-authoring-adapter.ts +6 -48
- package/src/kit/authoring/three-projection-core.ts +11 -40
- package/src/kit/authoring/viewport-pick-context.ts +5 -12
- package/src/kit/authoring/viewport-raycast.ts +15 -93
- package/src/kit/authoring/world-hidden-viewport.ts +8 -61
- package/src/kit/capture-camera-pose.ts +68 -0
- package/src/kit/collaboration-presence.ts +41 -0
- package/src/kit/components/CameraInfo.tsx +4 -6
- package/src/kit/components/InspectorObjectPreview.tsx +3 -30
- package/src/kit/components/Object3DDocumentToolbar.css +75 -0
- package/src/kit/components/Object3DDocumentToolbar.tsx +18 -84
- package/src/kit/components/Object3DDocumentViewport.tsx +6 -11
- package/src/kit/components/StageHost.css +40 -0
- package/src/kit/components/StageHost.tsx +192 -439
- package/src/kit/components/StageOverlays.tsx +1 -2
- package/src/kit/components/StatsOverlay.tsx +5 -17
- package/src/kit/components/ViewportFurniture.tsx +27 -154
- package/src/kit/components/ViewportOverlay.tsx +13 -17
- package/src/kit/components/ViewportOverlaysMenu.tsx +177 -0
- package/src/kit/components/ViewportShadingMenu.tsx +2 -42
- package/src/kit/components/ViewportViewMenu.tsx +7 -18
- package/src/kit/components/asset-viewers/EntityModelDocument.tsx +6 -17
- package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +9 -11
- package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +31 -133
- package/src/kit/components/asset-viewers/LutAssetDocument.tsx +10 -12
- package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +4 -8
- package/src/kit/components/asset-viewers/Object3DPreview.tsx +13 -64
- package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +6 -10
- package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +48 -65
- package/src/kit/components/asset-viewers/shader-source.ts +144 -0
- package/src/kit/components/asset-viewers/three-asset-viewers.tsx +2 -14
- package/src/kit/components/object3d-contribution-surfaces.tsx +5 -3
- package/src/kit/components/stage-keyboard.tsx +6 -12
- package/src/kit/components/stage-overlay-set.tsx +11 -20
- package/src/kit/components/stage-presence-markers.ts +9 -32
- package/src/kit/components/stage-transform-chrome.ts +4 -4
- package/src/kit/components/stage-transform-tools.tsx +7 -14
- package/src/kit/components/standard-viewport-dressing.ts +47 -167
- package/src/kit/components/world-root-binding.ts +2 -7
- package/src/kit/constraint-helper.ts +1 -1
- package/src/kit/document-renderer-session.ts +114 -0
- package/src/kit/editor-presentation-activity.ts +58 -0
- package/src/kit/editor-shell-store.ts +31 -403
- package/src/kit/editor-viewport.ts +486 -1762
- package/src/kit/entity-lod.ts +3 -16
- package/src/kit/entity-object.ts +1 -39
- package/src/kit/hierarchy-mark-reader.ts +13 -30
- package/src/kit/instanced-presentation.ts +1 -47
- package/src/kit/live-gesture-lock.ts +35 -0
- package/src/kit/live-module-source.ts +12 -52
- package/src/kit/model-thumbnail.ts +7 -35
- package/src/kit/play-camera-flight.ts +7 -16
- package/src/kit/projection/three.ts +13 -366
- package/src/kit/retained-document-states.ts +91 -0
- package/src/kit/scene-document-viewport.ts +3 -3
- package/src/kit/scene-framing.ts +11 -152
- package/src/kit/scene-view-fog.ts +2 -47
- package/src/kit/spatial-handle-visuals.ts +19 -28
- package/src/kit/stories/three-story-model.ts +5 -33
- package/src/kit/three-canvas-render.ts +3 -4
- package/src/kit/three-hierarchy-row-media.ts +3 -11
- package/src/kit/three-inspection-media.ts +6 -6
- package/src/kit/three-integration.ts +18 -18
- package/src/kit/three-state.ts +2 -19
- package/src/kit/three-viewport/camera-fit.ts +2 -6
- package/src/kit/three-viewport/selection-brackets.ts +5 -20
- package/src/kit/three-viewport/selection-outline.ts +4 -56
- package/src/kit/three-viewport/skeleton-helper.ts +1 -32
- package/src/kit/three-viewport/source-color.ts +1 -33
- package/src/kit/three-viewport/studio-environment.ts +4 -55
- package/src/kit/three-viewport-presentation.ts +23 -0
- package/src/kit/transport-strip.tsx +147 -0
- package/src/kit/viewport-actions.ts +8 -50
- package/src/kit/viewport-authoring-policy.ts +5 -52
- package/src/kit/viewport-commands.ts +28 -57
- package/src/kit/viewport-hotkeys.ts +29 -31
- package/src/kit/viewport-status-facet.ts +4 -5
- package/src/loader.ts +8 -71
- package/src/object3d-contributions.ts +18 -34
- package/src/viewport/clip-planes.ts +4 -32
- package/src/viewport/content-bounds.ts +13 -142
- package/src/viewport/editor-layers.ts +4 -26
- package/src/viewport/environment.ts +1 -1
- package/src/viewport/preview-renderer.ts +7 -45
- package/src/viewport/renderer-ownership.ts +14 -62
- package/src/viewport-api.ts +14 -33
- package/src/viewport-door.ts +16 -86
- package/contributions/three-integration.service.ts +0 -13
- package/src/adapter/hierarchy-marks.ts +0 -156
- package/src/adapter/ingest/scene-capture.ts +0 -843
- package/src/adapter/ingest/structural-ids.ts +0 -139
- package/src/adapter/ingest/visible-capture-window.ts +0 -302
- package/src/adapter/renderer-config.ts +0 -115
- package/src/asset-parse-error.ts +0 -29
- package/src/host-hierarchy-objects.ts +0 -31
- package/src/kit/authoring/design-time-settle.ts +0 -343
- 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
|
-
//
|
|
37
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
63
|
-
*
|
|
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/
|
|
23
|
-
import { activeWorkspaceDocumentId } from '@volter/sdk/
|
|
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/
|
|
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 (
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
}
|