@volter/editor-threejs 0.5.65 → 0.5.67
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 +2 -0
- package/contributions/animation-mixers.service.ts +20 -0
- package/contributions/animation-timeline.utility.tsx +44 -0
- package/contributions/three-integration.service.ts +13 -0
- package/dist-node/serving.mjs +405 -0
- package/package.json +113 -5
- package/serving/animation-live-module.ts +70 -0
- package/serving/animation-stamp.ts +88 -0
- package/serving/index.ts +14 -0
- package/serving/model-import-conversion.ts +344 -0
- package/src/adapter/ingest/scene-capture.ts +1 -23
- package/src/adapter/renderer-config.ts +3 -4
- package/src/adapter/three-contract.ts +72 -0
- package/src/animation/live-mixers.ts +55 -0
- package/src/ecs/object-marks.ts +1 -1
- package/src/ecs/user-data.ts +0 -16
- package/src/host-hierarchy-objects.ts +31 -0
- package/src/kit/animation/three-clips-subject.ts +190 -0
- package/src/kit/asset-compare.ts +294 -0
- package/src/kit/asset-preview-command.ts +265 -0
- package/src/kit/asset-preview-framing.ts +357 -0
- package/src/kit/asset-preview.ts +2802 -0
- package/src/kit/asset-workflow/model-inspection.ts +830 -0
- package/src/kit/authoring/component-instance-root.ts +171 -0
- package/src/kit/authoring/design-time-settle.ts +343 -0
- package/src/kit/authoring/live-object-transform.ts +62 -0
- package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
- package/src/kit/authoring/object3d-document-session.ts +1965 -0
- package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
- package/src/kit/authoring/quarks-particle-systems.ts +19 -0
- package/src/kit/authoring/shell-viewport-policy.ts +48 -0
- package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/kit/authoring/three-projection-core.ts +226 -0
- package/src/kit/authoring/viewport-pick-context.ts +39 -0
- package/src/kit/authoring/viewport-raycast.ts +240 -0
- package/src/kit/authoring/world-hidden-viewport.ts +95 -0
- package/src/kit/camera-authoring.ts +175 -0
- package/src/kit/components/CameraInfo.tsx +56 -0
- package/src/kit/components/InspectorObjectPreview.tsx +57 -0
- package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
- package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
- package/src/kit/components/StageHost.tsx +2547 -0
- package/src/kit/components/StageOverlays.tsx +21 -0
- package/src/kit/components/StatsOverlay.tsx +78 -0
- package/src/kit/components/ToolObject3DPreview.tsx +39 -0
- package/src/kit/components/ViewportFurniture.tsx +655 -0
- package/src/kit/components/ViewportOverlay.tsx +215 -0
- package/src/kit/components/ViewportShadingMenu.tsx +340 -0
- package/src/kit/components/ViewportViewMenu.tsx +155 -0
- package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
- package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
- package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
- package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
- package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
- package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
- package/src/kit/components/stage-keyboard.tsx +40 -0
- package/src/kit/components/stage-overlay-set.tsx +105 -0
- package/src/kit/components/stage-presence-markers.ts +482 -0
- package/src/kit/components/stage-transform-chrome.ts +30 -0
- package/src/kit/components/stage-transform-tools.tsx +73 -0
- package/src/kit/components/stage-view-name.ts +30 -0
- package/src/kit/components/standard-viewport-dressing.ts +1042 -0
- package/src/kit/components/world-root-binding.ts +64 -0
- package/src/kit/constraint-helper.ts +338 -0
- package/src/kit/editor-shell-store.ts +814 -0
- package/src/kit/editor-viewport.ts +6621 -0
- package/src/kit/entity-lod.ts +31 -0
- package/src/kit/entity-object.ts +92 -0
- package/src/kit/hierarchy-mark-reader.ts +74 -0
- package/src/kit/instanced-presentation.ts +164 -0
- package/src/kit/live-module-source.ts +230 -0
- package/src/kit/model-thumbnail.ts +539 -0
- package/src/kit/play-camera-flight.ts +300 -0
- package/src/kit/projection/three.ts +898 -0
- package/src/kit/reflection-probe-helper.ts +142 -0
- package/src/kit/scene-document-viewport.ts +51 -0
- package/src/kit/scene-framing.ts +315 -0
- package/src/kit/scene-view-fog.ts +89 -0
- package/src/kit/spatial-handle-visuals.ts +332 -0
- package/src/kit/stories/three-story-model.ts +66 -0
- package/src/kit/three-canvas-render.ts +44 -0
- package/src/kit/three-hierarchy-row-media.ts +26 -0
- package/src/kit/three-inspection-media.ts +73 -0
- package/src/kit/three-integration.ts +86 -0
- package/src/kit/three-state.ts +33 -0
- package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/kit/three-viewport/camera-fit.ts +41 -0
- package/src/kit/three-viewport/interactive-renderer.ts +132 -0
- package/src/kit/three-viewport/selection-brackets.ts +355 -0
- package/src/kit/three-viewport/selection-outline.ts +333 -0
- package/src/kit/three-viewport/skeleton-helper.ts +61 -0
- package/src/kit/three-viewport/source-color.ts +197 -0
- package/src/kit/three-viewport/studio-environment.ts +96 -0
- package/src/kit/trigger-volume-helper.ts +116 -0
- package/src/kit/viewport-actions.ts +128 -0
- package/src/kit/viewport-authoring-policy.ts +154 -0
- package/src/kit/viewport-commands.ts +318 -0
- package/src/kit/viewport-hotkeys.ts +119 -0
- package/src/kit/viewport-shading-boundary.ts +12 -0
- package/src/kit/viewport-status-facet.ts +53 -0
- package/src/object3d-contributions.ts +494 -0
- package/src/render/viewport-shading.ts +6 -2
- package/src/viewport/content-bounds.ts +38 -4
- package/src/viewport/environment.ts +16 -0
- package/src/viewport-api.ts +92 -0
- package/src/viewport-door.ts +237 -0
- package/src/animation/animation-clock.ts +0 -479
- package/src/animation/runtime-inspection.ts +0 -45
|
@@ -0,0 +1,2547 @@
|
|
|
1
|
+
import { optionalThreeStateOf, threeStateOf } from '../three-state';
|
|
2
|
+
import { ShellStore } from '@volter/editor-sdk/kit/shell-store';
|
|
3
|
+
import type { ToolObject3DAuthoringProps, ToolObject3DDocumentAuthoring, ToolObject3DPreviewSource, ToolViewportDressing } from '../../object3d-contributions';
|
|
4
|
+
import { invalidateStages, stageGeneration } from '@volter/editor-sdk/kit/stage-invalidation';
|
|
5
|
+
import type { StageTransportSnapshot } from '@volter/editor-sdk/host';
|
|
6
|
+
import { EditorIcon, editorIcons, IconButton, themeVars } from '@volter/editor-sdk/widgets';
|
|
7
|
+
import type { AuthoringAdapter } from '@volter/editor-project/adapter';
|
|
8
|
+
import { contentWorldBounds } from '@volter/editor-threejs/viewport/content-bounds';
|
|
9
|
+
import { isInEditorOwnedSubtree } from '@volter/editor-threejs/viewport/editor-layers';
|
|
10
|
+
import { createStandardEnvironment } from '@volter/editor-threejs/viewport/environment';
|
|
11
|
+
import {
|
|
12
|
+
acquireInspectorPreviewRenderer,
|
|
13
|
+
type InspectorPreviewLease,
|
|
14
|
+
} from '@volter/editor-threejs/viewport/preview-renderer';
|
|
15
|
+
import { setUserData } from '@volter/editor-threejs/ecs/user-data';
|
|
16
|
+
import {
|
|
17
|
+
lazy,
|
|
18
|
+
type RefObject,
|
|
19
|
+
Suspense,
|
|
20
|
+
useCallback,
|
|
21
|
+
useEffect,
|
|
22
|
+
useRef,
|
|
23
|
+
useState,
|
|
24
|
+
useSyncExternalStore,
|
|
25
|
+
} from 'react';
|
|
26
|
+
import * as THREE from 'three';
|
|
27
|
+
import { registerStageTransport, StageTransport } from '@volter/editor-sdk/kit/animation/stage-transport';
|
|
28
|
+
import { scanClipSubjects } from '../animation/three-clips-subject';
|
|
29
|
+
import { liveGestureActive, whenLiveGestureIdle } from '@volter/editor-sdk/kit/live-gesture-lock';
|
|
30
|
+
import {
|
|
31
|
+
type Object3DDocumentPresentationState,
|
|
32
|
+
Object3DDocumentSession,
|
|
33
|
+
} from '../authoring/object3d-document-session';
|
|
34
|
+
import {
|
|
35
|
+
registerObject3DDocumentSession,
|
|
36
|
+
} from '../authoring/object3d-document-session-registry';
|
|
37
|
+
import { Object3DGestureController } from '../authoring/object3d-gesture-controller';
|
|
38
|
+
import { SourceObject3DAuthoringAdapter } from '../authoring/source-object3d-authoring-adapter';
|
|
39
|
+
import { registerDesignTimeSurface } from '@volter/editor-sdk/kit/coverage/design-time-surfaces';
|
|
40
|
+
import { DocumentRendererSession } from '@volter/editor-sdk/kit/document-renderer-session';
|
|
41
|
+
import { useOptionalEditorStats, useOptionalEditorStore } from '@volter/editor-sdk/kit/editor-runtime';
|
|
42
|
+
import type { EditorShellStore } from '../editor-shell-store';
|
|
43
|
+
import { EditorViewport } from '../editor-viewport';
|
|
44
|
+
import {
|
|
45
|
+
lookDeclaresViewportColors,
|
|
46
|
+
nativeViewportLook,
|
|
47
|
+
subscribeNativeSelectionTheme,
|
|
48
|
+
} from '@volter/editor-sdk/kit/native-selection-style';
|
|
49
|
+
import {
|
|
50
|
+
type Object3DDocumentPersistenceSession,
|
|
51
|
+
object3DDocumentWritePolicy,
|
|
52
|
+
} from '@volter/editor-sdk/kit/object3d-document-write-policy';
|
|
53
|
+
import { registerPerformanceSource } from '@volter/editor-sdk/kit/performance-sources';
|
|
54
|
+
import {
|
|
55
|
+
assetSubjectApplies,
|
|
56
|
+
documentStageContext,
|
|
57
|
+
focusedStageStore,
|
|
58
|
+
identityRowApplies,
|
|
59
|
+
studioStageApplies,
|
|
60
|
+
} from '@volter/editor-sdk/kit/stage-context';
|
|
61
|
+
import { registerStageStore } from '@volter/editor-sdk/kit/stage-store-registry';
|
|
62
|
+
import { announceDocumentStage, registerDocumentViewport } from '@volter/editor-sdk/kit/document-viewports';
|
|
63
|
+
import { sceneDocumentViewport } from '../scene-document-viewport';
|
|
64
|
+
import { threeStageTransformChrome } from './stage-transform-chrome';
|
|
65
|
+
import { RetainedDocumentStates } from '@volter/editor-sdk/kit/retained-document-states';
|
|
66
|
+
import { perspectiveDistanceToFitBox } from '../three-viewport/camera-fit';
|
|
67
|
+
import {
|
|
68
|
+
acquireInteractiveViewportRenderer,
|
|
69
|
+
type InteractiveViewportRendererLease,
|
|
70
|
+
} from '../three-viewport/interactive-renderer';
|
|
71
|
+
import type { ThreeViewportProjection } from '@volter/editor-sdk/kit/three-viewport-presentation';
|
|
72
|
+
import {
|
|
73
|
+
activeViewportBreakdownDocumentId,
|
|
74
|
+
markViewportConstructReady,
|
|
75
|
+
markViewportConstructStarted,
|
|
76
|
+
markViewportFirstRenderStart,
|
|
77
|
+
markViewportRafResume,
|
|
78
|
+
markViewportReactActive,
|
|
79
|
+
markViewportSegment,
|
|
80
|
+
recordViewportFirstFrame,
|
|
81
|
+
} from '@volter/editor-sdk/kit/viewport-activation-timings';
|
|
82
|
+
import { bindViewportRig, runViewportFrame } from '../../viewport-door';
|
|
83
|
+
import {
|
|
84
|
+
notifyWorkspaceDocumentSelectionChanged,
|
|
85
|
+
openWorkspaceDocuments,
|
|
86
|
+
registerWorkspaceDocumentSelection,
|
|
87
|
+
subscribeWorkspaceDocuments,
|
|
88
|
+
} from '@volter/editor-sdk/kit/workspace-document-registry';
|
|
89
|
+
import { AssetEditorShell } from '@volter/editor-sdk/kit/components/AssetEditorShell';
|
|
90
|
+
import { StageOverlays } from './StageOverlays';
|
|
91
|
+
import type { WorldRootStageBinding } from './world-root-binding';
|
|
92
|
+
import { TransportStrip } from '@volter/editor-sdk/kit/transport-strip';
|
|
93
|
+
|
|
94
|
+
// The stage's own keyboard actions, behind the same lazy boundary as the
|
|
95
|
+
// overlays and for the same reason — a bounded host pays for neither
|
|
96
|
+
// (`stage-keyboard.tsx`).
|
|
97
|
+
const LazyStageKeyboard = lazy(async () => {
|
|
98
|
+
const module = await import('./stage-keyboard');
|
|
99
|
+
return { default: module.StageKeyboardBinding };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
import {
|
|
103
|
+
applyStandardViewportDressing,
|
|
104
|
+
createGradientBackgroundTexture,
|
|
105
|
+
type StandardViewportDressing,
|
|
106
|
+
watchPaletteBackdrop,
|
|
107
|
+
} from './standard-viewport-dressing';
|
|
108
|
+
import { ViewportFurniture } from './ViewportFurniture';
|
|
109
|
+
import { OBJECT3D_SURFACE_BUILDING, ViewportSurfaceStatus } from '@volter/editor-sdk/kit/viewport-surface-status';
|
|
110
|
+
import { workspaceHistoryService } from '@volter/editor-sdk/kit/components/workspace-history';
|
|
111
|
+
import {
|
|
112
|
+
bindViewPresentation,
|
|
113
|
+
DOCUMENT_STUDIO_PRESET,
|
|
114
|
+
mergeLayers,
|
|
115
|
+
type PresentationLayer,
|
|
116
|
+
reportViewDraw,
|
|
117
|
+
setViewPresentation,
|
|
118
|
+
stageLightsPerMode,
|
|
119
|
+
startingPresentation,
|
|
120
|
+
subscribeViewportPresentation,
|
|
121
|
+
viewPresentation,
|
|
122
|
+
viewPresentationSnapshot,
|
|
123
|
+
type ViewportDrawMode,
|
|
124
|
+
} from '@volter/editor-sdk/kit/viewport-presentation';
|
|
125
|
+
import { subscribeEnvironmentImages } from '@volter/editor-sdk/kit/environment-images';
|
|
126
|
+
import { StagePresentationRig } from './standard-viewport-dressing';
|
|
127
|
+
import { threeStoreForHost } from '../three-state';
|
|
128
|
+
import { threeObject } from '../../adapter/three-contract';
|
|
129
|
+
|
|
130
|
+
/** The kind of stage a document's view is, for its starting presentation: the document's own
|
|
131
|
+
* kind, its id's prefix inside the workspace's `document:` wrapper
|
|
132
|
+
* (`document:model:src/models/cube.blend` is a `model` stage). */
|
|
133
|
+
const stageKindOf = (documentId: string): string => {
|
|
134
|
+
const parts = documentId.split(':');
|
|
135
|
+
return (parts[0] === 'document' ? parts[1] : parts[0]) ?? documentId;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** WHERE THIS DOCUMENT'S BYTES GO — the one collaborator the shell installs
|
|
139
|
+
* (`object3d-document-write-policy.ts`). The tier's source recorder, the
|
|
140
|
+
* project file history and the thumbnail manifest all live behind it, so the
|
|
141
|
+
* Asset Lab 3D document carries no editor transport in its own closure
|
|
142
|
+
* (ARCHITECTURE-CORE §Editor chrome, "the viewport stack is separable").
|
|
143
|
+
* With no shell above it every member refuses by name, which is the same
|
|
144
|
+
* refusal a read-only tier already produced. */
|
|
145
|
+
const projectWrites = () => object3DDocumentWritePolicy();
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How far a BARE dock-panel stage bleeds past the box a person sees, so the
|
|
149
|
+
* rendered image fills the panel edge to edge under its padding. THE ONE
|
|
150
|
+
* PLACE this number lives: the stage box takes it negative, the DOM furniture
|
|
151
|
+
* takes it back positive, and the canvas-drawn orientation gizmo takes it
|
|
152
|
+
* through `EditorViewportOptions.chromeInsetPx` — three readers, one value.
|
|
153
|
+
* A chromeless mount and the Asset Editor shell fill their own box exactly
|
|
154
|
+
* and bleed nothing.
|
|
155
|
+
*/
|
|
156
|
+
const STAGE_BLEED_PX = 12;
|
|
157
|
+
|
|
158
|
+
function boxFromFrameBounds(
|
|
159
|
+
bounds:
|
|
160
|
+
| {
|
|
161
|
+
readonly min: readonly [number, number, number];
|
|
162
|
+
readonly max: readonly [number, number, number];
|
|
163
|
+
}
|
|
164
|
+
| undefined,
|
|
165
|
+
): THREE.Box3 | null {
|
|
166
|
+
if (!bounds) return null;
|
|
167
|
+
const box = new THREE.Box3(
|
|
168
|
+
new THREE.Vector3(bounds.min[0], bounds.min[1], bounds.min[2]),
|
|
169
|
+
new THREE.Vector3(bounds.max[0], bounds.max[1], bounds.max[2]),
|
|
170
|
+
);
|
|
171
|
+
return box.isEmpty() ? null : box;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Per-document overrides of the standard viewport dressing
|
|
176
|
+
* (`standard-viewport-dressing.ts`) — explicit opt-outs/opt-ins, never
|
|
177
|
+
* re-implementations. Omitted means the standard look.
|
|
178
|
+
*
|
|
179
|
+
* THE SHAPE IS THE SDK'S (`ToolViewportDressing`), because a contributed
|
|
180
|
+
* document is what asks for it: `@volter/editor-blender`'s Model document hands over
|
|
181
|
+
* its view-locked studio through this door. One declaration, so the public
|
|
182
|
+
* surface and the host cannot drift.
|
|
183
|
+
*/
|
|
184
|
+
export type StandardDressingProps = ToolViewportDressing;
|
|
185
|
+
|
|
186
|
+
/** What an editor-side source lane gets to build its authoring adapter over
|
|
187
|
+
* the document's own store and scene. */
|
|
188
|
+
export interface SourceDocumentAuthoringContext {
|
|
189
|
+
readonly store: EditorShellStore;
|
|
190
|
+
readonly scene: THREE.Scene;
|
|
191
|
+
readonly hierarchyRoots?: readonly THREE.Object3D[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type SourceDocumentAuthoringFactory = (
|
|
195
|
+
context: SourceDocumentAuthoringContext,
|
|
196
|
+
) => ToolObject3DDocumentAuthoring;
|
|
197
|
+
|
|
198
|
+
/** WHAT THE STAGE IS SHOWING. `build` is one Object3D the caller's `build()`
|
|
199
|
+
* constructs, in isolation; its kind supplies authoring behavior and chrome,
|
|
200
|
+
* and this host owns renderer lifetime, capture and disposal. `world-root` is
|
|
201
|
+
* the project's world: the contributing package's binding mounts the
|
|
202
|
+
* manifest's roots and presents the live roots Play adopts, and this host
|
|
203
|
+
* gives it the same surface place, frame session, per-stage door and
|
|
204
|
+
* overlays. A modeling host never loads a world binding. */
|
|
205
|
+
export type Object3DDocumentContent =
|
|
206
|
+
| { readonly kind: 'build' }
|
|
207
|
+
| { readonly kind: 'world-root'; load(): Promise<WorldRootStageBinding> };
|
|
208
|
+
|
|
209
|
+
/** Throttle for the world-root stage's clip rescan. A traverse per store
|
|
210
|
+
* notification would run on every selection change; 500 ms is fast enough
|
|
211
|
+
* that a streamed-in character becomes scrubbable while a person is still
|
|
212
|
+
* looking at it. */
|
|
213
|
+
const CLIP_RESCAN_INTERVAL_MS = 500;
|
|
214
|
+
|
|
215
|
+
/** Module-level so its identity is stable across renders, which
|
|
216
|
+
* `useSyncExternalStore` requires; the theme root is global, so no stage's
|
|
217
|
+
* own element narrows it. */
|
|
218
|
+
function subscribeThemeViewportGroup(onChange: () => void): () => void {
|
|
219
|
+
return subscribeNativeSelectionTheme(null, onChange);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface Object3DDocumentViewportProps
|
|
223
|
+
extends Omit<ToolObject3DAuthoringProps, 'build' | 'sourcePath'> {
|
|
224
|
+
/** Required by `build` content (and by the SDK door, where both stay
|
|
225
|
+
* mandatory); a `world-root` stage constructs nothing of its own and has no
|
|
226
|
+
* single source file — its content is the manifest's roots. */
|
|
227
|
+
readonly build?: ToolObject3DAuthoringProps['build'];
|
|
228
|
+
readonly sourcePath?: string;
|
|
229
|
+
/** See {@link Object3DDocumentContent}. Default `{ kind: 'build' }`. */
|
|
230
|
+
readonly content?: Object3DDocumentContent;
|
|
231
|
+
/** See `@volter/editor-sdk`'s `ToolObject3DAuthoringProps.audit`. */
|
|
232
|
+
readonly audit?: boolean;
|
|
233
|
+
/** Reuse the canonical Asset Lab viewport with NONE of the document chrome —
|
|
234
|
+
* no toolbar, no animation widget, no workspace-document registration —
|
|
235
|
+
* so it can fill a small box (the inspector's preview section). */
|
|
236
|
+
readonly chromeless?: boolean;
|
|
237
|
+
readonly modelSource?:
|
|
238
|
+
| { readonly kind: 'project-file'; readonly path: string }
|
|
239
|
+
| { readonly kind: 'entity'; readonly entityId: string };
|
|
240
|
+
/** Asset Lab's native subject kind for this Object3D document. */
|
|
241
|
+
readonly assetType?: string;
|
|
242
|
+
/**
|
|
243
|
+
* Editor-side authoring for a document whose write authority is project
|
|
244
|
+
* SOURCE (a mounted R3F composition's stamped JSX, today). The caller that
|
|
245
|
+
* knows the source lane supplies the adapter; this document constructs
|
|
246
|
+
* none of them, so it stays mountable without any source lane in its
|
|
247
|
+
* closure (ARCHITECTURE-CORE §Editor chrome, "the viewport stack is
|
|
248
|
+
* separable"). Raw model/artifact documents omit this and remain honestly
|
|
249
|
+
* read-only through the native source adapter. Consulted only when the
|
|
250
|
+
* project `authoring` factory declined. MUST be referentially stable for
|
|
251
|
+
* the document's lifetime, like `build`: it is an activation dependency.
|
|
252
|
+
*/
|
|
253
|
+
readonly sourceAuthoring?: SourceDocumentAuthoringFactory;
|
|
254
|
+
/**
|
|
255
|
+
* Where this viewport's `WebGLRenderer` comes from.
|
|
256
|
+
*
|
|
257
|
+
* `own` (the default) constructs one for this mount — the right answer for a
|
|
258
|
+
* document that lives as long as its panel does. `inspector-preview` draws
|
|
259
|
+
* through the shared inspector-preview renderer
|
|
260
|
+
* (`inspector-preview-renderer.ts`): that lane REMOUNTS PER SELECTION, and a
|
|
261
|
+
* renderer construction there is ~1.9 s of frozen main thread plus a cold
|
|
262
|
+
* shader cache and a fresh PMREM bake, every time you click something.
|
|
263
|
+
*/
|
|
264
|
+
readonly rendererLane?: 'own' | 'inspector-preview';
|
|
265
|
+
/**
|
|
266
|
+
* Double-click on a picked node — the document's "open THIS" gesture,
|
|
267
|
+
* reported with the node's own `Object3D` so the owner can resolve whatever
|
|
268
|
+
* it means by it. Single click stays plain selection; a document that
|
|
269
|
+
* declares no handler has no open gesture at all.
|
|
270
|
+
*
|
|
271
|
+
* Sole caller: the `3D` board, where an exhibit opens that
|
|
272
|
+
* story's own turntable document (`three-board/ThreeBoardDocument.tsx`).
|
|
273
|
+
*/
|
|
274
|
+
readonly onOpenNode?: (object: THREE.Object3D) => void;
|
|
275
|
+
/**
|
|
276
|
+
* When set, Frame with no selection and the view presets fit THIS box
|
|
277
|
+
* instead of `contentWorldBounds(root)`. The 3D board passes the union of
|
|
278
|
+
* each exhibit's presence as its true-scale overview.
|
|
279
|
+
*/
|
|
280
|
+
readonly frameBounds?: {
|
|
281
|
+
readonly min: readonly [number, number, number];
|
|
282
|
+
readonly max: readonly [number, number, number];
|
|
283
|
+
};
|
|
284
|
+
/** Optional first-paint frame distinct from the document's Frame fallback.
|
|
285
|
+
* The 3D component board opens on one readable exhibit, while clearing the
|
|
286
|
+
* selection and pressing Frame still restores its true-scale overview. */
|
|
287
|
+
readonly openingFrameBounds?: {
|
|
288
|
+
readonly min: readonly [number, number, number];
|
|
289
|
+
readonly max: readonly [number, number, number];
|
|
290
|
+
};
|
|
291
|
+
/** Multiplies the opening fit distance; `1` fills the view. */
|
|
292
|
+
readonly openingFit?: number;
|
|
293
|
+
/** The kind of stage this view is, for its starting presentation
|
|
294
|
+
* (`@volter/editor-sdk/kit/viewport-presentation`): the document's own kind (`'model'`).
|
|
295
|
+
* Without it the kind is read off the document id's prefix. */
|
|
296
|
+
readonly stageKind?: string;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Distinguishes the chromeless mounts of ONE document from each other and from
|
|
301
|
+
* that document's viewport, so each publishes its own design-time surface
|
|
302
|
+
* instead of overwriting a sibling's (see the registration below).
|
|
303
|
+
*/
|
|
304
|
+
/** The draw modes a view's presentation carries (`ViewportDrawMode`). */
|
|
305
|
+
const VIEW_DRAW_MODES: readonly ViewportDrawMode[] = [
|
|
306
|
+
'solid',
|
|
307
|
+
'preview',
|
|
308
|
+
'rendered',
|
|
309
|
+
'clay',
|
|
310
|
+
'unlit',
|
|
311
|
+
'wireframe',
|
|
312
|
+
'matcap',
|
|
313
|
+
'normals',
|
|
314
|
+
'overdraw',
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
let chromelessSurfaceSequence = 0;
|
|
318
|
+
|
|
319
|
+
interface RetainedObject3DStageState {
|
|
320
|
+
readonly documentId: string;
|
|
321
|
+
readonly store: EditorShellStore;
|
|
322
|
+
inUse: boolean;
|
|
323
|
+
source: ToolObject3DPreviewSource | null;
|
|
324
|
+
camera: { position: THREE.Vector3; target: THREE.Vector3; up: THREE.Vector3; fov: number } | null;
|
|
325
|
+
presentation: Object3DDocumentPresentationState | null;
|
|
326
|
+
transport: StageTransportSnapshot | null;
|
|
327
|
+
contentSeconds: number;
|
|
328
|
+
transportAdvances: number;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Per-pane state outlives the React surface Code-OSS mounts for the active tab.
|
|
333
|
+
* A second visible pane showing the same document claims a second state slot;
|
|
334
|
+
* an inactive slot is reused only after its former pane has unmounted.
|
|
335
|
+
*/
|
|
336
|
+
const retainedObject3DStages = new RetainedDocumentStates<RetainedObject3DStageState>(
|
|
337
|
+
documentId => ({
|
|
338
|
+
documentId,
|
|
339
|
+
store: threeStateOf(new ShellStore()),
|
|
340
|
+
inUse: false,
|
|
341
|
+
source: null,
|
|
342
|
+
camera: null,
|
|
343
|
+
presentation: null,
|
|
344
|
+
transport: null,
|
|
345
|
+
contentSeconds: 0,
|
|
346
|
+
transportAdvances: 0,
|
|
347
|
+
}),
|
|
348
|
+
documentId => openWorkspaceDocuments().some(document => document.descriptor.id === documentId),
|
|
349
|
+
subscribeWorkspaceDocuments,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The element the viewport draws INTO — an EMPTY host div in both lanes.
|
|
354
|
+
* React never owns the canvas. Both lanes mount a leased renderer canvas into
|
|
355
|
+
* this host imperatively, then return it when the pane hides. A lost canvas is
|
|
356
|
+
* destroyed rather than reused; a healthy one can safely serve the next pane.
|
|
357
|
+
*/
|
|
358
|
+
function ViewportSurface({
|
|
359
|
+
canvasHostRef,
|
|
360
|
+
}: {
|
|
361
|
+
readonly canvasHostRef: RefObject<HTMLDivElement | null>;
|
|
362
|
+
}) {
|
|
363
|
+
return <div ref={canvasHostRef} style={{ position: 'absolute', inset: 0 }} />;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The viewport's drawing surface for one mount: either the shared
|
|
368
|
+
* inspector-preview renderer or the interactive-document renderer pool. The
|
|
369
|
+
* lane decides which exclusive lease to mount into `canvasHost`.
|
|
370
|
+
*/
|
|
371
|
+
function mountViewportSurface(
|
|
372
|
+
canvasHost: HTMLDivElement,
|
|
373
|
+
lane: 'own' | 'inspector-preview',
|
|
374
|
+
displayName: string,
|
|
375
|
+
initialSize: { readonly width: number; readonly height: number },
|
|
376
|
+
onContextLost?: () => void,
|
|
377
|
+
): {
|
|
378
|
+
canvas: HTMLCanvasElement;
|
|
379
|
+
renderer: THREE.WebGLRenderer;
|
|
380
|
+
lease: InspectorPreviewLease | InteractiveViewportRendererLease | null;
|
|
381
|
+
} {
|
|
382
|
+
if (lane === 'inspector-preview') {
|
|
383
|
+
const lease = acquireInspectorPreviewRenderer();
|
|
384
|
+
lease.canvas.setAttribute('aria-label', `${displayName} authoring viewport`);
|
|
385
|
+
canvasHost.appendChild(lease.canvas);
|
|
386
|
+
return { canvas: lease.canvas, renderer: lease.renderer, lease };
|
|
387
|
+
}
|
|
388
|
+
const lease = acquireInteractiveViewportRenderer(
|
|
389
|
+
initialSize.width,
|
|
390
|
+
initialSize.height,
|
|
391
|
+
onContextLost,
|
|
392
|
+
);
|
|
393
|
+
const { canvas, renderer } = lease;
|
|
394
|
+
canvas.setAttribute('aria-label', `${displayName} authoring viewport`);
|
|
395
|
+
canvasHost.appendChild(canvas);
|
|
396
|
+
return { canvas, renderer, lease };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The drawing surface belongs to the document, not to a source revision.
|
|
400
|
+
* Source lifetimes borrow it; closing waits for any asynchronous gesture
|
|
401
|
+
* cleanup to return its borrow before releasing the context. */
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
interface DocumentContentBinding {
|
|
405
|
+
readonly scene: THREE.Scene;
|
|
406
|
+
settle(): Promise<void>;
|
|
407
|
+
dispose(): Promise<void>;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Persistent editor state. Source revisions own only their content binding. */
|
|
411
|
+
class Object3DDocumentHost {
|
|
412
|
+
readonly scene = new THREE.Scene();
|
|
413
|
+
/** This document's store, optionally supplied by its owner. */
|
|
414
|
+
readonly store: EditorShellStore;
|
|
415
|
+
readonly cleanups: Array<() => void> = [];
|
|
416
|
+
readonly rendererSession = new DocumentRendererSession(
|
|
417
|
+
(time, resumed) => this.frame?.(time, resumed),
|
|
418
|
+
() => {},
|
|
419
|
+
);
|
|
420
|
+
viewport: EditorViewport | null = null;
|
|
421
|
+
session: Object3DDocumentSession | null = null;
|
|
422
|
+
dressing: StandardViewportDressing | null = null;
|
|
423
|
+
/** The stage's lighting, tone and exposure, from its view's presentation
|
|
424
|
+
* (`standard-viewport-dressing.ts`, `kit/viewport-presentation`). */
|
|
425
|
+
presentationRig: StagePresentationRig | null = null;
|
|
426
|
+
/** Re-apply the view's presentation (after the viewport and session exist). */
|
|
427
|
+
applyPresentation: (() => void) | null = null;
|
|
428
|
+
/** Whether the view's presentation replaced the stage's own backdrop at the last draw. */
|
|
429
|
+
backdropOverridden = false;
|
|
430
|
+
/** The document's own view-locked studio, under the stage's holder (`dressing.viewLocked`). */
|
|
431
|
+
documentStudio: THREE.Group | null = null;
|
|
432
|
+
/** The lights the content carries and what it holds, for the view's `auto` rule. */
|
|
433
|
+
contentLights: readonly THREE.Light[] = [];
|
|
434
|
+
contentHas: { readonly light: boolean; readonly 'directional-light': boolean } = {
|
|
435
|
+
light: false,
|
|
436
|
+
'directional-light': false,
|
|
437
|
+
};
|
|
438
|
+
private darkened: THREE.Light[] = [];
|
|
439
|
+
/** Hide the content's own lights for the coming draw; the rendered scene's after-render hook
|
|
440
|
+
* shows them again. */
|
|
441
|
+
darkenContentLights(): number {
|
|
442
|
+
for (const light of this.contentLights) {
|
|
443
|
+
if (!light.visible) continue;
|
|
444
|
+
light.visible = false;
|
|
445
|
+
this.darkened.push(light);
|
|
446
|
+
}
|
|
447
|
+
if (this.darkened.length === 0) return 0;
|
|
448
|
+
const count = this.darkened.length;
|
|
449
|
+
this.scene.onAfterRender = () => {
|
|
450
|
+
for (const light of this.darkened) light.visible = true;
|
|
451
|
+
this.darkened = [];
|
|
452
|
+
this.scene.onAfterRender = () => {};
|
|
453
|
+
};
|
|
454
|
+
return count;
|
|
455
|
+
}
|
|
456
|
+
defaultEnvironment: THREE.Texture | null = null;
|
|
457
|
+
defaultBackground: THREE.Color | THREE.Texture | null = null;
|
|
458
|
+
adapter: AuthoringAdapter | null = null;
|
|
459
|
+
/** The adapter a project authoring is built over, whose ids the object index and selection
|
|
460
|
+
* use (`presentationAdapter` at bind); null where the project's adapter is the only space. */
|
|
461
|
+
presentationAdapter: AuthoringAdapter | null = null;
|
|
462
|
+
content: DocumentContentBinding | null = null;
|
|
463
|
+
frame: ((time: number, resumed: boolean) => void) | null = null;
|
|
464
|
+
/** Mirrors the document scene's world dressing onto the rendered scene. The
|
|
465
|
+
* HOST owns it; the active session holds a reference and runs it before
|
|
466
|
+
* every draw (see {@link Object3DDocumentSession.setBeforeRender}). */
|
|
467
|
+
syncHostScene: (() => void) | null = null;
|
|
468
|
+
/** The other participants on THIS stage, once its module has loaded
|
|
469
|
+
* (`stage-presence-markers.ts`). Null on a chromeless mount and until then. */
|
|
470
|
+
presence: { syncMarkers(dtSeconds: number): void; live(): boolean } | null = null;
|
|
471
|
+
initialized = false;
|
|
472
|
+
contentSeconds = 0;
|
|
473
|
+
transportAdvances = 0;
|
|
474
|
+
private users = 0;
|
|
475
|
+
private closed = false;
|
|
476
|
+
private released = false;
|
|
477
|
+
|
|
478
|
+
/** Whether {@link close} has run — a late async install must not push a
|
|
479
|
+
* cleanup onto a host whose cleanups have already been drained. */
|
|
480
|
+
get isClosed(): boolean {
|
|
481
|
+
return this.closed;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** THIS STAGE's transport — the one holder of the content-time door below.
|
|
485
|
+
* Built from this stage's own store, because `driver` is that store's
|
|
486
|
+
* `playState` and nothing else (`animation/stage-transport.ts`). */
|
|
487
|
+
readonly transport: StageTransport;
|
|
488
|
+
|
|
489
|
+
constructor(
|
|
490
|
+
readonly lane: 'own' | 'inspector-preview',
|
|
491
|
+
readonly surface: ReturnType<typeof mountViewportSurface>,
|
|
492
|
+
store?: EditorShellStore,
|
|
493
|
+
) {
|
|
494
|
+
this.store = store ?? threeStateOf(new ShellStore());
|
|
495
|
+
this.transport = new StageTransport(this.store.shell);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
borrow(): () => void {
|
|
499
|
+
this.users++;
|
|
500
|
+
let returned = false;
|
|
501
|
+
return () => {
|
|
502
|
+
if (returned) return;
|
|
503
|
+
returned = true;
|
|
504
|
+
this.users--;
|
|
505
|
+
this.releaseIfClosed();
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
close(): void {
|
|
510
|
+
this.closed = true;
|
|
511
|
+
this.frame = null;
|
|
512
|
+
this.syncHostScene = null;
|
|
513
|
+
this.session?.setBeforeRender(null);
|
|
514
|
+
this.session?.setPresentsFrames(false);
|
|
515
|
+
this.rendererSession.setActive(false);
|
|
516
|
+
void this.content?.dispose();
|
|
517
|
+
// The one teardown path allowed to end the transport (its header states
|
|
518
|
+
// the ownership): the document that owns this stage is unmounting.
|
|
519
|
+
this.transport.dispose();
|
|
520
|
+
this.releaseIfClosed();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private releaseIfClosed(): void {
|
|
524
|
+
if (!this.closed || this.users !== 0 || this.released) return;
|
|
525
|
+
this.released = true;
|
|
526
|
+
const { lease, renderer, canvas } = this.surface;
|
|
527
|
+
for (const cleanup of [
|
|
528
|
+
...this.cleanups.splice(0),
|
|
529
|
+
() => this.session?.dispose(),
|
|
530
|
+
() => this.viewport?.dispose(),
|
|
531
|
+
() => this.rendererSession.dispose(),
|
|
532
|
+
() => this.dressing?.dispose(),
|
|
533
|
+
...(lease
|
|
534
|
+
? [() => lease.release()]
|
|
535
|
+
: [() => renderer.dispose(), () => renderer.forceContextLoss(), () => canvas.remove()]),
|
|
536
|
+
]) {
|
|
537
|
+
try {
|
|
538
|
+
cleanup();
|
|
539
|
+
} catch (error) {
|
|
540
|
+
// biome-ignore lint/suspicious/noConsole: all remaining resources must still be released
|
|
541
|
+
console.error('Object3D document cleanup failed.', error);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Editor-owned authoring host for a project-supplied native Object3D factory.
|
|
549
|
+
* Project code supplies only source construction; this component owns the
|
|
550
|
+
* real EditorViewport, document-scoped AuthoringAdapter, selection, framing,
|
|
551
|
+
* animation preview, resize, and teardown.
|
|
552
|
+
*
|
|
553
|
+
* ## TIME ON THIS SURFACE — the one policy, inherited by everything mounted here
|
|
554
|
+
*
|
|
555
|
+
* This host is the shared stack: Asset Lab documents, story turntables, 3D board
|
|
556
|
+
* exhibits and the inspector's preview card all render through it. So the
|
|
557
|
+
* Edit-static rule is enforced HERE, once, rather than at each of them:
|
|
558
|
+
*
|
|
559
|
+
* - PRESENTATION time always advances — orbit damping, the view cube, the
|
|
560
|
+
* standard dressing, the editor's own helpers and overlays. It is
|
|
561
|
+
* editor-owned and it moves while the editor is in Edit.
|
|
562
|
+
* - CONTENT time does NOT. The source's own `update(dt)` and any clip a
|
|
563
|
+
* subject shows move only through the stage TRANSPORT, and only while a
|
|
564
|
+
* human is holding it (`animation/stage-transport.ts`, WORK.md §The stage
|
|
565
|
+
* transport and the animation door). A mounted
|
|
566
|
+
* document plays nothing on its own: that is the defect this closes (a GLB
|
|
567
|
+
* train animating in the Asset Lab while every instrument called the editor
|
|
568
|
+
* still, owner find 2026-08-15).
|
|
569
|
+
*
|
|
570
|
+
* Whatever content time this surface has reached is published as its content
|
|
571
|
+
* clock (`coverage/design-time-surfaces.ts`), so the Edit-static vitals row
|
|
572
|
+
* measures THIS surface by name instead of speaking only for the Scene view.
|
|
573
|
+
*/
|
|
574
|
+
export function Object3DDocumentViewport({
|
|
575
|
+
documentId,
|
|
576
|
+
sourcePath,
|
|
577
|
+
build,
|
|
578
|
+
displayName = 'Source Object3D',
|
|
579
|
+
// No default: an omitted background means the STANDARD dressing's gradient;
|
|
580
|
+
// a value is an explicit flat-color override (SDK contributions may pass it).
|
|
581
|
+
background,
|
|
582
|
+
cameraDirection,
|
|
583
|
+
persistence,
|
|
584
|
+
documentSource,
|
|
585
|
+
authoring,
|
|
586
|
+
interaction,
|
|
587
|
+
selectionOutline = true,
|
|
588
|
+
active = true,
|
|
589
|
+
audit = true,
|
|
590
|
+
chromeless = false,
|
|
591
|
+
modelSource,
|
|
592
|
+
assetType,
|
|
593
|
+
sourceAuthoring,
|
|
594
|
+
dressing,
|
|
595
|
+
rendererLane = 'own',
|
|
596
|
+
onOpenNode,
|
|
597
|
+
frameBounds,
|
|
598
|
+
openingFrameBounds,
|
|
599
|
+
openingFit,
|
|
600
|
+
openingView,
|
|
601
|
+
presentation: documentPresentation,
|
|
602
|
+
cameraView,
|
|
603
|
+
stageKind,
|
|
604
|
+
statistics,
|
|
605
|
+
subject,
|
|
606
|
+
gridScale,
|
|
607
|
+
content,
|
|
608
|
+
}: Object3DDocumentViewportProps) {
|
|
609
|
+
const viewStageKind = stageKind ?? stageKindOf(documentId);
|
|
610
|
+
// ANNOUNCE THE STAGE (`authoring/object3d-document-session-registry.ts`).
|
|
611
|
+
// The lazy boundary announces for the documents that go through it; the
|
|
612
|
+
// asset viewers import THIS component directly (`asset-viewers/
|
|
613
|
+
// ModelAssetDocument.tsx:19` and its two siblings), so the announcement is
|
|
614
|
+
// made here too. Announcing is counted, so the two overlap harmlessly.
|
|
615
|
+
useEffect(() => {
|
|
616
|
+
if (chromeless) return;
|
|
617
|
+
return announceDocumentStage(documentId);
|
|
618
|
+
}, [documentId, chromeless]);
|
|
619
|
+
// The SHELL store, not this host's own: what a stage is showing is a fact
|
|
620
|
+
// about the workspace's document, and `stage-context.ts` is where every
|
|
621
|
+
// stage capability asks it (ARCHITECTURE-CORE §One stage). OPTIONAL because
|
|
622
|
+
// this host is a bounded-host surface — `@volter/editor-blender`'s Model document
|
|
623
|
+
// mounts it with no `EditorProvider` above it, and the throwing hook took
|
|
624
|
+
// that document off the screen with "useEditorStore must be used within
|
|
625
|
+
// <EditorProvider>" (measured live, 2026-09-18). No shell, no shell frame.
|
|
626
|
+
const shellStore = optionalThreeStateOf(useOptionalEditorStore());
|
|
627
|
+
/**
|
|
628
|
+
* THE STORE THE STAGE CONTEXT IS ASKED AGAINST — the session's own, reached
|
|
629
|
+
* without a React provider.
|
|
630
|
+
*
|
|
631
|
+
* `optionalThreeStateOf(useOptionalEditorStore())` above answers a NARROWER question than the
|
|
632
|
+
* context needs: it is whether the shell's own React tree is above this
|
|
633
|
+
* host, and for every package-contributed document it is not.
|
|
634
|
+
* `@volter/editor-blender`'s Model document mounts this host through `ToolHost`, so the
|
|
635
|
+
* context store is null there — and reading only it left `stageCtx` null,
|
|
636
|
+
* which made EVERY condition in `stage-context.ts` unanswerable for that
|
|
637
|
+
* document. That is a capability decided by where a document's React tree
|
|
638
|
+
* happens to mount, which is the origin-deciding reading §One stage
|
|
639
|
+
* retired. Without the context, the studio arm of `studioStageApplies`
|
|
640
|
+
* could never fire for a model no matter what the look declared.
|
|
641
|
+
*
|
|
642
|
+
* `shellStoreForHost()` supplies the session store that
|
|
643
|
+
* `focusedStageStore()` falls back to. It is deliberately NOT used for the
|
|
644
|
+
* overlay set or the shell readout below: those are the SHELL's estate and
|
|
645
|
+
* their condition is the shell's React presence, exactly as §One stage
|
|
646
|
+
* unit 3 measured it — a stage that is not inside the shell has no shell
|
|
647
|
+
* overlays. That gap is real and named, not closed here: unit 1's bar is
|
|
648
|
+
* that the Model document's Blender frame does not move.
|
|
649
|
+
*/
|
|
650
|
+
const contextStore = shellStore ?? threeStoreForHost();
|
|
651
|
+
/**
|
|
652
|
+
* WHAT THIS STAGE IS SHOWING, asked once (ARCHITECTURE-CORE §One stage).
|
|
653
|
+
* `chrome` is the host's own answer and nothing infers it: an EMBEDDED
|
|
654
|
+
* preview has no workspace document at all, which is exactly why the
|
|
655
|
+
* subject would come back `unknown` for one.
|
|
656
|
+
*/
|
|
657
|
+
const stageCtx = contextStore
|
|
658
|
+
? documentStageContext(contextStore.shell, documentId, chromeless ? 'embedded' : 'document')
|
|
659
|
+
: null;
|
|
660
|
+
/**
|
|
661
|
+
* THE STUDIO PRESENTATION — the alpha clear, the suppressed palette
|
|
662
|
+
* backdrop, the identity-row frame and the asset flavour of this stage's
|
|
663
|
+
* provenance. It replaced a `documentKind` prop the CALLER chose, which is
|
|
664
|
+
* exactly the origin-of-the-document reading §One stage retired: every
|
|
665
|
+
* mount that passed `documentKind="asset"` is a story, an artifact or an
|
|
666
|
+
* embedded preview, and each still answers true.
|
|
667
|
+
*/
|
|
668
|
+
// The LOOK is one of `studioStageApplies`'s live inputs (a data subject takes
|
|
669
|
+
// the studio only where the look leaves the backdrop to the editor), and the
|
|
670
|
+
// one that changes with no store write behind it: switching the style
|
|
671
|
+
// re-emits the tokens onto the theme root's inline `style`, which is exactly
|
|
672
|
+
// what this observes. Without the subscription the presentation would flip
|
|
673
|
+
// only on the next unrelated render, and a style switch is the gesture the
|
|
674
|
+
// condition exists for.
|
|
675
|
+
const lookPaintsViewport = useSyncExternalStore(
|
|
676
|
+
subscribeThemeViewportGroup,
|
|
677
|
+
lookDeclaresViewportColors,
|
|
678
|
+
);
|
|
679
|
+
const studioStage = stageCtx !== null && studioStageApplies(stageCtx, lookPaintsViewport);
|
|
680
|
+
const studioStageRef = useRef(studioStage);
|
|
681
|
+
studioStageRef.current = studioStage;
|
|
682
|
+
// An ARTIFACT document gets the identity-row shell — the frame, the context
|
|
683
|
+
// activation and the SELECTION REGISTRATION (`AssetEditorShell`'s
|
|
684
|
+
// `selection` prop). Asked here, beside the studio presentation, because the
|
|
685
|
+
// mount effect needs it too: whoever does NOT get the shell registers its own
|
|
686
|
+
// workspace selection instead, and exactly one of the two must.
|
|
687
|
+
const hasShell = stageCtx !== null && identityRowApplies(stageCtx);
|
|
688
|
+
const hasShellRef = useRef(hasShell);
|
|
689
|
+
hasShellRef.current = hasShell;
|
|
690
|
+
// The ASSET FLAVOUR of this stage's provenance: the source adapter's
|
|
691
|
+
// `documentKind` ("derived asset", `model-asset`) and the performance
|
|
692
|
+
// source's kind. It is the studio presentation MINUS the data arm — a mesh
|
|
693
|
+
// module opens in an isolation scene but is not a derived asset — and was
|
|
694
|
+
// read off `studioStage` while the two were the same answer.
|
|
695
|
+
const assetSubject = stageCtx !== null && assetSubjectApplies(stageCtx);
|
|
696
|
+
const assetSubjectRef = useRef(assetSubject);
|
|
697
|
+
assetSubjectRef.current = assetSubject;
|
|
698
|
+
// The focused document fills the shell's readout. Null without a shell.
|
|
699
|
+
const shellStats = useOptionalEditorStats();
|
|
700
|
+
const shellStatsRef = useRef(shellStats);
|
|
701
|
+
shellStatsRef.current = shellStats;
|
|
702
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
703
|
+
// The shared lane mounts the pool's own canvas here instead of rendering one.
|
|
704
|
+
const canvasHostRef = useRef<HTMLDivElement>(null);
|
|
705
|
+
const documentHostRef = useRef<Object3DDocumentHost | null>(null);
|
|
706
|
+
const retainedRef = useRef<RetainedObject3DStageState | null>(null);
|
|
707
|
+
if (!retainedRef.current || retainedRef.current.documentId !== documentId) {
|
|
708
|
+
if (retainedRef.current) retainedObject3DStages.release(retainedRef.current);
|
|
709
|
+
retainedRef.current = retainedObject3DStages.claim(documentId);
|
|
710
|
+
}
|
|
711
|
+
useEffect(() => {
|
|
712
|
+
const retained = retainedRef.current!;
|
|
713
|
+
const generation = retainedObject3DStages.generation(retained);
|
|
714
|
+
return () => retainedObject3DStages.release(retained, generation);
|
|
715
|
+
}, [documentId]);
|
|
716
|
+
const retainedState = retainedRef.current;
|
|
717
|
+
// WHAT THIS STAGE SHOWS. A world root stays attached while hidden: Play
|
|
718
|
+
// adopts into it, and the play-entry flight rides its frame clock.
|
|
719
|
+
const worldRoot = content?.kind === 'world-root';
|
|
720
|
+
// Only a visible pane owns an interactive attachment. Hidden documents
|
|
721
|
+
// retain their state and return their renderer to the bounded pool.
|
|
722
|
+
const wantsSurface = active || worldRoot;
|
|
723
|
+
const worldRootLoadRef = useRef(content?.kind === 'world-root' ? content.load : null);
|
|
724
|
+
worldRootLoadRef.current = content?.kind === 'world-root' ? content.load : null;
|
|
725
|
+
const [surfaceAttached, setSurfaceAttached] = useState(wantsSurface);
|
|
726
|
+
useEffect(() => setSurfaceAttached(wantsSurface), [wantsSurface]);
|
|
727
|
+
// The selection silhouette is a LIVE prop: a modeling document turns it off
|
|
728
|
+
// the moment it enters its own sub-object mode and back on when it leaves,
|
|
729
|
+
// and the session applies that to the composer it has already built. The ref
|
|
730
|
+
// is what the mount path reads, since the session is built inside an effect
|
|
731
|
+
// that does not depend on this prop.
|
|
732
|
+
const selectionOutlineRef = useRef(selectionOutline);
|
|
733
|
+
useEffect(() => {
|
|
734
|
+
selectionOutlineRef.current = selectionOutline;
|
|
735
|
+
const session = documentHostRef.current?.session;
|
|
736
|
+
if (session)
|
|
737
|
+
session.selectionOutlineEnabled = rendererLane !== 'inspector-preview' && selectionOutline;
|
|
738
|
+
// And the view's own selection marks over it (`overlays.selection.outline`).
|
|
739
|
+
documentHostRef.current?.applyPresentation?.();
|
|
740
|
+
}, [selectionOutline, rendererLane]);
|
|
741
|
+
const contentUpdateTail = useRef<Promise<void>>(Promise.resolve());
|
|
742
|
+
const retainSourceOnDisposeRef = useRef(false);
|
|
743
|
+
useEffect(() => {
|
|
744
|
+
if (!surfaceAttached) return;
|
|
745
|
+
firstFrameGateRef.current = false;
|
|
746
|
+
setSurfaceStatus('building');
|
|
747
|
+
return () => {
|
|
748
|
+
const host = documentHostRef.current;
|
|
749
|
+
if (host && !worldRoot) {
|
|
750
|
+
// Inactive tabs stay open in the workspace. Their source remains
|
|
751
|
+
// mounted in retained CPU state while the expensive attachment goes
|
|
752
|
+
// back to the pool. A source revision or final close still disposes it.
|
|
753
|
+
retainSourceOnDisposeRef.current = !activeRef.current;
|
|
754
|
+
const pose = host.store.cameraPose;
|
|
755
|
+
retainedState.camera =
|
|
756
|
+
pose && host.viewport ? { ...pose, up: host.viewport.camera.up.clone() } : null;
|
|
757
|
+
retainedState.presentation = host.session?.presentation() ?? null;
|
|
758
|
+
retainedState.transport = host.transport.snapshot();
|
|
759
|
+
retainedState.contentSeconds = host.contentSeconds;
|
|
760
|
+
retainedState.transportAdvances = host.transportAdvances;
|
|
761
|
+
}
|
|
762
|
+
documentStateRef.current = null;
|
|
763
|
+
persistenceSessionRef.current = null;
|
|
764
|
+
interactionExtensionRef.current = null;
|
|
765
|
+
rendererSessionRef.current = null;
|
|
766
|
+
host?.close();
|
|
767
|
+
documentHostRef.current = null;
|
|
768
|
+
};
|
|
769
|
+
}, [documentId, rendererLane, retainedState, surfaceAttached, worldRoot]);
|
|
770
|
+
// Read through a ref: the one big lifetime effect below owns the canvas
|
|
771
|
+
// listener, and re-running it on a new handler identity would tear the whole
|
|
772
|
+
// renderer down and rebuild it.
|
|
773
|
+
const onOpenNodeRef = useRef(onOpenNode);
|
|
774
|
+
onOpenNodeRef.current = onOpenNode;
|
|
775
|
+
const frameBoundsRef = useRef(frameBounds);
|
|
776
|
+
frameBoundsRef.current = frameBounds;
|
|
777
|
+
const openingFrameBoundsRef = useRef(openingFrameBounds);
|
|
778
|
+
openingFrameBoundsRef.current = openingFrameBounds;
|
|
779
|
+
const openingViewRef = useRef(openingView);
|
|
780
|
+
openingViewRef.current = openingView;
|
|
781
|
+
const documentPresentationRef = useRef(documentPresentation);
|
|
782
|
+
documentPresentationRef.current = documentPresentation;
|
|
783
|
+
const cameraViewRef = useRef(cameraView);
|
|
784
|
+
cameraViewRef.current = cameraView;
|
|
785
|
+
// The current prop is still read by asynchronous installation. Attachment
|
|
786
|
+
// lifetime is controlled separately by `surfaceAttached` above.
|
|
787
|
+
const activeRef = useRef(active);
|
|
788
|
+
activeRef.current = active;
|
|
789
|
+
const previousActiveRef = useRef(active);
|
|
790
|
+
const sourceIdentityRef = useRef({ build, sourcePath });
|
|
791
|
+
useEffect(() => {
|
|
792
|
+
const previous = sourceIdentityRef.current;
|
|
793
|
+
sourceIdentityRef.current = { build, sourcePath };
|
|
794
|
+
if (previous.build === build && previous.sourcePath === sourcePath) return;
|
|
795
|
+
// An HMR revision received while hidden invalidates the prepared source.
|
|
796
|
+
// The next reveal constructs the current revision while retaining the
|
|
797
|
+
// pane's camera, tool, helpers, selection, and transport state.
|
|
798
|
+
if (!surfaceAttached && retainedState.source) {
|
|
799
|
+
retainedState.source.dispose();
|
|
800
|
+
retainedState.source = null;
|
|
801
|
+
}
|
|
802
|
+
}, [build, retainedState, sourcePath, surfaceAttached]);
|
|
803
|
+
const rendererSessionRef = useRef<DocumentRendererSession | null>(null);
|
|
804
|
+
const firstFrameGateRef = useRef(false);
|
|
805
|
+
const interactionExtensionRef = useRef<ReturnType<
|
|
806
|
+
NonNullable<typeof interaction>['setup']
|
|
807
|
+
> | null>(null);
|
|
808
|
+
const documentStateRef = useRef<{
|
|
809
|
+
root: THREE.Object3D;
|
|
810
|
+
animations: readonly THREE.AnimationClip[];
|
|
811
|
+
} | null>(null);
|
|
812
|
+
const persistenceSessionRef = useRef<Promise<Object3DDocumentPersistenceSession> | null>(null);
|
|
813
|
+
const [error, setError] = useState<string | null>(null);
|
|
814
|
+
const [surfaceStatus, setSurfaceStatus] = useState<'building' | 'ready'>('building');
|
|
815
|
+
// A world root's own mount state and the binding that draws its overlays.
|
|
816
|
+
const [worldRootBinding, setWorldRootBinding] = useState<WorldRootStageBinding | null>(null);
|
|
817
|
+
const [designMountStatus, setDesignMountStatus] = useState<'mounting' | 'ready'>('mounting');
|
|
818
|
+
const [designRootIds, setDesignRootIds] = useState<readonly string[]>([]);
|
|
819
|
+
const cameraPreviewRef = useRef<HTMLDivElement | null>(null);
|
|
820
|
+
const [projection, setProjection] = useState<ThreeViewportProjection>('perspective');
|
|
821
|
+
// The live authoring adapter this document's inspection composes from. It
|
|
822
|
+
// only exists once the graph is built, so it is state rather than a ref:
|
|
823
|
+
// its arrival is what re-renders the aside below.
|
|
824
|
+
const [documentAdapter, setDocumentAdapter] = useState<AuthoringAdapter | null>(null);
|
|
825
|
+
const documentAdapterRef = useRef<AuthoringAdapter | null>(null);
|
|
826
|
+
documentAdapterRef.current = documentAdapter;
|
|
827
|
+
const cameraX = cameraDirection?.[0];
|
|
828
|
+
const cameraY = cameraDirection?.[1];
|
|
829
|
+
const cameraZ = cameraDirection?.[2];
|
|
830
|
+
const modelSourceKind = modelSource?.kind;
|
|
831
|
+
const modelSourcePath = modelSource?.kind === 'project-file' ? modelSource.path : undefined;
|
|
832
|
+
const modelSourceEntityId = modelSource?.kind === 'entity' ? modelSource.entityId : undefined;
|
|
833
|
+
const dressingEnvironment = dressing?.environment;
|
|
834
|
+
const dressingBackground = dressing?.background;
|
|
835
|
+
const dressingKeyLight = dressing?.keyLight;
|
|
836
|
+
const dressingGrid = dressing?.grid;
|
|
837
|
+
const dressingViewLocked = dressing?.viewLocked;
|
|
838
|
+
const dressingToneMapping = dressing?.toneMapping;
|
|
839
|
+
if (active) markViewportReactActive(documentId);
|
|
840
|
+
|
|
841
|
+
// Document identity owns the host. Source changes prepare a separate native
|
|
842
|
+
// graph, then publish it into that host without recreating editor state.
|
|
843
|
+
useEffect(() => {
|
|
844
|
+
let cancelled = false;
|
|
845
|
+
if (!surfaceAttached) return;
|
|
846
|
+
/**
|
|
847
|
+
* THE WORLD ROOT'S CONTENT BINDING. It shows the project's world rather
|
|
848
|
+
* than one constructed Object3D, so it brings its own surface, scene,
|
|
849
|
+
* composer and the presenter Play adopts through; everything else about
|
|
850
|
+
* the stage is the host's, exactly as it is for a prefab or a model.
|
|
851
|
+
*/
|
|
852
|
+
const installWorldRoot = async (
|
|
853
|
+
load: () => Promise<WorldRootStageBinding>,
|
|
854
|
+
): Promise<void> => {
|
|
855
|
+
const container = containerRef.current;
|
|
856
|
+
const canvasHost = canvasHostRef.current;
|
|
857
|
+
if (!container || !canvasHost || documentHostRef.current) return;
|
|
858
|
+
const stats = shellStatsRef.current;
|
|
859
|
+
if (!stats) {
|
|
860
|
+
// The world root IS the shell's own subject — its readout, its
|
|
861
|
+
// adoption stack, its transport. A bounded host has none of that.
|
|
862
|
+
setError('The world root needs the editor shell above it; this host has none.');
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const binding = await load();
|
|
866
|
+
if (cancelled || documentHostRef.current) return;
|
|
867
|
+
setWorldRootBinding(() => binding);
|
|
868
|
+
markViewportConstructStarted();
|
|
869
|
+
const host = new Object3DDocumentHost(
|
|
870
|
+
'own',
|
|
871
|
+
binding.mountWorldRootSurface(canvasHost, container, displayName),
|
|
872
|
+
// The world is the session's own subject: Play adoption, ingest, the
|
|
873
|
+
// project history and the host door's hierarchy facet all address the
|
|
874
|
+
// SESSION store, so this stage runs on it rather than a private one.
|
|
875
|
+
threeStoreForHost() ?? undefined,
|
|
876
|
+
);
|
|
877
|
+
documentHostRef.current = host;
|
|
878
|
+
host.cleanups.push(registerStageStore(documentId, host.store.shell));
|
|
879
|
+
host.cleanups.push(registerStageTransport(documentId, host.transport));
|
|
880
|
+
host.cleanups.push(
|
|
881
|
+
registerDocumentViewport(documentId, {
|
|
882
|
+
...sceneDocumentViewport(host.store, documentId),
|
|
883
|
+
...threeStageTransformChrome(documentId),
|
|
884
|
+
}),
|
|
885
|
+
);
|
|
886
|
+
const stage = binding.installWorldRootStage({
|
|
887
|
+
store: host.store,
|
|
888
|
+
documentId,
|
|
889
|
+
container,
|
|
890
|
+
canvas: host.surface.canvas,
|
|
891
|
+
renderer: host.surface.renderer,
|
|
892
|
+
stats,
|
|
893
|
+
cameraPreview: () => cameraPreviewRef.current,
|
|
894
|
+
onMountStatus: setDesignMountStatus,
|
|
895
|
+
onRootIds: setDesignRootIds,
|
|
896
|
+
runFrame: (delta) => runViewportFrame(documentId, delta),
|
|
897
|
+
});
|
|
898
|
+
host.viewport = stage.viewport;
|
|
899
|
+
host.frame = stage.frame;
|
|
900
|
+
host.content = {
|
|
901
|
+
scene: stage.scene,
|
|
902
|
+
settle: () => Promise.resolve(),
|
|
903
|
+
dispose: async () => stage.dispose(),
|
|
904
|
+
};
|
|
905
|
+
// The world's tree arrives asynchronously and keeps changing, so its
|
|
906
|
+
// clip subjects are rescanned on the store's change notification,
|
|
907
|
+
// throttled, and not while Play drives time.
|
|
908
|
+
// The scene the stage DRAWS: an R3F world renders the session store's
|
|
909
|
+
// scene, not the stage's own (`world-root-stage.ts`), and scanning the
|
|
910
|
+
// latter found no subject on a world whose enemies each had a mixer.
|
|
911
|
+
const clipScan = scanClipSubjects(() => host.store.scene ?? stage.scene, host.transport, {
|
|
912
|
+
// A game makes its mixers as its world runs, not when the store changes.
|
|
913
|
+
rescanOnLiveMixers: () => host.store.shell.playState === 'stopped',
|
|
914
|
+
});
|
|
915
|
+
let lastClipScan = 0;
|
|
916
|
+
host.cleanups.push(() => clipScan.dispose());
|
|
917
|
+
host.cleanups.push(
|
|
918
|
+
host.store.shell.subscribe(() => {
|
|
919
|
+
if (host.store.shell.playState !== 'stopped') return;
|
|
920
|
+
const now = performance.now();
|
|
921
|
+
if (now - lastClipScan < CLIP_RESCAN_INTERVAL_MS) return;
|
|
922
|
+
lastClipScan = now;
|
|
923
|
+
clipScan.refresh();
|
|
924
|
+
}),
|
|
925
|
+
);
|
|
926
|
+
// This stage's presenter is the real one: Play adoption is a condition
|
|
927
|
+
// on the world-root binding, and a `build` stage declines every root.
|
|
928
|
+
host.cleanups.push(
|
|
929
|
+
bindViewportRig(
|
|
930
|
+
{
|
|
931
|
+
camera: stage.viewport.camera,
|
|
932
|
+
drawCamera: () => stage.viewport.renderCamera,
|
|
933
|
+
orbit: stage.viewport.orbitControls,
|
|
934
|
+
scene: stage.scene,
|
|
935
|
+
},
|
|
936
|
+
stage.present,
|
|
937
|
+
stage.setHelper,
|
|
938
|
+
{ documentId },
|
|
939
|
+
),
|
|
940
|
+
);
|
|
941
|
+
rendererSessionRef.current = host.rendererSession;
|
|
942
|
+
host.initialized = true;
|
|
943
|
+
markViewportConstructReady();
|
|
944
|
+
// The binding's own overlay reports mount progress.
|
|
945
|
+
setSurfaceStatus('ready');
|
|
946
|
+
// Always active: the play-entry flight rides this clock while the Game
|
|
947
|
+
// document holds focus; the stage skips frames by its own visibility.
|
|
948
|
+
host.rendererSession.setActive(true);
|
|
949
|
+
};
|
|
950
|
+
const install = async () => {
|
|
951
|
+
await documentHostRef.current?.content?.settle();
|
|
952
|
+
while (liveGestureActive()) await whenLiveGestureIdle();
|
|
953
|
+
if (cancelled) return;
|
|
954
|
+
const loadWorldRoot = worldRootLoadRef.current;
|
|
955
|
+
if (loadWorldRoot) return installWorldRoot(loadWorldRoot);
|
|
956
|
+
const container = containerRef.current;
|
|
957
|
+
const canvasHost = canvasHostRef.current;
|
|
958
|
+
if (!container || !canvasHost) return;
|
|
959
|
+
if (!build || sourcePath === undefined) {
|
|
960
|
+
// `build` content is one Object3D the caller constructs from one file.
|
|
961
|
+
// Both are required by the SDK; validate callers at the runtime boundary.
|
|
962
|
+
setError('This stage shows `build` content and was given no `build()`/`sourcePath`.');
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
let source: ReturnType<NonNullable<typeof build>>;
|
|
966
|
+
const retainedGeneration = retainedObject3DStages.generation(retainedState);
|
|
967
|
+
try {
|
|
968
|
+
source = retainedState.source ?? build();
|
|
969
|
+
retainedState.source = null;
|
|
970
|
+
} catch (caught) {
|
|
971
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
try {
|
|
975
|
+
if (!documentHostRef.current) {
|
|
976
|
+
markViewportConstructStarted();
|
|
977
|
+
documentHostRef.current = new Object3DDocumentHost(
|
|
978
|
+
rendererLane,
|
|
979
|
+
mountViewportSurface(
|
|
980
|
+
canvasHost,
|
|
981
|
+
rendererLane,
|
|
982
|
+
displayName,
|
|
983
|
+
{
|
|
984
|
+
width: Math.max(1, container.clientWidth),
|
|
985
|
+
height: Math.max(1, container.clientHeight),
|
|
986
|
+
},
|
|
987
|
+
() => {
|
|
988
|
+
// A lost context is never returned to the pool. Rebuild this
|
|
989
|
+
// visible attachment from the retained document state.
|
|
990
|
+
setSurfaceAttached(false);
|
|
991
|
+
window.setTimeout(() => {
|
|
992
|
+
if (activeRef.current) setSurfaceAttached(true);
|
|
993
|
+
}, 0);
|
|
994
|
+
},
|
|
995
|
+
),
|
|
996
|
+
retainedState.store,
|
|
997
|
+
);
|
|
998
|
+
documentHostRef.current.contentSeconds = retainedState.contentSeconds;
|
|
999
|
+
documentHostRef.current.transportAdvances = retainedState.transportAdvances;
|
|
1000
|
+
}
|
|
1001
|
+
} catch (caught) {
|
|
1002
|
+
source.dispose();
|
|
1003
|
+
throw caught;
|
|
1004
|
+
}
|
|
1005
|
+
const host = documentHostRef.current!;
|
|
1006
|
+
const returnSurface = host.borrow();
|
|
1007
|
+
const { renderer, canvas, lease } = host.surface;
|
|
1008
|
+
const store = host.store;
|
|
1009
|
+
// This stage's store, reachable by the shared panels through
|
|
1010
|
+
// `focusedStageStore()` (ARCHITECTURE-CORE §One stage unit 4). A
|
|
1011
|
+
// CHROMELESS preview is not a stage anything can focus — it has no
|
|
1012
|
+
// workspace document — so it registers nothing.
|
|
1013
|
+
if (!chromeless) {
|
|
1014
|
+
host.cleanups.push(registerStageStore(documentId, store.shell));
|
|
1015
|
+
host.cleanups.push(registerStageTransport(documentId, host.transport));
|
|
1016
|
+
}
|
|
1017
|
+
const history = workspaceHistoryService();
|
|
1018
|
+
if (history) store.shell.attachHistory(history);
|
|
1019
|
+
const shared = rendererLane === 'inspector-preview';
|
|
1020
|
+
const studioStage = studioStageRef.current;
|
|
1021
|
+
const hasShell = hasShellRef.current;
|
|
1022
|
+
const assetSubject = assetSubjectRef.current;
|
|
1023
|
+
const scene = new THREE.Scene();
|
|
1024
|
+
// The image-based light's strength is the view presentation's (its studio preset's), set
|
|
1025
|
+
// on the rendered scene before every draw (`syncHostScene`). The content scene keeps
|
|
1026
|
+
// three's default, so a document that authors its own strength still states it here.
|
|
1027
|
+
const sourceParent = source.root.parent;
|
|
1028
|
+
scene.add(source.root);
|
|
1029
|
+
if (source.animations) source.root.animations = [...source.animations];
|
|
1030
|
+
const documentState = { root: source.root, animations: source.root.animations };
|
|
1031
|
+
let defaultAdapter: SourceObject3DAuthoringAdapter | null = null;
|
|
1032
|
+
let projectAuthoring: ToolObject3DDocumentAuthoring | null = null;
|
|
1033
|
+
let activePersistenceSession: Promise<Object3DDocumentPersistenceSession> | null = null;
|
|
1034
|
+
let interactionExtension: ReturnType<NonNullable<typeof interaction>['setup']> | null = null;
|
|
1035
|
+
let gestureController: Object3DGestureController | null = null;
|
|
1036
|
+
let pointerDownListener: ((event: PointerEvent) => void) | null = null;
|
|
1037
|
+
let pointerMoveListener: ((event: PointerEvent) => void) | null = null;
|
|
1038
|
+
let pointerUpListener: ((event: PointerEvent) => void) | null = null;
|
|
1039
|
+
let pointerCancelListener: ((event: PointerEvent) => void) | null = null;
|
|
1040
|
+
let escapeListener: ((event: KeyboardEvent) => void) | null = null;
|
|
1041
|
+
let stopDrawSignals: (() => void) | null = null;
|
|
1042
|
+
let activateInteraction: (() => void) | null = null;
|
|
1043
|
+
let rollback: (() => void) | null = null;
|
|
1044
|
+
let published = false;
|
|
1045
|
+
let disposed = false;
|
|
1046
|
+
let cleanup: Promise<void> | null = null;
|
|
1047
|
+
const binding: DocumentContentBinding = {
|
|
1048
|
+
scene,
|
|
1049
|
+
settle: () => gestureController?.settle() ?? Promise.resolve(),
|
|
1050
|
+
dispose: () => {
|
|
1051
|
+
if (cleanup) return cleanup;
|
|
1052
|
+
disposed = true;
|
|
1053
|
+
const retainSource = retainSourceOnDisposeRef.current;
|
|
1054
|
+
retainSourceOnDisposeRef.current = false;
|
|
1055
|
+
if (pointerDownListener)
|
|
1056
|
+
container.removeEventListener('pointerdown', pointerDownListener, true);
|
|
1057
|
+
if (pointerMoveListener)
|
|
1058
|
+
container.removeEventListener('pointermove', pointerMoveListener, true);
|
|
1059
|
+
if (pointerUpListener)
|
|
1060
|
+
container.removeEventListener('pointerup', pointerUpListener, true);
|
|
1061
|
+
if (pointerCancelListener)
|
|
1062
|
+
container.removeEventListener('pointercancel', pointerCancelListener, true);
|
|
1063
|
+
if (escapeListener) window.removeEventListener('keydown', escapeListener);
|
|
1064
|
+
stopDrawSignals?.();
|
|
1065
|
+
stopDrawSignals = null;
|
|
1066
|
+
cleanup = (async () => {
|
|
1067
|
+
await gestureController?.settle();
|
|
1068
|
+
const failures: unknown[] = [];
|
|
1069
|
+
for (const release of [
|
|
1070
|
+
() => interactionExtension?.dispose(),
|
|
1071
|
+
() => projectAuthoring?.dispose?.(),
|
|
1072
|
+
() => defaultAdapter?.dispose(),
|
|
1073
|
+
() => scene.removeFromParent(),
|
|
1074
|
+
() => {
|
|
1075
|
+
if (retainSource)
|
|
1076
|
+
retainedObject3DStages.retain(retainedState, source, retainedGeneration);
|
|
1077
|
+
else source.dispose();
|
|
1078
|
+
},
|
|
1079
|
+
returnSurface,
|
|
1080
|
+
]) {
|
|
1081
|
+
try {
|
|
1082
|
+
release();
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
failures.push(error);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
if (failures.length) {
|
|
1088
|
+
// biome-ignore lint/suspicious/noConsole: teardown must remain diagnosable
|
|
1089
|
+
console.error('Object3D source cleanup failed.', ...failures);
|
|
1090
|
+
}
|
|
1091
|
+
})();
|
|
1092
|
+
return cleanup;
|
|
1093
|
+
},
|
|
1094
|
+
};
|
|
1095
|
+
try {
|
|
1096
|
+
const commitProjectDocument = async (label?: string): Promise<boolean> => {
|
|
1097
|
+
try {
|
|
1098
|
+
const document = documentState;
|
|
1099
|
+
if (documentSource && !persistence) {
|
|
1100
|
+
// The whole document is ONE source module: the same seam the
|
|
1101
|
+
// animation binding writes through — checksum-guarded whole-file
|
|
1102
|
+
// replace, recorded in canonical history — never the resource
|
|
1103
|
+
// route, which refuses executable source by rule.
|
|
1104
|
+
if (!document)
|
|
1105
|
+
throw new Error('Project history is unavailable; the edit was not saved.');
|
|
1106
|
+
const history = workspaceHistoryService();
|
|
1107
|
+
if (!history) {
|
|
1108
|
+
throw new Error('Project history is unavailable; the edit was not saved.');
|
|
1109
|
+
}
|
|
1110
|
+
const source = documentSource.serialize(document);
|
|
1111
|
+
if (source === null) {
|
|
1112
|
+
// The document says nothing changed: a real no-change outcome,
|
|
1113
|
+
// never a write of the emitted form over a hand-written file.
|
|
1114
|
+
setError(null);
|
|
1115
|
+
return false;
|
|
1116
|
+
}
|
|
1117
|
+
const changed = await projectWrites().replaceSource(history, {
|
|
1118
|
+
file: documentSource.path,
|
|
1119
|
+
source,
|
|
1120
|
+
label: label ?? documentSource.label ?? `Edit ${displayName}`,
|
|
1121
|
+
});
|
|
1122
|
+
setError(null);
|
|
1123
|
+
return changed;
|
|
1124
|
+
}
|
|
1125
|
+
if (!persistence) {
|
|
1126
|
+
throw new Error('This Asset Lab document has no persistence binding.');
|
|
1127
|
+
}
|
|
1128
|
+
if (!activePersistenceSession || !document) {
|
|
1129
|
+
throw new Error('Project history is unavailable; the edit was not saved.');
|
|
1130
|
+
}
|
|
1131
|
+
// The pipe's ack for THIS commit: `persisted` is whether a byte moved
|
|
1132
|
+
// (a serializer that reproduced the bytes already on disk is a real
|
|
1133
|
+
// no-change outcome; a genuine failure throws below).
|
|
1134
|
+
const { persisted } = await (await activePersistenceSession).commit(document, label);
|
|
1135
|
+
setError(null);
|
|
1136
|
+
return persisted;
|
|
1137
|
+
} catch (caught) {
|
|
1138
|
+
setError(caught instanceof Error ? caught.message : JSON.stringify(caught));
|
|
1139
|
+
throw caught;
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
defaultAdapter = new SourceObject3DAuthoringAdapter(store, scene, {
|
|
1143
|
+
documentId,
|
|
1144
|
+
title: displayName,
|
|
1145
|
+
sourcePath,
|
|
1146
|
+
documentKind: assetSubject ? 'asset' : 'source',
|
|
1147
|
+
audit,
|
|
1148
|
+
...(source.hierarchyRoots ? { hierarchyRoots: source.hierarchyRoots } : {}),
|
|
1149
|
+
...(modelSourceKind === 'project-file' && modelSourcePath
|
|
1150
|
+
? { modelSource: { kind: modelSourceKind, path: modelSourcePath } as const }
|
|
1151
|
+
: modelSourceKind === 'entity' && modelSourceEntityId
|
|
1152
|
+
? { modelSource: { kind: modelSourceKind, entityId: modelSourceEntityId } as const }
|
|
1153
|
+
: {}),
|
|
1154
|
+
});
|
|
1155
|
+
const authoringContext = {
|
|
1156
|
+
documentId,
|
|
1157
|
+
sourcePath,
|
|
1158
|
+
root: source.root,
|
|
1159
|
+
animations: source.root.animations,
|
|
1160
|
+
scene,
|
|
1161
|
+
defaultAdapter,
|
|
1162
|
+
commit: commitProjectDocument,
|
|
1163
|
+
};
|
|
1164
|
+
projectAuthoring = authoring?.(authoringContext) ?? null;
|
|
1165
|
+
// A document's own authoring is built over the default adapter (it is in the context), and
|
|
1166
|
+
// publishes selection in its ids; a source authoring replaces it outright.
|
|
1167
|
+
const presentationAdapter = projectAuthoring ? defaultAdapter : null;
|
|
1168
|
+
if (!projectAuthoring && sourceAuthoring) {
|
|
1169
|
+
projectAuthoring = sourceAuthoring({
|
|
1170
|
+
store,
|
|
1171
|
+
scene,
|
|
1172
|
+
...(source.hierarchyRoots ? { hierarchyRoots: source.hierarchyRoots } : {}),
|
|
1173
|
+
});
|
|
1174
|
+
defaultAdapter.dispose();
|
|
1175
|
+
}
|
|
1176
|
+
// Project authoring may establish the module-local artifact that its
|
|
1177
|
+
// serializers read (a rig document is the concrete example). Open the
|
|
1178
|
+
// persistence baseline only after that factory has adopted the root;
|
|
1179
|
+
// opening it before authoring made every clean rig mount reject with
|
|
1180
|
+
// "Object3D is not a project rig artifact."
|
|
1181
|
+
activePersistenceSession =
|
|
1182
|
+
persistence && history
|
|
1183
|
+
? projectWrites().openPersistence({
|
|
1184
|
+
binding: persistence,
|
|
1185
|
+
document: documentState,
|
|
1186
|
+
history,
|
|
1187
|
+
})
|
|
1188
|
+
: null;
|
|
1189
|
+
if (activePersistenceSession) {
|
|
1190
|
+
void activePersistenceSession.catch((caught) => {
|
|
1191
|
+
if (!disposed && documentHostRef.current?.content === binding) {
|
|
1192
|
+
setError(caught instanceof Error ? caught.message : JSON.stringify(caught));
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
const adapter: AuthoringAdapter = projectAuthoring?.adapter ?? defaultAdapter;
|
|
1197
|
+
|
|
1198
|
+
// All editor furniture belongs to the persistent scene. Adapters and
|
|
1199
|
+
// project extensions see only this revision's isolated content scene.
|
|
1200
|
+
if (!host.dressing) {
|
|
1201
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
1202
|
+
// The stage's VIEW TRANSFORM. ACES unless the document states its
|
|
1203
|
+
// own — see `ToolViewportDressing.toneMapping`. It is set here, with
|
|
1204
|
+
// the rest of the renderer's fixed state, because it belongs to the
|
|
1205
|
+
// surface rather than to any one material: three compiles the
|
|
1206
|
+
// operator into every `toneMapped` program.
|
|
1207
|
+
renderer.toneMapping = dressingToneMapping ?? THREE.ACESFilmicToneMapping;
|
|
1208
|
+
renderer.shadowMap.enabled = true;
|
|
1209
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
1210
|
+
renderer.info.autoReset = false;
|
|
1211
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
1212
|
+
if (studioStage) renderer.setClearColor(0x000000, 0);
|
|
1213
|
+
const environment =
|
|
1214
|
+
dressingEnvironment === false
|
|
1215
|
+
? null
|
|
1216
|
+
: lease && 'environment' in lease
|
|
1217
|
+
? lease.environment()
|
|
1218
|
+
: createStandardEnvironment(renderer);
|
|
1219
|
+
// A palette that carries a viewport background (Blender's flat grey,
|
|
1220
|
+
// Plotter's paper) paints it in place of the dressing's gradient. The
|
|
1221
|
+
// gradient is made either way, so a switch to a palette that names
|
|
1222
|
+
// none has a backdrop to return to (`watchPaletteBackdrop` below).
|
|
1223
|
+
const look = nativeViewportLook(canvas);
|
|
1224
|
+
host.dressing = applyStandardViewportDressing(host.scene, {
|
|
1225
|
+
environment,
|
|
1226
|
+
background:
|
|
1227
|
+
!studioStage &&
|
|
1228
|
+
background === undefined &&
|
|
1229
|
+
dressingBackground !== false,
|
|
1230
|
+
// The stage's lights are the presentation rig's, below.
|
|
1231
|
+
keyLight: false,
|
|
1232
|
+
grid: dressingGrid === true,
|
|
1233
|
+
content: source.root,
|
|
1234
|
+
});
|
|
1235
|
+
if (!studioStage && background !== undefined)
|
|
1236
|
+
host.scene.background = new THREE.Color(background);
|
|
1237
|
+
else if (!studioStage && look.background !== null)
|
|
1238
|
+
host.scene.background = new THREE.Color(look.background);
|
|
1239
|
+
host.defaultEnvironment = host.scene.environment;
|
|
1240
|
+
host.defaultBackground = host.scene.background;
|
|
1241
|
+
// Every document stage has one: a studio stage differs in its BACKDROP (the page shows
|
|
1242
|
+
// through its alpha canvas), not in how it is lit.
|
|
1243
|
+
{
|
|
1244
|
+
const rig = new StagePresentationRig(host.scene, invalidateStages);
|
|
1245
|
+
host.presentationRig = rig;
|
|
1246
|
+
bindViewPresentation(documentId, viewStageKind);
|
|
1247
|
+
const applyPresentation = () => {
|
|
1248
|
+
const presentation = viewPresentation(documentId);
|
|
1249
|
+
rig.apply(presentation, renderer, dressingToneMapping);
|
|
1250
|
+
// The view's overlays: its selection marks and its grid's major step. The native
|
|
1251
|
+
// outline is also the stage's own switch (a shared preview draws none).
|
|
1252
|
+
host.viewport?.setStageFunction(presentation.world, presentation.interaction);
|
|
1253
|
+
host.viewport?.setFieldOfView(presentation.camera.fov);
|
|
1254
|
+
host.viewport?.setSelectionMarks(presentation.overlays.selection);
|
|
1255
|
+
host.viewport?.setGridMajorEvery(presentation.overlays.grid.majorEvery);
|
|
1256
|
+
host.viewport?.setAxisLines(presentation.overlays.axes);
|
|
1257
|
+
host.viewport?.setNavigation(presentation.overlays.navigation);
|
|
1258
|
+
host.viewport?.setGridVisible(presentation.overlays.grid.visible);
|
|
1259
|
+
if (host.session) {
|
|
1260
|
+
// An X-ray with no surface draws no outline: the selection is its wires' colour.
|
|
1261
|
+
const surfaceless =
|
|
1262
|
+
host.session.presentation().mode === 'wireframe' &&
|
|
1263
|
+
presentation.xray.enabled &&
|
|
1264
|
+
presentation.xray.alpha <= 0;
|
|
1265
|
+
host.session.selectionOutlineEnabled =
|
|
1266
|
+
!shared && selectionOutlineRef.current && presentation.overlays.selection.outline && !surfaceless;
|
|
1267
|
+
host.session.selectionOriginsEnabled = !shared && presentation.overlays.selection.origins;
|
|
1268
|
+
host.session.setXray(presentation.xray);
|
|
1269
|
+
}
|
|
1270
|
+
invalidateStages();
|
|
1271
|
+
};
|
|
1272
|
+
host.applyPresentation = applyPresentation;
|
|
1273
|
+
applyPresentation();
|
|
1274
|
+
host.cleanups.push(subscribeViewportPresentation(applyPresentation));
|
|
1275
|
+
// A view may name an environment image its integration registers later.
|
|
1276
|
+
host.cleanups.push(
|
|
1277
|
+
subscribeEnvironmentImages(() => {
|
|
1278
|
+
rig.forgetFailedImages();
|
|
1279
|
+
applyPresentation();
|
|
1280
|
+
}),
|
|
1281
|
+
);
|
|
1282
|
+
host.cleanups.push(() => {
|
|
1283
|
+
rig.dispose();
|
|
1284
|
+
if (host.presentationRig === rig) host.presentationRig = null;
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
host.cleanups.push(() => {
|
|
1288
|
+
if (
|
|
1289
|
+
host.defaultBackground instanceof THREE.Texture &&
|
|
1290
|
+
host.defaultBackground !== host.dressing?.backgroundTexture
|
|
1291
|
+
)
|
|
1292
|
+
host.defaultBackground.dispose();
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
const pick = (clientX: number, clientY: number): string | null => {
|
|
1296
|
+
const current = host.adapter;
|
|
1297
|
+
const viewport = host.viewport;
|
|
1298
|
+
if (!current || !viewport) return null;
|
|
1299
|
+
const rect = canvas.getBoundingClientRect();
|
|
1300
|
+
const pointer = new THREE.Vector2(
|
|
1301
|
+
((clientX - rect.left) / Math.max(rect.width, 1)) * 2 - 1,
|
|
1302
|
+
-((clientY - rect.top) / Math.max(rect.height, 1)) * 2 + 1,
|
|
1303
|
+
);
|
|
1304
|
+
const raycaster = new THREE.Raycaster();
|
|
1305
|
+
raycaster.layers.enableAll();
|
|
1306
|
+
// A drawn line is hit within a few pixels of it (`LineSegments2`'s own test).
|
|
1307
|
+
(raycaster.params as { Line2?: { threshold: number } }).Line2 = { threshold: 4 };
|
|
1308
|
+
raycaster.setFromCamera(pointer, host.session?.camera() ?? viewport.camera);
|
|
1309
|
+
// In the index's space: the presentation adapter's ids first, as the bind writes them.
|
|
1310
|
+
const presentation = host.presentationAdapter;
|
|
1311
|
+
const idOf = (start: THREE.Object3D | null): string | null => {
|
|
1312
|
+
for (let object = start; object; object = object.parent) {
|
|
1313
|
+
const id =
|
|
1314
|
+
presentation?.hierarchy.idForObject3D?.(object) ?? current.hierarchy.idForObject3D?.(object);
|
|
1315
|
+
if (id) return id;
|
|
1316
|
+
}
|
|
1317
|
+
return null;
|
|
1318
|
+
};
|
|
1319
|
+
let nearest: { distance: number; id: string } | null = null;
|
|
1320
|
+
for (const hit of raycaster.intersectObjects([...store.objectMap.values()], true)) {
|
|
1321
|
+
if (isInEditorOwnedSubtree(hit.object)) continue;
|
|
1322
|
+
const id = idOf(hit.object);
|
|
1323
|
+
if (id) {
|
|
1324
|
+
nearest = { distance: hit.distance, id };
|
|
1325
|
+
break;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
// AN OBJECT DRAWN BY A HELPER is picked through it: a document that draws an object's
|
|
1329
|
+
// overlay itself (a Blender camera's wire, a light's icon) marks each part with the
|
|
1330
|
+
// object it stands for (`userData.vgaiPicksAs`), and the nearer hit wins, as Blender's
|
|
1331
|
+
// pick over its whole drawing does.
|
|
1332
|
+
// Only helpers that say they can be picked (`userData.vgaiPickable`) are asked, and a
|
|
1333
|
+
// part is hit only where it is shown (three's raycast reads layers, not `visible`).
|
|
1334
|
+
const pickable = viewport.visibleHelpers().filter((helper) => helper.userData['vgaiPickable'] === true);
|
|
1335
|
+
for (const hit of raycaster.intersectObjects(pickable, true)) {
|
|
1336
|
+
let proxy: THREE.Object3D | null = null;
|
|
1337
|
+
let shown = true;
|
|
1338
|
+
for (let object: THREE.Object3D | null = hit.object; object; object = object.parent) {
|
|
1339
|
+
if (!object.visible) shown = false;
|
|
1340
|
+
proxy ??= (object.userData['vgaiPicksAs'] as THREE.Object3D | undefined) ?? null;
|
|
1341
|
+
}
|
|
1342
|
+
if (!shown) continue;
|
|
1343
|
+
const id = proxy ? idOf(proxy) : null;
|
|
1344
|
+
if (!id) continue;
|
|
1345
|
+
if (!nearest || hit.distance < nearest.distance) nearest = { distance: hit.distance, id };
|
|
1346
|
+
break;
|
|
1347
|
+
}
|
|
1348
|
+
return nearest?.id ?? null;
|
|
1349
|
+
};
|
|
1350
|
+
if (!host.viewport) {
|
|
1351
|
+
host.viewport = new EditorViewport(canvas, host.scene, store, container, {
|
|
1352
|
+
renderer,
|
|
1353
|
+
authoring: () => host.adapter ?? adapter,
|
|
1354
|
+
pick,
|
|
1355
|
+
// Asked only once the viewport stands (a box select), so `host.viewport` is set.
|
|
1356
|
+
drawCamera: (): THREE.Camera => host.session?.camera() ?? host.viewport!.camera,
|
|
1357
|
+
publishPickContext: false,
|
|
1358
|
+
onProjectionChange: setProjection,
|
|
1359
|
+
// The same expression that places the DOM furniture, for the one
|
|
1360
|
+
// piece of furniture that is drawn on the canvas instead.
|
|
1361
|
+
chromeInsetPx: chromeless || hasShell ? 0 : STAGE_BLEED_PX,
|
|
1362
|
+
// A document that turns the dressing's key light off has said it
|
|
1363
|
+
// lights itself, and the editor's design-time rig answers exactly
|
|
1364
|
+
// the same question. MEASURED on the Model stage before this
|
|
1365
|
+
// existed: the rig's ambient 0.5 + directional 1.0 sit on
|
|
1366
|
+
// `EDITOR_LAYER`, but three filters lights by the CAMERA's layers
|
|
1367
|
+
// (`WebGLRenderer.projectObject`) and the one camera that draws
|
|
1368
|
+
// this stage enables that layer to see the grid and the gizmo —
|
|
1369
|
+
// so the layer scopes them away from nothing at all and they lit
|
|
1370
|
+
// the content.
|
|
1371
|
+
lightRig: false,
|
|
1372
|
+
});
|
|
1373
|
+
if (dressingGrid === true) host.viewport.grid.removeFromParent();
|
|
1374
|
+
const open = (event: MouseEvent) => {
|
|
1375
|
+
const id = pick(event.clientX, event.clientY);
|
|
1376
|
+
const object = id && host.adapter ? threeObject(host.adapter.hierarchy, id) : null;
|
|
1377
|
+
if (object) onOpenNodeRef.current?.(object);
|
|
1378
|
+
};
|
|
1379
|
+
container.addEventListener('dblclick', open);
|
|
1380
|
+
host.cleanups.push(() => container.removeEventListener('dblclick', open));
|
|
1381
|
+
}
|
|
1382
|
+
const viewport = host.viewport;
|
|
1383
|
+
if (dressingViewLocked) {
|
|
1384
|
+
// VIEW-LOCKED DRESSING. The object turns with the view because it
|
|
1385
|
+
// hangs off the camera, and the camera joins the RENDERED scene for
|
|
1386
|
+
// the same reason: three collects lights while it walks the scene
|
|
1387
|
+
// (`WebGLRenderer.projectObject`), so a light parented to a camera
|
|
1388
|
+
// that is not in the scene is never collected at all. A camera in
|
|
1389
|
+
// the graph draws nothing and picks nothing — it carries no
|
|
1390
|
+
// geometry — and the scene's own `updateMatrixWorld` is then what
|
|
1391
|
+
// carries the view pose down to its children each frame.
|
|
1392
|
+
//
|
|
1393
|
+
// ONE camera is the view pose for this stage: the session's
|
|
1394
|
+
// orthographic camera copies `viewport.camera`'s position and
|
|
1395
|
+
// quaternion before every draw it makes (`object3d-document-
|
|
1396
|
+
// session.ts::syncOrthographicCamera`), so locking to the
|
|
1397
|
+
// perspective camera locks to both projections. A capture with its
|
|
1398
|
+
// OWN camera (a Blender render photographs from the scene's camera)
|
|
1399
|
+
// is deliberately not followed: a render is lit by the scene.
|
|
1400
|
+
const camera = viewport.camera;
|
|
1401
|
+
host.scene.add(camera);
|
|
1402
|
+
// Under a group of the stage's own, which the view's presentation shows only while it
|
|
1403
|
+
// lights by the document's studio; the document keeps its say over what is inside.
|
|
1404
|
+
const viewLockedHolder = new THREE.Group();
|
|
1405
|
+
viewLockedHolder.name = 'vgai:document-studio';
|
|
1406
|
+
viewLockedHolder.add(dressingViewLocked);
|
|
1407
|
+
camera.add(viewLockedHolder);
|
|
1408
|
+
host.documentStudio = viewLockedHolder;
|
|
1409
|
+
host.cleanups.push(() => {
|
|
1410
|
+
dressingViewLocked.removeFromParent();
|
|
1411
|
+
viewLockedHolder.removeFromParent();
|
|
1412
|
+
if (host.documentStudio === viewLockedHolder) host.documentStudio = null;
|
|
1413
|
+
camera.removeFromParent();
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
if (interaction) {
|
|
1417
|
+
const overlay = new THREE.Group();
|
|
1418
|
+
overlay.name = '__object3d_document_overlay';
|
|
1419
|
+
setUserData(overlay, 'editorHelper', true);
|
|
1420
|
+
scene.add(overlay);
|
|
1421
|
+
interactionExtension = interaction.setup({
|
|
1422
|
+
root: source.root,
|
|
1423
|
+
scene,
|
|
1424
|
+
renderer,
|
|
1425
|
+
overlay,
|
|
1426
|
+
camera: () => host.session?.camera() ?? viewport.camera,
|
|
1427
|
+
writable: persistence !== undefined || documentSource !== undefined,
|
|
1428
|
+
});
|
|
1429
|
+
const pointerEvent = (event: PointerEvent) => {
|
|
1430
|
+
const rect = canvas.getBoundingClientRect();
|
|
1431
|
+
const pointer = new THREE.Vector2(
|
|
1432
|
+
((event.clientX - rect.left) / Math.max(rect.width, 1)) * 2 - 1,
|
|
1433
|
+
-((event.clientY - rect.top) / Math.max(rect.height, 1)) * 2 + 1,
|
|
1434
|
+
);
|
|
1435
|
+
const raycaster = new THREE.Raycaster();
|
|
1436
|
+
raycaster.layers.enableAll();
|
|
1437
|
+
raycaster.setFromCamera(pointer, host.session?.camera() ?? viewport.camera);
|
|
1438
|
+
return {
|
|
1439
|
+
pointerId: event.pointerId,
|
|
1440
|
+
button: event.button,
|
|
1441
|
+
buttons: event.buttons,
|
|
1442
|
+
clientX: event.clientX,
|
|
1443
|
+
clientY: event.clientY,
|
|
1444
|
+
altKey: event.altKey,
|
|
1445
|
+
ctrlKey: event.ctrlKey,
|
|
1446
|
+
metaKey: event.metaKey,
|
|
1447
|
+
shiftKey: event.shiftKey,
|
|
1448
|
+
ray: raycaster.ray.clone(),
|
|
1449
|
+
hits: raycaster.intersectObjects([source!.root, overlay], true),
|
|
1450
|
+
};
|
|
1451
|
+
};
|
|
1452
|
+
gestureController = new Object3DGestureController({
|
|
1453
|
+
begin: (event) => {
|
|
1454
|
+
if (!persistence && !documentSource) {
|
|
1455
|
+
throw new Error(
|
|
1456
|
+
'This Asset Lab document has no persistence binding; project gestures are read-only.',
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
return interactionExtension!.begin(event);
|
|
1460
|
+
},
|
|
1461
|
+
persist: async (label) => {
|
|
1462
|
+
await commitProjectDocument(label);
|
|
1463
|
+
},
|
|
1464
|
+
reportError: (caught) =>
|
|
1465
|
+
setError(caught instanceof Error ? caught.message : JSON.stringify(caught)),
|
|
1466
|
+
});
|
|
1467
|
+
const claim = (event: PointerEvent) => {
|
|
1468
|
+
event.preventDefault();
|
|
1469
|
+
event.stopPropagation();
|
|
1470
|
+
};
|
|
1471
|
+
pointerDownListener = (event) => {
|
|
1472
|
+
// A control painted OVER the stage owns its own press. These
|
|
1473
|
+
// listeners sit on the CONTAINER in the capture phase, so they run
|
|
1474
|
+
// before that control's own handler and a `stopPropagation` there
|
|
1475
|
+
// would be too late: pressing the rolled-back-operation notice's
|
|
1476
|
+
// Dismiss also started a box-select on the mesh (measured — the
|
|
1477
|
+
// notice's text changed from the failed op to "Box select").
|
|
1478
|
+
// The viewport's navigation cluster (`ViewportFurniture`) is the
|
|
1479
|
+
// same case and was missing here: its Pan button drives the camera
|
|
1480
|
+
// from `onPointerDown`, which React dispatches at the root, so
|
|
1481
|
+
// claiming the press here meant `startPan` never ran and the
|
|
1482
|
+
// control was inert (measured — the camera floats were identical
|
|
1483
|
+
// before and after a full pointer gesture, while the cluster's
|
|
1484
|
+
// `onClick` buttons beside it moved the camera every time).
|
|
1485
|
+
if (
|
|
1486
|
+
event.target instanceof Element &&
|
|
1487
|
+
event.target.closest('[role="alert"], [role="toolbar"]') !== null
|
|
1488
|
+
) {
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (!gestureController?.begin(pointerEvent(event))) return;
|
|
1492
|
+
claim(event);
|
|
1493
|
+
// DOM-only editor automation dispatches honest untrusted pointer
|
|
1494
|
+
// events, which have no browser-owned active pointer and therefore
|
|
1495
|
+
// cannot be captured. Hardware pointers still take capture so a
|
|
1496
|
+
// gesture can leave the viewport without getting stranded.
|
|
1497
|
+
if (event.isTrusted) canvas.setPointerCapture?.(event.pointerId);
|
|
1498
|
+
};
|
|
1499
|
+
pointerMoveListener = (event) => {
|
|
1500
|
+
if (!gestureController?.hasActiveGesture()) return;
|
|
1501
|
+
claim(event);
|
|
1502
|
+
gestureController.update(pointerEvent(event));
|
|
1503
|
+
};
|
|
1504
|
+
pointerUpListener = (event) => {
|
|
1505
|
+
if (!gestureController?.hasActiveGesture()) return;
|
|
1506
|
+
claim(event);
|
|
1507
|
+
if (canvas.hasPointerCapture?.(event.pointerId)) {
|
|
1508
|
+
canvas.releasePointerCapture?.(event.pointerId);
|
|
1509
|
+
}
|
|
1510
|
+
void gestureController.commit(pointerEvent(event));
|
|
1511
|
+
};
|
|
1512
|
+
pointerCancelListener = (event) => {
|
|
1513
|
+
if (!gestureController?.hasActiveGesture()) return;
|
|
1514
|
+
claim(event);
|
|
1515
|
+
void gestureController.cancel(event.pointerId);
|
|
1516
|
+
};
|
|
1517
|
+
escapeListener = (event) => {
|
|
1518
|
+
if (event.key !== 'Escape' || !gestureController?.hasActiveGesture()) return;
|
|
1519
|
+
event.preventDefault();
|
|
1520
|
+
void gestureController.cancel();
|
|
1521
|
+
};
|
|
1522
|
+
activateInteraction = () => {
|
|
1523
|
+
container.addEventListener('pointerdown', pointerDownListener!, true);
|
|
1524
|
+
container.addEventListener('pointermove', pointerMoveListener!, true);
|
|
1525
|
+
container.addEventListener('pointerup', pointerUpListener!, true);
|
|
1526
|
+
container.addEventListener('pointercancel', pointerCancelListener!, true);
|
|
1527
|
+
window.addEventListener('keydown', escapeListener!);
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
// Candidate construction has succeeded. Publish all source references in
|
|
1531
|
+
// one synchronous turn; no frame or input event can see a half-swap.
|
|
1532
|
+
const previous = host.content;
|
|
1533
|
+
const selected = [...store.shell.selectedEntityIds];
|
|
1534
|
+
const previousAdapter = host.adapter;
|
|
1535
|
+
const previousPresentationAdapter = host.presentationAdapter;
|
|
1536
|
+
const previousObjects = new Map(store.objectMap);
|
|
1537
|
+
const previousRoot = host.session?.root;
|
|
1538
|
+
const previousFrame = host.frame;
|
|
1539
|
+
const previousSyncHostScene = host.syncHostScene;
|
|
1540
|
+
const previousDocument = documentStateRef.current;
|
|
1541
|
+
const previousPersistence = persistenceSessionRef.current;
|
|
1542
|
+
const previousInteraction = interactionExtensionRef.current;
|
|
1543
|
+
const previousBackground = host.session?.neutralBackgroundTexture();
|
|
1544
|
+
const previousLights = host.dressing.lights.map((light) => light.visible);
|
|
1545
|
+
const cleanupCount = host.cleanups.length;
|
|
1546
|
+
const wasInitialized = host.initialized;
|
|
1547
|
+
rollback = () => {
|
|
1548
|
+
scene.removeFromParent();
|
|
1549
|
+
if (sourceParent) sourceParent.add(source.root);
|
|
1550
|
+
if (previous) host.scene.add(previous.scene);
|
|
1551
|
+
host.adapter = previousAdapter;
|
|
1552
|
+
host.presentationAdapter = previousPresentationAdapter;
|
|
1553
|
+
host.content = previous;
|
|
1554
|
+
host.frame = previousFrame;
|
|
1555
|
+
host.syncHostScene = previousSyncHostScene;
|
|
1556
|
+
host.initialized = wasInitialized;
|
|
1557
|
+
for (const cleanup of host.cleanups.splice(cleanupCount)) cleanup();
|
|
1558
|
+
if (!previousRoot && host.session) {
|
|
1559
|
+
host.session.dispose();
|
|
1560
|
+
host.session = null;
|
|
1561
|
+
}
|
|
1562
|
+
store.objectMap.clear();
|
|
1563
|
+
for (const [id, object] of previousObjects) store.objectMap.set(id, object);
|
|
1564
|
+
if (previous)
|
|
1565
|
+
store.bindScene(previous.scene, renderer, viewport.batchedRenderer, viewport.camera);
|
|
1566
|
+
if (previousRoot && previousAdapter)
|
|
1567
|
+
host.session?.replaceContent(previousRoot, previousAdapter);
|
|
1568
|
+
documentStateRef.current = previousDocument;
|
|
1569
|
+
persistenceSessionRef.current = previousPersistence;
|
|
1570
|
+
interactionExtensionRef.current = previousInteraction;
|
|
1571
|
+
documentAdapterRef.current = previousAdapter;
|
|
1572
|
+
setDocumentAdapter(previousAdapter);
|
|
1573
|
+
if (previousBackground !== undefined)
|
|
1574
|
+
host.session?.setNeutralBackground(previousBackground);
|
|
1575
|
+
host.dressing?.lights.forEach((light, index) => {
|
|
1576
|
+
light.visible = previousLights[index] ?? true;
|
|
1577
|
+
});
|
|
1578
|
+
if (previousRoot) {
|
|
1579
|
+
host.dressing?.frameContent(previousRoot);
|
|
1580
|
+
host.presentationRig?.placeFloor(previousRoot);
|
|
1581
|
+
}
|
|
1582
|
+
store.shell.selectMultiple(selected);
|
|
1583
|
+
store.notifyIngestObjectMapEdit();
|
|
1584
|
+
};
|
|
1585
|
+
previous?.scene.removeFromParent();
|
|
1586
|
+
host.scene.add(scene);
|
|
1587
|
+
host.adapter = adapter;
|
|
1588
|
+
host.presentationAdapter = presentationAdapter;
|
|
1589
|
+
host.content = binding;
|
|
1590
|
+
store.bindScene(scene, renderer, viewport.batchedRenderer, viewport.camera);
|
|
1591
|
+
store.setOrbitTarget(viewport.orbitControls.target);
|
|
1592
|
+
store.objectMap.clear();
|
|
1593
|
+
scene.traverse((object) => {
|
|
1594
|
+
// ONE SPACE FOR THE INDEX: a project authoring built over the default adapter publishes
|
|
1595
|
+
// selection in that adapter's ids, and the default adapter re-indexes the map in them on
|
|
1596
|
+
// every structural change (`SourceObject3DAuthoringAdapter`), so the bind writes them
|
|
1597
|
+
// too. A Blender document names its objects only once the engine's outliner rows
|
|
1598
|
+
// arrive, often after this bind; the presentation ids exist from the first frame.
|
|
1599
|
+
const id =
|
|
1600
|
+
presentationAdapter?.hierarchy.idForObject3D?.(object) ?? adapter.hierarchy.idForObject3D?.(object);
|
|
1601
|
+
if (id) store.objectMap.set(id, object);
|
|
1602
|
+
});
|
|
1603
|
+
if (!host.session) {
|
|
1604
|
+
const overviewFrame = boxFromFrameBounds(frameBoundsRef.current);
|
|
1605
|
+
const openingFrame = boxFromFrameBounds(openingFrameBoundsRef.current) ?? overviewFrame;
|
|
1606
|
+
// The view's camera (`ViewportCamera`): its field of view, and the direction it opens
|
|
1607
|
+
// from when the document states none.
|
|
1608
|
+
const viewCamera = viewPresentation(documentId).camera;
|
|
1609
|
+
viewport.setFieldOfView(viewCamera.fov);
|
|
1610
|
+
const stated = openingViewRef.current;
|
|
1611
|
+
const statedDirection = cameraX !== undefined && cameraY !== undefined && cameraZ !== undefined;
|
|
1612
|
+
if (!stated && !statedDirection) {
|
|
1613
|
+
// Along the view's own opening direction, at the distance the camera stands.
|
|
1614
|
+
const target = viewport.orbitControls.target;
|
|
1615
|
+
const distance = viewport.camera.position.distanceTo(target);
|
|
1616
|
+
viewport.camera.position
|
|
1617
|
+
.copy(target)
|
|
1618
|
+
.addScaledVector(new THREE.Vector3(...viewCamera.opening).normalize(), distance);
|
|
1619
|
+
}
|
|
1620
|
+
if (!openingFrame) viewport.focusOn(source.root);
|
|
1621
|
+
if (stated) {
|
|
1622
|
+
const target = new THREE.Vector3(...stated.target);
|
|
1623
|
+
const direction = new THREE.Vector3(...stated.direction).normalize();
|
|
1624
|
+
viewport.camera.position.copy(target).addScaledVector(direction, stated.distance);
|
|
1625
|
+
// The screen's up is the view's own, so a rolled view opens rolled (a turntable
|
|
1626
|
+
// orbit keeps it); without one, the world's.
|
|
1627
|
+
viewport.camera.up.set(0, 1, 0);
|
|
1628
|
+
if (stated.up) viewport.camera.up.set(...stated.up);
|
|
1629
|
+
viewport.orbitControls.target.copy(target);
|
|
1630
|
+
} else if (statedDirection) {
|
|
1631
|
+
const box = openingFrame ?? contentWorldBounds(source.root);
|
|
1632
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
1633
|
+
const direction = new THREE.Vector3(cameraX, cameraY, cameraZ).normalize();
|
|
1634
|
+
const distance =
|
|
1635
|
+
perspectiveDistanceToFitBox(box, viewport.camera, direction) *
|
|
1636
|
+
Math.min(10, Math.max(0.1, openingFit ?? 1));
|
|
1637
|
+
viewport.camera.position.copy(center).add(direction.multiplyScalar(distance));
|
|
1638
|
+
viewport.orbitControls.target.copy(center);
|
|
1639
|
+
}
|
|
1640
|
+
host.session = new Object3DDocumentSession(
|
|
1641
|
+
documentId,
|
|
1642
|
+
source.root,
|
|
1643
|
+
host.scene,
|
|
1644
|
+
renderer,
|
|
1645
|
+
viewport,
|
|
1646
|
+
adapter,
|
|
1647
|
+
studioStage
|
|
1648
|
+
? null
|
|
1649
|
+
: background === undefined
|
|
1650
|
+
? host.dressing.backgroundTexture
|
|
1651
|
+
: new THREE.Color(background),
|
|
1652
|
+
overviewFrame,
|
|
1653
|
+
);
|
|
1654
|
+
host.session.selectionOutlineEnabled = !shared && selectionOutlineRef.current;
|
|
1655
|
+
host.session.setCameraViewSource(cameraViewRef.current ?? null);
|
|
1656
|
+
// The view's presentation, now that the viewport and session exist to take it.
|
|
1657
|
+
host.applyPresentation?.();
|
|
1658
|
+
// A saved view's projection is part of where the file opens.
|
|
1659
|
+
if (openingViewRef.current?.projection === 'orthographic') host.session.setProjection('orthographic');
|
|
1660
|
+
if (!studioStage && background === undefined && host.dressing.backgroundTexture) {
|
|
1661
|
+
const session = host.session;
|
|
1662
|
+
host.cleanups.push(
|
|
1663
|
+
watchPaletteBackdrop(() => {
|
|
1664
|
+
// THE LOOK'S BACKDROP, in both directions and through the
|
|
1665
|
+
// session's neutral backdrop, which repaints what is showing: a
|
|
1666
|
+
// palette's flat viewport background when it names one, the
|
|
1667
|
+
// dressing's gradient re-derived for the palette otherwise.
|
|
1668
|
+
// Painting the flat colour straight onto the scene left it there
|
|
1669
|
+
// when the next palette named none (Plotter's paper stayed under
|
|
1670
|
+
// Classic).
|
|
1671
|
+
const flat = nativeViewportLook(canvas).background;
|
|
1672
|
+
const previous = host.defaultBackground;
|
|
1673
|
+
host.defaultBackground =
|
|
1674
|
+
flat !== null ? new THREE.Color(flat) : createGradientBackgroundTexture();
|
|
1675
|
+
if (session.neutralBackgroundTexture() === previous)
|
|
1676
|
+
session.setNeutralBackground(host.defaultBackground);
|
|
1677
|
+
if (
|
|
1678
|
+
previous instanceof THREE.Texture &&
|
|
1679
|
+
previous !== host.dressing?.backgroundTexture
|
|
1680
|
+
)
|
|
1681
|
+
previous.dispose();
|
|
1682
|
+
}),
|
|
1683
|
+
);
|
|
1684
|
+
}
|
|
1685
|
+
} else host.session.replaceContent(source.root, adapter);
|
|
1686
|
+
const retainedCamera = retainedState.camera;
|
|
1687
|
+
if (retainedCamera) {
|
|
1688
|
+
// Top views and rolled views have their own up direction, which setPose's lookAt
|
|
1689
|
+
// derives the orientation with.
|
|
1690
|
+
viewport.setPose(
|
|
1691
|
+
retainedCamera.position,
|
|
1692
|
+
retainedCamera.target,
|
|
1693
|
+
retainedCamera.fov || undefined,
|
|
1694
|
+
retainedCamera.up,
|
|
1695
|
+
);
|
|
1696
|
+
// After the pose, which draws in perspective: an orthographic view comes back as one.
|
|
1697
|
+
viewport.setProjection(projection);
|
|
1698
|
+
retainedState.camera = null;
|
|
1699
|
+
}
|
|
1700
|
+
const documentSession = host.session;
|
|
1701
|
+
documentStateRef.current = documentState;
|
|
1702
|
+
persistenceSessionRef.current = activePersistenceSession;
|
|
1703
|
+
interactionExtensionRef.current = interactionExtension;
|
|
1704
|
+
documentAdapterRef.current = adapter;
|
|
1705
|
+
setDocumentAdapter(adapter);
|
|
1706
|
+
// THE DOCUMENT'S SAY, as its layer beneath the person's choices. A document that hands
|
|
1707
|
+
// the stage its own view-locked studio (Blender's four Solid-mode lights) is lit by it —
|
|
1708
|
+
// the `document` preset, never giving way to the scene, as Blender's Solid never does. A
|
|
1709
|
+
// document that turned the key off without one has said it lights itself: the `scene`
|
|
1710
|
+
// source. Lights the CONTENT carries are the view's `auto` rule's to weigh, per draw.
|
|
1711
|
+
// A stage whose builder already stated its lighting (Blender's, per shading mode) has said
|
|
1712
|
+
// it; the document's layer would override every mode's with one.
|
|
1713
|
+
const startingLights =
|
|
1714
|
+
startingPresentation(viewStageKind)?.all?.lighting !== undefined || stageLightsPerMode(viewStageKind);
|
|
1715
|
+
const dressingLayer: PresentationLayer | null = startingLights
|
|
1716
|
+
? null
|
|
1717
|
+
: dressingViewLocked
|
|
1718
|
+
? { all: { lighting: { source: 'studio', studioPreset: DOCUMENT_STUDIO_PRESET.id, auto: null } } }
|
|
1719
|
+
: dressingKeyLight === false
|
|
1720
|
+
? { all: { lighting: { source: 'scene' } } }
|
|
1721
|
+
: null;
|
|
1722
|
+
// What the document's file says about how it is seen (`ToolObject3DDocument.presentation`)
|
|
1723
|
+
// over what its dressing implies.
|
|
1724
|
+
const ownLayer = documentPresentationRef.current ?? null;
|
|
1725
|
+
bindViewPresentation(
|
|
1726
|
+
documentId,
|
|
1727
|
+
viewStageKind,
|
|
1728
|
+
dressingLayer && ownLayer ? mergeLayers(dressingLayer, ownLayer) : (ownLayer ?? dressingLayer),
|
|
1729
|
+
);
|
|
1730
|
+
// THE DRAW MODE IS ONE FACT IN TWO PLACES, kept equal: the session draws it, and the view's
|
|
1731
|
+
// presentation resolves its per-mode lighting by it and persists it. A shading cell changes
|
|
1732
|
+
// the session and the view follows; a named view or a restored view changes the view and
|
|
1733
|
+
// the session follows. Modes the view does
|
|
1734
|
+
// not carry (UV, vertex colours) change only the session.
|
|
1735
|
+
const viewModes = new Set<string>(VIEW_DRAW_MODES);
|
|
1736
|
+
let drawnMode: string | undefined;
|
|
1737
|
+
const sessionToView = (): void => {
|
|
1738
|
+
const mode = host.session?.presentation().mode;
|
|
1739
|
+
// What the presentation applies can depend on the mode drawn, the view's or not (an
|
|
1740
|
+
// X-ray's outline): a change of it applies the presentation again.
|
|
1741
|
+
if (mode !== drawnMode) {
|
|
1742
|
+
drawnMode = mode;
|
|
1743
|
+
host.applyPresentation?.();
|
|
1744
|
+
}
|
|
1745
|
+
if (mode === undefined || !viewModes.has(mode)) return;
|
|
1746
|
+
if (viewPresentation(documentId).drawMode !== mode)
|
|
1747
|
+
setViewPresentation(documentId, { drawMode: mode as ViewportDrawMode });
|
|
1748
|
+
};
|
|
1749
|
+
// A draw mode the person chose and the view restored is theirs: the session takes it. Only
|
|
1750
|
+
// a view with no such choice is given the session's.
|
|
1751
|
+
const restoredMode = viewPresentationSnapshot(documentId).drawMode;
|
|
1752
|
+
if (restoredMode !== undefined && viewModes.has(restoredMode) && host.session)
|
|
1753
|
+
host.session.setMode(restoredMode);
|
|
1754
|
+
else sessionToView();
|
|
1755
|
+
const stopSessionMode = host.session?.subscribe(sessionToView);
|
|
1756
|
+
const stopViewMode = subscribeViewportPresentation(() => {
|
|
1757
|
+
const session = host.session;
|
|
1758
|
+
const mode = viewPresentation(documentId).drawMode;
|
|
1759
|
+
if (session && session.presentation().mode !== mode && viewModes.has(session.presentation().mode))
|
|
1760
|
+
session.setMode(mode);
|
|
1761
|
+
});
|
|
1762
|
+
host.cleanups.push(() => {
|
|
1763
|
+
stopSessionMode?.();
|
|
1764
|
+
stopViewMode();
|
|
1765
|
+
});
|
|
1766
|
+
const contentLights: THREE.Light[] = [];
|
|
1767
|
+
source.root.traverse((object) => {
|
|
1768
|
+
if ((object as THREE.Light).isLight) contentLights.push(object as THREE.Light);
|
|
1769
|
+
});
|
|
1770
|
+
const contentHas = {
|
|
1771
|
+
light: contentLights.length > 0,
|
|
1772
|
+
'directional-light': contentLights.some((light) => (light as THREE.DirectionalLight).isDirectionalLight),
|
|
1773
|
+
};
|
|
1774
|
+
host.contentLights = contentLights;
|
|
1775
|
+
host.contentHas = contentHas;
|
|
1776
|
+
host.dressing.frameContent(source.root);
|
|
1777
|
+
host.presentationRig?.placeFloor(source.root);
|
|
1778
|
+
// An edit can move the content's lowest point; the floor follows it.
|
|
1779
|
+
host.cleanups.push(store.shell.subscribe(() => host.presentationRig?.placeFloor(source.root)));
|
|
1780
|
+
// A selected id survives the swap when the new adapter still answers for it, not only when
|
|
1781
|
+
// it keys the object map: an adapter can answer in two id spaces (Blender's Outliner keys
|
|
1782
|
+
// its rows and resolves the presentation's ids too), and the map holds only its own.
|
|
1783
|
+
// One id per object: a selection that gathered an object under both of its ids keeps one.
|
|
1784
|
+
const kept = new Set<THREE.Object3D | string>();
|
|
1785
|
+
store.shell.selectMultiple(
|
|
1786
|
+
selected.filter((id) => {
|
|
1787
|
+
const object = store.objectMap.get(id) ?? threeObject(adapter.hierarchy, id);
|
|
1788
|
+
if (object === null && !store.objectMap.has(id)) return false;
|
|
1789
|
+
const key = object ?? id;
|
|
1790
|
+
if (kept.has(key)) return false;
|
|
1791
|
+
kept.add(key);
|
|
1792
|
+
return true;
|
|
1793
|
+
}),
|
|
1794
|
+
);
|
|
1795
|
+
store.notifyIngestObjectMapEdit();
|
|
1796
|
+
documentSession.syncSelectionPresentation();
|
|
1797
|
+
activateInteraction?.();
|
|
1798
|
+
setError(null);
|
|
1799
|
+
if (!chromeless) notifyWorkspaceDocumentSelectionChanged(documentId);
|
|
1800
|
+
let nativeBackground = scene.background;
|
|
1801
|
+
documentSession.setNeutralBackground(nativeBackground ?? host.defaultBackground);
|
|
1802
|
+
const retainedPresentation = retainedState.presentation;
|
|
1803
|
+
if (retainedPresentation) {
|
|
1804
|
+
documentSession.setMode(retainedPresentation.mode);
|
|
1805
|
+
documentSession.setBackground(retainedPresentation.background);
|
|
1806
|
+
documentSession.setProjection(retainedPresentation.projection);
|
|
1807
|
+
documentSession.setSkeleton(retainedPresentation.skeleton);
|
|
1808
|
+
documentSession.setBounds(retainedPresentation.bounds);
|
|
1809
|
+
retainedState.presentation = null;
|
|
1810
|
+
}
|
|
1811
|
+
// The document's world dressing lives on its OWN scene, nested inside the
|
|
1812
|
+
// rendered `host.scene`. Every draw of the rendered scene mirrors it out
|
|
1813
|
+
// first — the viewport frame and every offscreen photograph alike — so a
|
|
1814
|
+
// capture can never show the dressing the last viewport tick happened to
|
|
1815
|
+
// leave behind. The session runs this before it renders; nothing else may
|
|
1816
|
+
// hold a second copy of this field list.
|
|
1817
|
+
const syncHostScene = () => {
|
|
1818
|
+
// A document draws through its session's camera, which the viewport's own frame
|
|
1819
|
+
// does not know is orthographic.
|
|
1820
|
+
host.viewport?.alignGridToView(documentSession.camera(), renderer.domElement.width);
|
|
1821
|
+
host.scene.environment = scene.environment ?? host.defaultEnvironment;
|
|
1822
|
+
host.scene.fog = scene.fog;
|
|
1823
|
+
host.scene.environmentIntensity = scene.environmentIntensity;
|
|
1824
|
+
host.scene.environmentRotation.copy(scene.environmentRotation);
|
|
1825
|
+
host.scene.backgroundIntensity = scene.backgroundIntensity;
|
|
1826
|
+
host.scene.backgroundBlurriness = scene.backgroundBlurriness;
|
|
1827
|
+
host.scene.backgroundRotation.copy(scene.backgroundRotation);
|
|
1828
|
+
// The source this draw lights by. A `studio` view lights by its preset alone: the
|
|
1829
|
+
// stage's own environment at the preset's strength, its camera-locked lights turned
|
|
1830
|
+
// with the camera, and the content's own lights dark for this draw only (restored
|
|
1831
|
+
// after it, so nothing that saves the document ever sees them changed).
|
|
1832
|
+
const rig = host.presentationRig;
|
|
1833
|
+
if (rig) {
|
|
1834
|
+
const drawSource = rig.resolveSource({ ...host.contentHas, environment: scene.environment !== null });
|
|
1835
|
+
const environment = rig.environment();
|
|
1836
|
+
if (environment) {
|
|
1837
|
+
host.scene.environment = environment.texture ?? host.defaultEnvironment;
|
|
1838
|
+
host.scene.environmentIntensity = environment.intensity;
|
|
1839
|
+
host.scene.environmentRotation.set(0, environment.rotation, 0);
|
|
1840
|
+
}
|
|
1841
|
+
// What is drawn behind the scene: the stage's own backdrop (the look's fill, or the
|
|
1842
|
+
// scene's own as mirrored above) unless the view names another.
|
|
1843
|
+
const backdrop = rig.backdrop();
|
|
1844
|
+
if (backdrop !== 'keep') {
|
|
1845
|
+
host.scene.background = backdrop.value;
|
|
1846
|
+
host.scene.backgroundBlurriness = backdrop.blur;
|
|
1847
|
+
host.scene.backgroundIntensity = backdrop.intensity;
|
|
1848
|
+
host.scene.backgroundRotation.set(0, backdrop.rotation, 0);
|
|
1849
|
+
host.backdropOverridden = true;
|
|
1850
|
+
} else if (host.backdropOverridden) {
|
|
1851
|
+
host.scene.background = documentSession.neutralBackgroundTexture();
|
|
1852
|
+
host.scene.backgroundBlurriness = scene.backgroundBlurriness;
|
|
1853
|
+
host.scene.backgroundIntensity = scene.backgroundIntensity;
|
|
1854
|
+
host.backdropOverridden = false;
|
|
1855
|
+
}
|
|
1856
|
+
const documentStudio = drawSource === 'studio' && rig.presetId() === DOCUMENT_STUDIO_PRESET.id;
|
|
1857
|
+
if (host.documentStudio) host.documentStudio.visible = documentStudio;
|
|
1858
|
+
// The document's own studio manages its scene's lights itself (Blender stands them
|
|
1859
|
+
// down for modelling and up for a render).
|
|
1860
|
+
// A studio lights alone; a preview ADDS to the scene's other lights (Godot's preview
|
|
1861
|
+
// sun gives way only to a directional light, which `auto` weighs).
|
|
1862
|
+
// A preview without the scene's lights (Blender's Material Preview) darkens them too.
|
|
1863
|
+
const darkened =
|
|
1864
|
+
(drawSource === 'studio' && !documentStudio) || (drawSource === 'preview' && !rig.sceneLightsShown())
|
|
1865
|
+
? host.darkenContentLights()
|
|
1866
|
+
: 0;
|
|
1867
|
+
rig.update(documentSession.camera());
|
|
1868
|
+
reportViewDraw(documentId, {
|
|
1869
|
+
source: drawSource,
|
|
1870
|
+
presetId: rig.presetId(),
|
|
1871
|
+
presetLights: rig.lightsVisible(),
|
|
1872
|
+
documentStudio: host.documentStudio ? host.documentStudio.visible : null,
|
|
1873
|
+
contentLights: host.contentLights.length,
|
|
1874
|
+
contentLightsDarkened: darkened,
|
|
1875
|
+
environmentIntensity: host.scene.environmentIntensity,
|
|
1876
|
+
environmentImage: rig.imageReport().shown,
|
|
1877
|
+
environmentImagePending: rig.imageReport().pending,
|
|
1878
|
+
toneMapping: String(renderer.toneMapping),
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
if (nativeBackground !== scene.background) {
|
|
1882
|
+
nativeBackground = scene.background;
|
|
1883
|
+
documentSession.setNeutralBackground(nativeBackground ?? host.defaultBackground);
|
|
1884
|
+
}
|
|
1885
|
+
};
|
|
1886
|
+
host.syncHostScene = syncHostScene;
|
|
1887
|
+
documentSession.setBeforeRender(() => host.syncHostScene?.());
|
|
1888
|
+
// This loop draws the compass and every other overlay pass over the
|
|
1889
|
+
// document's own render, so it — and only it — can serve the chrome
|
|
1890
|
+
// door a frame that matches the screen.
|
|
1891
|
+
documentSession.setPresentsFrames(true);
|
|
1892
|
+
// THE CONTENT-TIME DOOR, re-created in Step 3 with exactly one caller
|
|
1893
|
+
// (the transport's `tick` below). The mixer line the deleted estate
|
|
1894
|
+
// had here is gone for good: subjects seek THEMSELVES, so content time
|
|
1895
|
+
// is only ever the bookkeeping plus the source's own update.
|
|
1896
|
+
const advanceContent = (deltaSeconds: number): void => {
|
|
1897
|
+
if (disposed || !(deltaSeconds > 0) || !Number.isFinite(deltaSeconds)) return;
|
|
1898
|
+
host.contentSeconds += deltaSeconds;
|
|
1899
|
+
host.transportAdvances++;
|
|
1900
|
+
source.update?.(deltaSeconds);
|
|
1901
|
+
};
|
|
1902
|
+
// What this stage can show at a time. Scanned once here; the world
|
|
1903
|
+
// root's stage rescans (below) because its tree arrives later.
|
|
1904
|
+
const clipScan = scanClipSubjects(source.root, host.transport);
|
|
1905
|
+
host.cleanups.push(() => clipScan.dispose());
|
|
1906
|
+
const retainedTransport = retainedState.transport;
|
|
1907
|
+
if (retainedTransport) {
|
|
1908
|
+
if (retainedTransport.activeSubject)
|
|
1909
|
+
host.transport.setActiveSubject(retainedTransport.activeSubject);
|
|
1910
|
+
host.transport.setLoop(retainedTransport.range.loop);
|
|
1911
|
+
host.transport.setTimeScale(retainedTransport.timeScale);
|
|
1912
|
+
host.transport.seek(retainedTransport.time);
|
|
1913
|
+
if (retainedTransport.playbackState === 'playing') host.transport.play();
|
|
1914
|
+
else if (retainedTransport.playbackState === 'paused') host.transport.pause();
|
|
1915
|
+
retainedState.transport = null;
|
|
1916
|
+
}
|
|
1917
|
+
let previousTime = performance.now();
|
|
1918
|
+
// DRAWING ON CHANGE. A source that announces its changes
|
|
1919
|
+
// (`ToolObject3DPreviewSource.onChange`) is drawn only when something
|
|
1920
|
+
// changed; one that does not is drawn every frame, as before. The loop
|
|
1921
|
+
// itself still runs every frame -- transport, controls, flights and
|
|
1922
|
+
// presence advance there -- and what is skipped is the render: the
|
|
1923
|
+
// scene, the composer's passes and the compass. MEASURED before: an
|
|
1924
|
+
// idle Model document spent 43% of the main thread drawing the same
|
|
1925
|
+
// picture (4 s at load ~30: 372 frames, 1.7 s in the loop, 0.18 s of
|
|
1926
|
+
// it WebGL calls, the rest three's render and the outline passes).
|
|
1927
|
+
//
|
|
1928
|
+
// What draws a frame: the content's own announcement; the stage
|
|
1929
|
+
// store; pointer, wheel and key input on the stage (hover and gizmo
|
|
1930
|
+
// highlights follow the pointer); the editor acting on a stage from
|
|
1931
|
+
// outside those (`stage-invalidation.ts`); a moved camera or a resized
|
|
1932
|
+
// surface; playback; a flight or a pending photograph
|
|
1933
|
+
// (`needsFrame`); another participant's markers; particle systems;
|
|
1934
|
+
// and an interaction extension that refines every frame.
|
|
1935
|
+
const drawsOnChange = typeof source.onChange === 'function';
|
|
1936
|
+
let dirty = true;
|
|
1937
|
+
const markDirty = (): void => {
|
|
1938
|
+
dirty = true;
|
|
1939
|
+
};
|
|
1940
|
+
if (drawsOnChange) {
|
|
1941
|
+
const stopSource = source.onChange!(markDirty);
|
|
1942
|
+
const stopStore = store.shell.subscribe(markDirty);
|
|
1943
|
+
const inputs = ['pointerdown', 'pointermove', 'pointerup', 'pointerleave', 'wheel', 'keydown', 'keyup'] as const;
|
|
1944
|
+
for (const type of inputs) container.addEventListener(type, markDirty, { capture: true, passive: true });
|
|
1945
|
+
stopDrawSignals = () => {
|
|
1946
|
+
stopSource();
|
|
1947
|
+
stopStore();
|
|
1948
|
+
for (const type of inputs) container.removeEventListener(type, markDirty, { capture: true });
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
let drawnGeneration = -1;
|
|
1952
|
+
const drawnCamera = new THREE.Matrix4();
|
|
1953
|
+
const drawnProjection = new THREE.Matrix4();
|
|
1954
|
+
const drawnSize = new THREE.Vector2();
|
|
1955
|
+
const currentSize = new THREE.Vector2();
|
|
1956
|
+
let drawnBackground: unknown = undefined;
|
|
1957
|
+
let drawnToneMapping: THREE.ToneMapping | null = null;
|
|
1958
|
+
let drawnExposure = Number.NaN;
|
|
1959
|
+
const mustDraw = (advanced: number): boolean => {
|
|
1960
|
+
if (!drawsOnChange || dirty || advanced > 0) return true;
|
|
1961
|
+
if (stageGeneration() !== drawnGeneration) return true;
|
|
1962
|
+
if (documentSession.needsFrame() || host.presence?.live()) return true;
|
|
1963
|
+
if (interactionExtension?.prepareFrame || interactionExtension?.update) return true;
|
|
1964
|
+
if (((viewport.batchedRenderer as unknown as { batches?: readonly unknown[] }).batches?.length ?? 0) > 0)
|
|
1965
|
+
return true;
|
|
1966
|
+
const camera = documentSession.camera();
|
|
1967
|
+
camera.updateMatrixWorld();
|
|
1968
|
+
renderer.getDrawingBufferSize(currentSize);
|
|
1969
|
+
return !camera.matrixWorld.equals(drawnCamera) ||
|
|
1970
|
+
!camera.projectionMatrix.equals(drawnProjection) ||
|
|
1971
|
+
!currentSize.equals(drawnSize) ||
|
|
1972
|
+
host.scene.background !== drawnBackground ||
|
|
1973
|
+
renderer.toneMapping !== drawnToneMapping ||
|
|
1974
|
+
renderer.toneMappingExposure !== drawnExposure;
|
|
1975
|
+
};
|
|
1976
|
+
const noteDrawn = (): void => {
|
|
1977
|
+
dirty = false;
|
|
1978
|
+
drawnGeneration = stageGeneration();
|
|
1979
|
+
const camera = documentSession.camera();
|
|
1980
|
+
drawnCamera.copy(camera.matrixWorld);
|
|
1981
|
+
drawnProjection.copy(camera.projectionMatrix);
|
|
1982
|
+
renderer.getDrawingBufferSize(drawnSize);
|
|
1983
|
+
drawnBackground = host.scene.background;
|
|
1984
|
+
drawnToneMapping = renderer.toneMapping;
|
|
1985
|
+
drawnExposure = renderer.toneMappingExposure;
|
|
1986
|
+
};
|
|
1987
|
+
const animate = (time: number, resumed: boolean) => {
|
|
1988
|
+
if (disposed || !renderer || !viewport) return;
|
|
1989
|
+
const activeRenderer = renderer;
|
|
1990
|
+
const activeViewport = viewport;
|
|
1991
|
+
const profiler = documentSession?.profiler;
|
|
1992
|
+
profiler?.beginFrame();
|
|
1993
|
+
if (resumed) previousTime = time;
|
|
1994
|
+
const delta = Math.min((time - previousTime) / 1000, 0.1);
|
|
1995
|
+
previousTime = time;
|
|
1996
|
+
profiler?.beginPhase();
|
|
1997
|
+
// CONTENT TIME moves ONLY through the stage transport, and only
|
|
1998
|
+
// while a human is holding it: `tick` is inert unless playing and
|
|
1999
|
+
// the editor drives, and it returns exactly the seconds it advanced.
|
|
2000
|
+
const advanced = host.transport.tick(delta);
|
|
2001
|
+
if (advanced > 0) advanceContent(advanced);
|
|
2002
|
+
interactionExtension?.prepareFrame?.(delta);
|
|
2003
|
+
interactionExtension?.update?.(delta);
|
|
2004
|
+
profiler?.endPhase('animation');
|
|
2005
|
+
profiler?.beginPhase();
|
|
2006
|
+
host.presence?.syncMarkers(delta);
|
|
2007
|
+
viewport.update(delta);
|
|
2008
|
+
// This stage's frame hook. After viewport.update, the same order the
|
|
2009
|
+
// scene panel uses, so anything riding the loop (a camera flight)
|
|
2010
|
+
// has final say over the pose and orbit damping never fights it.
|
|
2011
|
+
if (!chromeless) runViewportFrame(documentId, delta);
|
|
2012
|
+
viewport.batchedRenderer.update(delta);
|
|
2013
|
+
profiler?.endPhase('editor');
|
|
2014
|
+
if (!resumed && !mustDraw(advanced)) {
|
|
2015
|
+
profiler?.endFrame();
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
profiler?.beginPhase();
|
|
2019
|
+
activeRenderer.info.reset();
|
|
2020
|
+
const timeFirstRender =
|
|
2021
|
+
!firstFrameGateRef.current && activeViewportBreakdownDocumentId() === documentId;
|
|
2022
|
+
if (timeFirstRender) markViewportFirstRenderStart(documentId);
|
|
2023
|
+
const tFirstRender = timeFirstRender ? Date.now() : 0;
|
|
2024
|
+
const presentationOverride = host.scene.overrideMaterial;
|
|
2025
|
+
const mode = documentSession.presentation().mode;
|
|
2026
|
+
if (mode !== 'uv' && mode !== 'vertex-colors')
|
|
2027
|
+
host.scene.overrideMaterial = scene.overrideMaterial;
|
|
2028
|
+
try {
|
|
2029
|
+
activeViewport.renderWithInfrastructure(() => {
|
|
2030
|
+
documentSession.renderViewport(delta);
|
|
2031
|
+
activeViewport.renderViewCube(activeRenderer);
|
|
2032
|
+
});
|
|
2033
|
+
} finally {
|
|
2034
|
+
host.scene.overrideMaterial = presentationOverride;
|
|
2035
|
+
}
|
|
2036
|
+
// Inside the frame that drew them — the canvas has no
|
|
2037
|
+
// preserveDrawingBuffer, so this is the only moment its pixels exist.
|
|
2038
|
+
documentSession.servePresentedFrame();
|
|
2039
|
+
noteDrawn();
|
|
2040
|
+
if (!firstFrameGateRef.current) {
|
|
2041
|
+
firstFrameGateRef.current = true;
|
|
2042
|
+
if (timeFirstRender) markViewportSegment('first-render', Date.now() - tFirstRender);
|
|
2043
|
+
recordViewportFirstFrame(documentId);
|
|
2044
|
+
setSurfaceStatus('ready');
|
|
2045
|
+
}
|
|
2046
|
+
profiler?.reportRender({
|
|
2047
|
+
gpuMs: null,
|
|
2048
|
+
drawCalls: activeRenderer.info.render.calls,
|
|
2049
|
+
triangles: activeRenderer.info.render.triangles,
|
|
2050
|
+
geometries: activeRenderer.info.memory.geometries,
|
|
2051
|
+
textures: activeRenderer.info.memory.textures,
|
|
2052
|
+
});
|
|
2053
|
+
profiler?.endPhase('render');
|
|
2054
|
+
profiler?.endFrame();
|
|
2055
|
+
// THE SHELL'S READOUT IS THE FOCUSED STAGE'S (ARCHITECTURE-CORE
|
|
2056
|
+
// §One stage unit 4). `CameraInfo` and `StatsOverlay` read one
|
|
2057
|
+
// struct the shell owns; the stage the panels are following fills
|
|
2058
|
+
// it, so a model or prefab document reports ITS frame and ITS
|
|
2059
|
+
// camera. Inactive documents leave the shared readout alone.
|
|
2060
|
+
const shellReadout = shellStatsRef.current;
|
|
2061
|
+
if (shellReadout && shellStore && focusedStageStore(shellStore.shell) === store.shell) {
|
|
2062
|
+
const pose = documentSession.cameraPose();
|
|
2063
|
+
shellReadout.frameTime = delta * 1000;
|
|
2064
|
+
shellReadout.fps = delta > 0 ? 1 / delta : 0;
|
|
2065
|
+
shellReadout.drawCalls = activeRenderer.info.render.calls;
|
|
2066
|
+
shellReadout.triangles = activeRenderer.info.render.triangles;
|
|
2067
|
+
shellReadout.cameraPosition.x = pose.position[0];
|
|
2068
|
+
shellReadout.cameraPosition.y = pose.position[1];
|
|
2069
|
+
shellReadout.cameraPosition.z = pose.position[2];
|
|
2070
|
+
shellReadout.cameraTarget.x = pose.target[0];
|
|
2071
|
+
shellReadout.cameraTarget.y = pose.target[1];
|
|
2072
|
+
shellReadout.cameraTarget.z = pose.target[2];
|
|
2073
|
+
}
|
|
2074
|
+
};
|
|
2075
|
+
|
|
2076
|
+
host.frame = animate;
|
|
2077
|
+
rendererSessionRef.current = host.rendererSession;
|
|
2078
|
+
if (!host.initialized) {
|
|
2079
|
+
if (!chromeless) {
|
|
2080
|
+
// --- The viewport door (viewport-door.ts) ---
|
|
2081
|
+
// This host is ONE STAGE among the mounted 3D documents
|
|
2082
|
+
// (ARCHITECTURE-CORE §One stage), and it binds under the id of the
|
|
2083
|
+
// document it draws, so an SDK reader reaches THIS stage's rig,
|
|
2084
|
+
// helper sink and frame loop through the viewport door's `viewportStages()`.
|
|
2085
|
+
// It presents no live roots yet — Play's adoption is the world
|
|
2086
|
+
// root's, and moves onto this host in unit 3 — so the presenter
|
|
2087
|
+
// declines every root rather than pretending to a subject.
|
|
2088
|
+
// A CHROMELESS mount (the inspector's object preview) binds
|
|
2089
|
+
// nothing: it is a thumbnail of a document, not a stage of its own,
|
|
2090
|
+
// and several can be alive for one document id at once.
|
|
2091
|
+
host.cleanups.push(
|
|
2092
|
+
bindViewportRig(
|
|
2093
|
+
{
|
|
2094
|
+
camera: viewport.camera,
|
|
2095
|
+
drawCamera: () => host.session?.camera() ?? viewport.camera,
|
|
2096
|
+
orbit: viewport.orbitControls,
|
|
2097
|
+
scene: host.scene,
|
|
2098
|
+
},
|
|
2099
|
+
() => null,
|
|
2100
|
+
(kind, object) => viewport.setHelper(kind, object),
|
|
2101
|
+
{ documentId },
|
|
2102
|
+
),
|
|
2103
|
+
);
|
|
2104
|
+
// --- The other participants, on THIS stage ---
|
|
2105
|
+
// Presence was the scene panel's, so only the world root had it.
|
|
2106
|
+
// It is a capability with a condition — a stage painting a three
|
|
2107
|
+
// surface — and a `build` stage always paints one, so every
|
|
2108
|
+
// document with chrome mounts it and a prefab story shows the
|
|
2109
|
+
// same camera frusta, selection boxes and pointer rays the Scene
|
|
2110
|
+
// does (ARCHITECTURE-CORE §One stage; WORK.md §Presence and the
|
|
2111
|
+
// substrate, presence unit 4). Dynamically imported: it reaches
|
|
2112
|
+
// the collaboration client, which a bounded host must not pay for.
|
|
2113
|
+
void import('./stage-presence-markers').then((markers) => {
|
|
2114
|
+
if (host.isClosed || documentHostRef.current !== host || host.presence) return;
|
|
2115
|
+
const binding = markers.bindStagePresenceMarkers({
|
|
2116
|
+
// The HOST's scene, not this revision's content scene: a
|
|
2117
|
+
// source update swaps the content and the markers must not go
|
|
2118
|
+
// with it.
|
|
2119
|
+
scene: host.scene,
|
|
2120
|
+
store,
|
|
2121
|
+
documentId,
|
|
2122
|
+
container,
|
|
2123
|
+
canvas,
|
|
2124
|
+
viewport,
|
|
2125
|
+
readVisibleCameraPose: () => {
|
|
2126
|
+
const camera = host.session?.camera() ?? viewport.renderCamera;
|
|
2127
|
+
const position = camera.getWorldPosition(new THREE.Vector3());
|
|
2128
|
+
const fov = (camera as THREE.PerspectiveCamera).fov;
|
|
2129
|
+
return {
|
|
2130
|
+
position,
|
|
2131
|
+
target: viewport.orbitControls.target,
|
|
2132
|
+
fov: typeof fov === 'number' ? fov : 0,
|
|
2133
|
+
};
|
|
2134
|
+
},
|
|
2135
|
+
});
|
|
2136
|
+
host.presence = binding;
|
|
2137
|
+
host.cleanups.push(() => binding.dispose());
|
|
2138
|
+
});
|
|
2139
|
+
host.cleanups.push(
|
|
2140
|
+
registerObject3DDocumentSession(documentSession, threeStageTransformChrome(documentId)),
|
|
2141
|
+
);
|
|
2142
|
+
host.cleanups.push(
|
|
2143
|
+
registerPerformanceSource({
|
|
2144
|
+
id: documentId,
|
|
2145
|
+
label: displayName,
|
|
2146
|
+
kind: assetSubject ? 'asset' : 'source',
|
|
2147
|
+
profiler: documentSession.profiler,
|
|
2148
|
+
}),
|
|
2149
|
+
);
|
|
2150
|
+
}
|
|
2151
|
+
// EXACTLY ONE registrar of this document's workspace selection: the
|
|
2152
|
+
// identity-row shell does it through its own `selection` prop, so
|
|
2153
|
+
// the host does it wherever that shell is absent. The condition was
|
|
2154
|
+
// `!studioStage` while the shell and the studio were the same
|
|
2155
|
+
// answer; they parted when a DATA subject gained the studio
|
|
2156
|
+
// (§A model is data), and reading the old one left the Model
|
|
2157
|
+
// document with NEITHER registrar — the Hierarchy said "No
|
|
2158
|
+
// authoring adapter" and the mesh inspector emptied (measured live
|
|
2159
|
+
// on the models scaffold, 2026-09-18).
|
|
2160
|
+
if (!chromeless && !hasShell) {
|
|
2161
|
+
host.cleanups.push(
|
|
2162
|
+
registerWorkspaceDocumentSelection(documentId, () => ({
|
|
2163
|
+
adapter: host.adapter!,
|
|
2164
|
+
nodeId: host.adapter?.selection?.get()[0] ?? null,
|
|
2165
|
+
})),
|
|
2166
|
+
);
|
|
2167
|
+
}
|
|
2168
|
+
const designTimeSurfaceId = chromeless
|
|
2169
|
+
? `${documentId}#chromeless-${++chromelessSurfaceSequence}`
|
|
2170
|
+
: documentId;
|
|
2171
|
+
host.cleanups.push(
|
|
2172
|
+
registerDesignTimeSurface({
|
|
2173
|
+
id: designTimeSurfaceId,
|
|
2174
|
+
label: displayName,
|
|
2175
|
+
contentClock: () => host.contentSeconds,
|
|
2176
|
+
transportAdvances: () => host.transportAdvances,
|
|
2177
|
+
}),
|
|
2178
|
+
);
|
|
2179
|
+
// THE VIEWPORT ACTION BUS, on a document stage. The world root has
|
|
2180
|
+
// answered these since the scene panel existed; a document stage
|
|
2181
|
+
// never did, which is why Frame (`F`) was inert on a prefab or a
|
|
2182
|
+
// model — the hotkey pushes `focus-selection` onto the stage's own
|
|
2183
|
+
// store and nothing was listening (ARCHITECTURE-CORE §One stage:
|
|
2184
|
+
// the capability is present wherever its condition holds).
|
|
2185
|
+
host.cleanups.push(
|
|
2186
|
+
store.shell.onViewportAction((action) => {
|
|
2187
|
+
switch (action.type) {
|
|
2188
|
+
case 'focus-selection':
|
|
2189
|
+
case 'focus-scene':
|
|
2190
|
+
// The session frames the selection, else the whole subject,
|
|
2191
|
+
// and says so on the console when there is nothing to frame.
|
|
2192
|
+
if (!host.session?.frame() && host.session?.root)
|
|
2193
|
+
viewport.focusOn(host.session.root);
|
|
2194
|
+
break;
|
|
2195
|
+
case 'focus-entity': {
|
|
2196
|
+
const object = host.adapter ? threeObject(host.adapter.hierarchy, action.id) : null;
|
|
2197
|
+
if (object) viewport.focusOn(object);
|
|
2198
|
+
break;
|
|
2199
|
+
}
|
|
2200
|
+
case 'snap-selection-to-floor':
|
|
2201
|
+
viewport.snapSelectionToFloor();
|
|
2202
|
+
break;
|
|
2203
|
+
case 'set-view-preset':
|
|
2204
|
+
// A mounted document draws with its own camera pair, so the preset is its
|
|
2205
|
+
// (the relay's `view-preset` routes the same way); the viewport's own
|
|
2206
|
+
// projection would change a camera nobody draws with.
|
|
2207
|
+
if (host.session)
|
|
2208
|
+
host.session.setViewPreset(action.preset === 'perspective' ? 'isometric' : action.preset, 'view');
|
|
2209
|
+
else viewport.setViewPreset(action.preset);
|
|
2210
|
+
break;
|
|
2211
|
+
case 'toggle-camera-view':
|
|
2212
|
+
host.session?.toggleCameraView();
|
|
2213
|
+
break;
|
|
2214
|
+
case 'frame-all':
|
|
2215
|
+
if (!host.session?.frame(1, 'all') && host.session?.root) viewport.focusOn(host.session.root);
|
|
2216
|
+
else if (!host.session) viewport.focusOnScene();
|
|
2217
|
+
break;
|
|
2218
|
+
case 'zoom-view':
|
|
2219
|
+
// In a camera view a zoom zooms the camera's frame (`view_zoom_to_window_xy_camera`).
|
|
2220
|
+
if (host.session?.cameraView() && !host.session.cameraViewLocked())
|
|
2221
|
+
host.session.zoomCameraView(action.direction > 0 ? 1.2 : 1 / 1.2);
|
|
2222
|
+
else viewport.zoomStep(action.direction);
|
|
2223
|
+
break;
|
|
2224
|
+
case 'step-view':
|
|
2225
|
+
// In a camera view only the lock orbits, moving the camera (Blender cancels
|
|
2226
|
+
// it otherwise).
|
|
2227
|
+
if (host.session?.cameraView() && !host.session.cameraViewLocked()) break;
|
|
2228
|
+
viewport.stepView(action.step);
|
|
2229
|
+
break;
|
|
2230
|
+
case 'toggle-projection': {
|
|
2231
|
+
// A camera view has its camera's projection, as the cluster's toggle does.
|
|
2232
|
+
const session = host.session;
|
|
2233
|
+
if (session?.cameraView()) break;
|
|
2234
|
+
const drawn = session ? session.projection() : viewport.projection;
|
|
2235
|
+
const next = drawn === 'perspective' ? 'orthographic' : 'perspective';
|
|
2236
|
+
if (session) session.setProjection(next);
|
|
2237
|
+
else viewport.setProjection(next);
|
|
2238
|
+
break;
|
|
2239
|
+
}
|
|
2240
|
+
case 'set-camera-pose':
|
|
2241
|
+
viewport.setPose(action.position, action.target, action.fov);
|
|
2242
|
+
break;
|
|
2243
|
+
}
|
|
2244
|
+
}),
|
|
2245
|
+
);
|
|
2246
|
+
let selectionSignature = '';
|
|
2247
|
+
host.cleanups.push(
|
|
2248
|
+
store.shell.subscribe(() => {
|
|
2249
|
+
viewport.objectMap = store.objectMap;
|
|
2250
|
+
viewport.syncFromStore();
|
|
2251
|
+
host.session?.syncSelectionPresentation();
|
|
2252
|
+
const next = (host.adapter?.selection?.get() ?? []).join('\u0000');
|
|
2253
|
+
if (next !== selectionSignature) {
|
|
2254
|
+
selectionSignature = next;
|
|
2255
|
+
if (!chromeless) notifyWorkspaceDocumentSelectionChanged(documentId);
|
|
2256
|
+
}
|
|
2257
|
+
}),
|
|
2258
|
+
);
|
|
2259
|
+
const resize = () => {
|
|
2260
|
+
const width = Math.max(1, container.clientWidth);
|
|
2261
|
+
const height = Math.max(1, container.clientHeight);
|
|
2262
|
+
const ratio = renderer.getPixelRatio();
|
|
2263
|
+
if (
|
|
2264
|
+
canvas.width !== Math.floor(width * ratio) ||
|
|
2265
|
+
canvas.height !== Math.floor(height * ratio)
|
|
2266
|
+
)
|
|
2267
|
+
renderer.setSize(width, height, false);
|
|
2268
|
+
viewport.resize(width, height);
|
|
2269
|
+
host.session?.resize(width, height);
|
|
2270
|
+
host.rendererSession.redraw();
|
|
2271
|
+
};
|
|
2272
|
+
const observer = new ResizeObserver(resize);
|
|
2273
|
+
observer.observe(container);
|
|
2274
|
+
host.cleanups.push(() => observer.disconnect());
|
|
2275
|
+
resize();
|
|
2276
|
+
host.initialized = true;
|
|
2277
|
+
markViewportConstructReady();
|
|
2278
|
+
}
|
|
2279
|
+
viewport.objectMap = store.objectMap;
|
|
2280
|
+
viewport.syncFromStore();
|
|
2281
|
+
host.rendererSession.setActive(activeRef.current);
|
|
2282
|
+
host.rendererSession.redraw();
|
|
2283
|
+
published = true;
|
|
2284
|
+
// Old graph stays owned until the new binding is fully published.
|
|
2285
|
+
void previous?.dispose();
|
|
2286
|
+
} catch (caught) {
|
|
2287
|
+
if (!published) {
|
|
2288
|
+
try {
|
|
2289
|
+
rollback?.();
|
|
2290
|
+
} finally {
|
|
2291
|
+
if (sourceParent && source.root.parent === scene) sourceParent.add(source.root);
|
|
2292
|
+
void binding.dispose();
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
// Candidate construction leaves the last good binding and pixels live.
|
|
2296
|
+
// biome-ignore lint/suspicious/noConsole: source failure must remain diagnosable
|
|
2297
|
+
console.error(`[object3d document] ${displayName}: source update failed`, caught);
|
|
2298
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
2299
|
+
}
|
|
2300
|
+
};
|
|
2301
|
+
const update = contentUpdateTail.current.then(install);
|
|
2302
|
+
contentUpdateTail.current = update.catch((caught) => {
|
|
2303
|
+
if (!cancelled) setError(caught instanceof Error ? caught.message : String(caught));
|
|
2304
|
+
});
|
|
2305
|
+
return () => {
|
|
2306
|
+
cancelled = true;
|
|
2307
|
+
};
|
|
2308
|
+
}, [
|
|
2309
|
+
authoring,
|
|
2310
|
+
background,
|
|
2311
|
+
build,
|
|
2312
|
+
cameraX,
|
|
2313
|
+
cameraY,
|
|
2314
|
+
cameraZ,
|
|
2315
|
+
displayName,
|
|
2316
|
+
documentId,
|
|
2317
|
+
studioStage,
|
|
2318
|
+
dressingBackground,
|
|
2319
|
+
dressingEnvironment,
|
|
2320
|
+
dressingGrid,
|
|
2321
|
+
dressingKeyLight,
|
|
2322
|
+
dressingViewLocked,
|
|
2323
|
+
dressingToneMapping,
|
|
2324
|
+
chromeless,
|
|
2325
|
+
modelSourceEntityId,
|
|
2326
|
+
modelSourceKind,
|
|
2327
|
+
modelSourcePath,
|
|
2328
|
+
interaction,
|
|
2329
|
+
persistence,
|
|
2330
|
+
documentSource,
|
|
2331
|
+
rendererLane,
|
|
2332
|
+
retainedState,
|
|
2333
|
+
surfaceAttached,
|
|
2334
|
+
sourceAuthoring,
|
|
2335
|
+
sourcePath,
|
|
2336
|
+
worldRoot,
|
|
2337
|
+
]);
|
|
2338
|
+
|
|
2339
|
+
useEffect(() => {
|
|
2340
|
+
const session = rendererSessionRef.current;
|
|
2341
|
+
const becameActive = active && !previousActiveRef.current;
|
|
2342
|
+
previousActiveRef.current = active;
|
|
2343
|
+
if (!session) return;
|
|
2344
|
+
if (becameActive) {
|
|
2345
|
+
firstFrameGateRef.current = false;
|
|
2346
|
+
markViewportRafResume(documentId);
|
|
2347
|
+
}
|
|
2348
|
+
// A world root keeps its clock while another document holds focus.
|
|
2349
|
+
session.setActive(active || worldRoot);
|
|
2350
|
+
}, [active, documentId, worldRoot]);
|
|
2351
|
+
|
|
2352
|
+
// The stage's own handle for the capabilities that drive it directly (the
|
|
2353
|
+
// viewport hotkeys). `surfaceStatus` is what re-renders once the stage has
|
|
2354
|
+
// finished building, so reading the ref here is safe and never stale.
|
|
2355
|
+
const host = documentHostRef.current;
|
|
2356
|
+
const stageHandle =
|
|
2357
|
+
surfaceStatus === 'ready' && host?.viewport
|
|
2358
|
+
? { store: host.store, viewport: host.viewport, canvas: host.surface.canvas }
|
|
2359
|
+
: null;
|
|
2360
|
+
// The document's own selection changes as you pick inside it; the registry
|
|
2361
|
+
// reads this on demand, so the picked part follows without re-registering.
|
|
2362
|
+
// With nothing picked the DOCUMENT NODE is the subject — the same reading
|
|
2363
|
+
// as clicking a model's root in the scene.
|
|
2364
|
+
const readSelection = useCallback(() => {
|
|
2365
|
+
const owner = documentAdapterRef.current;
|
|
2366
|
+
return owner
|
|
2367
|
+
? {
|
|
2368
|
+
adapter: owner,
|
|
2369
|
+
nodeId: owner.selection?.get()[0] ?? owner.hierarchy.roots()[0]?.id ?? null,
|
|
2370
|
+
}
|
|
2371
|
+
: null;
|
|
2372
|
+
}, []);
|
|
2373
|
+
const viewport = (
|
|
2374
|
+
<div
|
|
2375
|
+
data-testid="tool-object3d-authoring"
|
|
2376
|
+
data-vgai-chromeless={chromeless || undefined}
|
|
2377
|
+
className={studioStage ? 'vgai-object3d-studio-stage' : undefined}
|
|
2378
|
+
style={{
|
|
2379
|
+
position: chromeless || hasShell ? 'relative' : 'absolute',
|
|
2380
|
+
// Inside the Asset Editor shell the stage fills its own box exactly;
|
|
2381
|
+
// in a bare dock panel it bleeds under the panel's padding.
|
|
2382
|
+
...(chromeless
|
|
2383
|
+
? { width: '100%', height: '100%' }
|
|
2384
|
+
: hasShell
|
|
2385
|
+
? { flex: 1, minHeight: 0 }
|
|
2386
|
+
: { inset: -STAGE_BLEED_PX }),
|
|
2387
|
+
overflow: 'hidden',
|
|
2388
|
+
// Asset documents paint the shared studio stage through their class.
|
|
2389
|
+
// An inline background here would win the cascade and hide it.
|
|
2390
|
+
background: studioStage ? undefined : themeVars.surface.raised,
|
|
2391
|
+
pointerEvents: 'auto',
|
|
2392
|
+
}}
|
|
2393
|
+
>
|
|
2394
|
+
<div
|
|
2395
|
+
ref={containerRef}
|
|
2396
|
+
style={{ position: 'absolute', inset: 0, minHeight: 0, overflow: 'hidden' }}
|
|
2397
|
+
>
|
|
2398
|
+
<ViewportSurface canvasHostRef={canvasHostRef} />
|
|
2399
|
+
{/* WHICH KEYS THIS STAGE ANSWERS — the transform modes, the view
|
|
2400
|
+
presets, Frame, pivot, snap. Beside the stage rather than inside
|
|
2401
|
+
the overlay set, because a package-contributed document mounts
|
|
2402
|
+
with no shell above it and reached none of them
|
|
2403
|
+
(`stage-keyboard.tsx`). The registry holds ONE stage's set, so the
|
|
2404
|
+
condition is the ACTIVE document's stage. */}
|
|
2405
|
+
{active && stageHandle && !chromeless ? (
|
|
2406
|
+
<Suspense fallback={null}>
|
|
2407
|
+
<LazyStageKeyboard stage={stageHandle} />
|
|
2408
|
+
</Suspense>
|
|
2409
|
+
) : null}
|
|
2410
|
+
{/* The stage's own overlays, co-located with the canvas the way
|
|
2411
|
+
`RootSelectionOverlay`'s DOM contract requires (its marquee is
|
|
2412
|
+
measured against this container's box). A stage with no shell
|
|
2413
|
+
above it — a bounded host — renders none of them and never loads
|
|
2414
|
+
them. */}
|
|
2415
|
+
{shellStore && !chromeless ? (
|
|
2416
|
+
<StageOverlays
|
|
2417
|
+
store={shellStore}
|
|
2418
|
+
documentId={documentId}
|
|
2419
|
+
stage={stageHandle}
|
|
2420
|
+
active={active}
|
|
2421
|
+
chrome="document"
|
|
2422
|
+
/>
|
|
2423
|
+
) : null}
|
|
2424
|
+
{shellStore && worldRootBinding ? (
|
|
2425
|
+
<worldRootBinding.Overlays
|
|
2426
|
+
store={shellStore}
|
|
2427
|
+
documentId={documentId}
|
|
2428
|
+
cameraPreviewRef={cameraPreviewRef}
|
|
2429
|
+
mountStatus={designMountStatus}
|
|
2430
|
+
rootIds={designRootIds}
|
|
2431
|
+
/>
|
|
2432
|
+
) : null}
|
|
2433
|
+
{/* A ROLLED-BACK operation (an animation write, a capture, a source
|
|
2434
|
+
write the history guarded) — every writer of `error` restores the
|
|
2435
|
+
prior state, so the surface below is live and must stay visible.
|
|
2436
|
+
It used to mount a full-bleed opaque curtain with no dismiss: the
|
|
2437
|
+
stage read as dead until the session restarted (measured). A
|
|
2438
|
+
notice over the stage, dismissible, and cleared by the next
|
|
2439
|
+
gesture that succeeds. */}
|
|
2440
|
+
{error && (
|
|
2441
|
+
<div
|
|
2442
|
+
role="alert"
|
|
2443
|
+
style={{
|
|
2444
|
+
position: 'absolute',
|
|
2445
|
+
insetInline: 0,
|
|
2446
|
+
top: 0,
|
|
2447
|
+
display: 'flex',
|
|
2448
|
+
justifyContent: 'center',
|
|
2449
|
+
padding: 'var(--vgai-space-3)',
|
|
2450
|
+
pointerEvents: 'none',
|
|
2451
|
+
}}
|
|
2452
|
+
>
|
|
2453
|
+
<div
|
|
2454
|
+
className="vgai-chrome-island"
|
|
2455
|
+
style={{
|
|
2456
|
+
display: 'flex',
|
|
2457
|
+
alignItems: 'center',
|
|
2458
|
+
gap: 'var(--vgai-space-3)',
|
|
2459
|
+
maxWidth: '80%',
|
|
2460
|
+
padding: 'var(--vgai-space-2) var(--vgai-space-3)',
|
|
2461
|
+
borderRadius: 'var(--vgai-radius-md)',
|
|
2462
|
+
border: `var(--vgai-stroke-resting) solid ${themeVars.semantic.danger}`,
|
|
2463
|
+
background: themeVars.surface.raised,
|
|
2464
|
+
color: themeVars.semantic.danger,
|
|
2465
|
+
pointerEvents: 'auto',
|
|
2466
|
+
}}
|
|
2467
|
+
>
|
|
2468
|
+
<span>{error}</span>
|
|
2469
|
+
<IconButton
|
|
2470
|
+
size="compact"
|
|
2471
|
+
aria-label="Dismiss"
|
|
2472
|
+
onClick={() => setError(null)}
|
|
2473
|
+
data-testid="object3d-document-error-dismiss"
|
|
2474
|
+
>
|
|
2475
|
+
<EditorIcon icon={editorIcons.action.close} />
|
|
2476
|
+
</IconButton>
|
|
2477
|
+
</div>
|
|
2478
|
+
</div>
|
|
2479
|
+
)}
|
|
2480
|
+
{!error && surfaceStatus === 'building' && (
|
|
2481
|
+
<ViewportSurfaceStatus testId="object3d-document-status">
|
|
2482
|
+
{OBJECT3D_SURFACE_BUILDING}
|
|
2483
|
+
</ViewportSurfaceStatus>
|
|
2484
|
+
)}
|
|
2485
|
+
{/* The stage BLEEDS 12px under the dock panel's padding (`inset: -12`
|
|
2486
|
+
above) so the rendered image fills it edge to edge. The furniture is
|
|
2487
|
+
CHROME, not image: it has to sit inside the panel the person can
|
|
2488
|
+
actually see, so it gets that bleed back. Without this the view text
|
|
2489
|
+
started at page x=-6 — the "U" of "User Perspective" was clipped off
|
|
2490
|
+
the window — and the navigation cluster's right edge landed 4px past
|
|
2491
|
+
the panel, under the Inspector's section rail (both measured). */}
|
|
2492
|
+
<div
|
|
2493
|
+
style={{
|
|
2494
|
+
position: 'absolute',
|
|
2495
|
+
inset: chromeless || hasShell ? 0 : STAGE_BLEED_PX,
|
|
2496
|
+
pointerEvents: 'none',
|
|
2497
|
+
}}
|
|
2498
|
+
>
|
|
2499
|
+
{!chromeless && surfaceStatus === 'ready' && documentHostRef.current && (
|
|
2500
|
+
<ViewportFurniture
|
|
2501
|
+
viewport={documentHostRef.current.viewport}
|
|
2502
|
+
documentId={documentId}
|
|
2503
|
+
session={documentHostRef.current.session}
|
|
2504
|
+
store={documentHostRef.current.store.shell}
|
|
2505
|
+
projection={projection}
|
|
2506
|
+
displayName={displayName}
|
|
2507
|
+
{...(statistics ? { statistics } : {})}
|
|
2508
|
+
{...(subject !== undefined ? { subject } : {})}
|
|
2509
|
+
{...(gridScale ? { gridScale } : {})}
|
|
2510
|
+
objectName={(id) =>
|
|
2511
|
+
((adapter) => (adapter ? threeObject(adapter.hierarchy, id)?.name : undefined))(documentHostRef.current?.adapter) ??
|
|
2512
|
+
// Document builders may supply names through their store index.
|
|
2513
|
+
documentHostRef.current?.store.objectMap.get(id)?.name ??
|
|
2514
|
+
null
|
|
2515
|
+
}
|
|
2516
|
+
/>
|
|
2517
|
+
)}
|
|
2518
|
+
</div>
|
|
2519
|
+
</div>
|
|
2520
|
+
</div>
|
|
2521
|
+
);
|
|
2522
|
+
if (!hasShell) return viewport;
|
|
2523
|
+
return (
|
|
2524
|
+
<AssetEditorShell
|
|
2525
|
+
documentId={documentId}
|
|
2526
|
+
active={active}
|
|
2527
|
+
type={assetType ?? (modelSourceEntityId ? 'entity' : 'model')}
|
|
2528
|
+
title={displayName}
|
|
2529
|
+
status={sourcePath}
|
|
2530
|
+
selection={readSelection}
|
|
2531
|
+
fill
|
|
2532
|
+
>
|
|
2533
|
+
<div className="vgai-object3d-asset-workspace">
|
|
2534
|
+
{viewport}
|
|
2535
|
+
{/* Where the deleted preview strip was. It draws nothing until this
|
|
2536
|
+
stage's transport has a subject, so a document with nothing to show
|
|
2537
|
+
at a time is unchanged. */}
|
|
2538
|
+
<TransportStrip transport={documentHostRef.current?.transport ?? null} />
|
|
2539
|
+
</div>
|
|
2540
|
+
</AssetEditorShell>
|
|
2541
|
+
);
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
/** Public project contribution surface over the editor's native Object3D document host. */
|
|
2545
|
+
export function ToolObject3DAuthoring(props: ToolObject3DAuthoringProps) {
|
|
2546
|
+
return <Object3DDocumentViewport {...props} />;
|
|
2547
|
+
}
|