@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,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE OTHER PARTICIPANTS, ON A STAGE — the spatial half of collaboration
|
|
3
|
+
* presence: what THIS stage publishes about the reader's own camera and
|
|
4
|
+
* gestures, and the camera frusta, selection boxes and pointer rays it paints
|
|
5
|
+
* for everyone else (WORK.md §Presence and the substrate, presence unit 4).
|
|
6
|
+
*
|
|
7
|
+
* It was the scene panel's, so only the world root had it. Under
|
|
8
|
+
* ARCHITECTURE-CORE §One stage it is a CAPABILITY with a condition — a stage
|
|
9
|
+
* painting a three surface — so every 3D document mounts it and a prefab
|
|
10
|
+
* story shows the same markers the Scene does.
|
|
11
|
+
*
|
|
12
|
+
* WHICH STAGE A PARTICIPANT IS ON. Presence is ONE record per participant
|
|
13
|
+
* (`collaboration-presence.ts`), and its `document` field is written by the
|
|
14
|
+
* shell for the ACTIVE workspace document (`DefaultEditorLayout.tsx`). So a
|
|
15
|
+
* stage publishes its camera only while its own document is the active one —
|
|
16
|
+
* otherwise several stages would overwrite each other's pose in the single
|
|
17
|
+
* record — and paints a participant only on the stage whose id matches that
|
|
18
|
+
* participant's `presence.document`. Measured against the spec's "publish it
|
|
19
|
+
* from the host with the stage's documentId": the presence record has ONE
|
|
20
|
+
* `document` field and the shell already owns it, so the stage owns the
|
|
21
|
+
* CONDITION on writing, not a second writer of that field.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { CollaborationParticipant } from '@volter/editor-sdk/session/collaboration-types';
|
|
25
|
+
import { EDITOR_THEME_CLASS, graphiteDarkEditorTheme } from '@volter/editor-sdk/widgets';
|
|
26
|
+
import { contentWorldBounds } from '@volter/editor-threejs/viewport/content-bounds';
|
|
27
|
+
import * as THREE from 'three';
|
|
28
|
+
import { collaborationSnapshot } from '@volter/editor-sdk/kit/collaboration-client';
|
|
29
|
+
import { reportCollaborationPresence } from '@volter/editor-sdk/kit/collaboration-presence';
|
|
30
|
+
import { EDITOR_PARTICIPANT_ID } from '@volter/editor-sdk/kit/editor-presence';
|
|
31
|
+
import type { EditorShellStore } from '../editor-shell-store';
|
|
32
|
+
import type { EditorViewport } from '../editor-viewport';
|
|
33
|
+
import { activeWorkspaceDocumentId } from '@volter/editor-sdk/kit/workspace-document-registry';
|
|
34
|
+
|
|
35
|
+
interface RemoteCameraMarker {
|
|
36
|
+
camera: THREE.PerspectiveCamera;
|
|
37
|
+
helper: THREE.CameraHelper;
|
|
38
|
+
label: THREE.Sprite;
|
|
39
|
+
texture: THREE.CanvasTexture;
|
|
40
|
+
signature: string;
|
|
41
|
+
targetPosition: THREE.Vector3;
|
|
42
|
+
targetQuaternion: THREE.Quaternion;
|
|
43
|
+
targetFov: number;
|
|
44
|
+
initialized: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface RemoteSelectionMarker {
|
|
48
|
+
object: THREE.Object3D;
|
|
49
|
+
box: THREE.Box3;
|
|
50
|
+
helper: THREE.Box3Helper;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface RemoteRayMarker {
|
|
54
|
+
line: THREE.Line;
|
|
55
|
+
positions: THREE.BufferAttribute;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Remote-camera labels paint into a DETACHED canvas, and a 2D context cannot
|
|
60
|
+
* resolve `var(--vgai-…)` — so the theme's value has to be read off the live
|
|
61
|
+
* theme root before it reaches `fillStyle`. Before a theme is installed (unit
|
|
62
|
+
* environments), fall back to the default theme's own value rather than a raw
|
|
63
|
+
* literal, so the label never depends on a color the theme contract doesn't own.
|
|
64
|
+
*/
|
|
65
|
+
function contentOnAccentColor(): string {
|
|
66
|
+
const root = document.querySelector(`.${EDITOR_THEME_CLASS}`);
|
|
67
|
+
const resolved = root
|
|
68
|
+
? getComputedStyle(root).getPropertyValue('--vgai-content-on-accent').trim()
|
|
69
|
+
: '';
|
|
70
|
+
return resolved || graphiteDarkEditorTheme.color.content.onAccent;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function drawRemoteCameraLabel(
|
|
74
|
+
texture: THREE.CanvasTexture,
|
|
75
|
+
participant: CollaborationParticipant,
|
|
76
|
+
): void {
|
|
77
|
+
const canvas = texture.image as HTMLCanvasElement;
|
|
78
|
+
const context = canvas.getContext('2d');
|
|
79
|
+
if (!context) return;
|
|
80
|
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
81
|
+
context.fillStyle = participant.color;
|
|
82
|
+
context.beginPath();
|
|
83
|
+
context.roundRect(2, 2, canvas.width - 4, canvas.height - 4, 16);
|
|
84
|
+
context.fill();
|
|
85
|
+
context.fillStyle = contentOnAccentColor();
|
|
86
|
+
context.font = '600 22px system-ui, sans-serif';
|
|
87
|
+
context.textAlign = 'center';
|
|
88
|
+
context.textBaseline = 'middle';
|
|
89
|
+
const suffix = participant.kind === 'agent' ? ' · AI' : '';
|
|
90
|
+
context.fillText(`${participant.displayName}${suffix}`, canvas.width / 2, canvas.height / 2, 238);
|
|
91
|
+
texture.needsUpdate = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createRemoteCameraMarker(participant: CollaborationParticipant): RemoteCameraMarker {
|
|
95
|
+
const camera = new THREE.PerspectiveCamera(50, 1, 0.2, 1.4);
|
|
96
|
+
const helper = new THREE.CameraHelper(camera);
|
|
97
|
+
const color = new THREE.Color(participant.color);
|
|
98
|
+
helper.setColors(color, color, color, color, color);
|
|
99
|
+
helper.name = `Collaboration camera: ${participant.displayName}`;
|
|
100
|
+
helper.userData['editorOnly'] = true;
|
|
101
|
+
const canvas = document.createElement('canvas');
|
|
102
|
+
canvas.width = 256;
|
|
103
|
+
canvas.height = 64;
|
|
104
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
105
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
106
|
+
const label = new THREE.Sprite(
|
|
107
|
+
new THREE.SpriteMaterial({ map: texture, depthTest: false, transparent: true }),
|
|
108
|
+
);
|
|
109
|
+
label.name = `Collaboration label: ${participant.displayName}`;
|
|
110
|
+
label.userData['editorOnly'] = true;
|
|
111
|
+
label.scale.set(1.8, 0.45, 1);
|
|
112
|
+
label.renderOrder = 10_000;
|
|
113
|
+
drawRemoteCameraLabel(texture, participant);
|
|
114
|
+
return {
|
|
115
|
+
camera,
|
|
116
|
+
helper,
|
|
117
|
+
label,
|
|
118
|
+
texture,
|
|
119
|
+
signature: '',
|
|
120
|
+
targetPosition: new THREE.Vector3(),
|
|
121
|
+
targetQuaternion: new THREE.Quaternion(),
|
|
122
|
+
targetFov: 50,
|
|
123
|
+
initialized: false,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function updateRemoteCameraMarker(
|
|
128
|
+
marker: RemoteCameraMarker,
|
|
129
|
+
participant: CollaborationParticipant,
|
|
130
|
+
): void {
|
|
131
|
+
const pose = participant.presence.camera;
|
|
132
|
+
if (!pose) return;
|
|
133
|
+
marker.targetPosition.set(pose.x, pose.y, pose.z);
|
|
134
|
+
const targetCamera = new THREE.Object3D();
|
|
135
|
+
targetCamera.position.copy(marker.targetPosition);
|
|
136
|
+
targetCamera.lookAt(pose.targetX, pose.targetY, pose.targetZ);
|
|
137
|
+
marker.targetQuaternion.copy(targetCamera.quaternion);
|
|
138
|
+
marker.targetFov = pose.fov;
|
|
139
|
+
if (!marker.initialized) {
|
|
140
|
+
marker.initialized = true;
|
|
141
|
+
marker.camera.position.copy(marker.targetPosition);
|
|
142
|
+
marker.camera.quaternion.copy(marker.targetQuaternion);
|
|
143
|
+
marker.camera.fov = marker.targetFov;
|
|
144
|
+
}
|
|
145
|
+
const signature = `${participant.displayName}|${participant.kind}|${participant.color}|${participant.status}`;
|
|
146
|
+
if (signature !== marker.signature) {
|
|
147
|
+
marker.signature = signature;
|
|
148
|
+
drawRemoteCameraLabel(marker.texture, participant);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function animateRemoteCameraMarker(marker: RemoteCameraMarker, dt: number): void {
|
|
153
|
+
const blend = 1 - Math.exp(-12 * Math.max(0, dt));
|
|
154
|
+
marker.camera.position.lerp(marker.targetPosition, blend);
|
|
155
|
+
marker.camera.quaternion.slerp(marker.targetQuaternion, blend);
|
|
156
|
+
marker.camera.fov += (marker.targetFov - marker.camera.fov) * blend;
|
|
157
|
+
marker.camera.updateProjectionMatrix();
|
|
158
|
+
marker.camera.updateMatrixWorld(true);
|
|
159
|
+
marker.helper.update();
|
|
160
|
+
marker.label.position.copy(marker.camera.position);
|
|
161
|
+
marker.label.position.y += 0.55;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function disposeRemoteCameraMarker(marker: RemoteCameraMarker): void {
|
|
165
|
+
marker.helper.removeFromParent();
|
|
166
|
+
marker.label.removeFromParent();
|
|
167
|
+
marker.helper.dispose();
|
|
168
|
+
marker.texture.dispose();
|
|
169
|
+
(marker.label.material as THREE.SpriteMaterial).dispose();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function createRemoteSelectionMarker(object: THREE.Object3D, color: string): RemoteSelectionMarker {
|
|
173
|
+
const box = contentWorldBounds(object);
|
|
174
|
+
const helper = new THREE.Box3Helper(box, new THREE.Color(color));
|
|
175
|
+
helper.name = `Collaboration selection: ${object.name || object.uuid}`;
|
|
176
|
+
helper.userData['editorOnly'] = true;
|
|
177
|
+
helper.renderOrder = 9_999;
|
|
178
|
+
(helper.material as THREE.LineBasicMaterial).depthTest = false;
|
|
179
|
+
return { object, box, helper };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function disposeRemoteSelectionMarker(marker: RemoteSelectionMarker): void {
|
|
183
|
+
marker.helper.removeFromParent();
|
|
184
|
+
marker.helper.geometry.dispose();
|
|
185
|
+
(marker.helper.material as THREE.Material).dispose();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function pointerRayGesture(value: Readonly<Record<string, unknown>> | null): {
|
|
189
|
+
origin: THREE.Vector3;
|
|
190
|
+
end: THREE.Vector3;
|
|
191
|
+
} | null {
|
|
192
|
+
if (!value || value['type'] !== 'pointer-ray') return null;
|
|
193
|
+
const keys = [
|
|
194
|
+
'originX',
|
|
195
|
+
'originY',
|
|
196
|
+
'originZ',
|
|
197
|
+
'directionX',
|
|
198
|
+
'directionY',
|
|
199
|
+
'directionZ',
|
|
200
|
+
'length',
|
|
201
|
+
] as const;
|
|
202
|
+
if (!keys.every((key) => typeof value[key] === 'number' && Number.isFinite(value[key]))) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const origin = new THREE.Vector3(
|
|
206
|
+
value['originX'] as number,
|
|
207
|
+
value['originY'] as number,
|
|
208
|
+
value['originZ'] as number,
|
|
209
|
+
);
|
|
210
|
+
const direction = new THREE.Vector3(
|
|
211
|
+
value['directionX'] as number,
|
|
212
|
+
value['directionY'] as number,
|
|
213
|
+
value['directionZ'] as number,
|
|
214
|
+
);
|
|
215
|
+
const length = Math.min(1_000, Math.max(0.1, value['length'] as number));
|
|
216
|
+
if (direction.lengthSq() < 0.000_001) return null;
|
|
217
|
+
return { origin, end: origin.clone().add(direction.normalize().multiplyScalar(length)) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface StagePresenceOptions {
|
|
221
|
+
/** The scene the markers are added to — the stage's own drawn scene. */
|
|
222
|
+
readonly scene: THREE.Scene;
|
|
223
|
+
readonly store: EditorShellStore;
|
|
224
|
+
/** The stage's document id: the presence records painted here are the ones
|
|
225
|
+
* whose own `document` is this. */
|
|
226
|
+
readonly documentId: string;
|
|
227
|
+
readonly container: HTMLElement;
|
|
228
|
+
readonly canvas: HTMLCanvasElement;
|
|
229
|
+
readonly viewport: EditorViewport;
|
|
230
|
+
/** The pose the reader is actually looking through, in world space. */
|
|
231
|
+
readonly readVisibleCameraPose: () => {
|
|
232
|
+
readonly position: THREE.Vector3;
|
|
233
|
+
readonly target: THREE.Vector3;
|
|
234
|
+
readonly fov: number;
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface StagePresenceBinding {
|
|
239
|
+
/** Publish the reader's camera now (after a view change the frame loop's
|
|
240
|
+
* pose comparison would otherwise notice a frame late). */
|
|
241
|
+
reportCamera(): void;
|
|
242
|
+
/** Per frame: project new presence sequences and animate the markers. */
|
|
243
|
+
syncMarkers(dtSeconds: number): void;
|
|
244
|
+
/** Whether another participant is drawn on this stage: their markers move
|
|
245
|
+
* on their own, so the stage draws every frame while any are shown. */
|
|
246
|
+
live(): boolean;
|
|
247
|
+
dispose(): void;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function bindStagePresenceMarkers(options: StagePresenceOptions): StagePresenceBinding {
|
|
251
|
+
const { scene, store, documentId, container, canvas, viewport, readVisibleCameraPose } = options;
|
|
252
|
+
/** THE WRITE CONDITION: one presence record, one `document` field — a stage
|
|
253
|
+
* that is not the active document does not speak for the reader. */
|
|
254
|
+
const publishes = (): boolean => activeWorkspaceDocumentId() === documentId;
|
|
255
|
+
|
|
256
|
+
const reportCamera = (): void => {
|
|
257
|
+
if (!publishes()) return;
|
|
258
|
+
const pose = readVisibleCameraPose();
|
|
259
|
+
reportCollaborationPresence({
|
|
260
|
+
camera: {
|
|
261
|
+
x: pose.position.x,
|
|
262
|
+
y: pose.position.y,
|
|
263
|
+
z: pose.position.z,
|
|
264
|
+
targetX: pose.target.x,
|
|
265
|
+
targetY: pose.target.y,
|
|
266
|
+
targetZ: pose.target.z,
|
|
267
|
+
fov: pose.fov,
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
viewport.orbitControls.addEventListener('change', reportCamera);
|
|
272
|
+
|
|
273
|
+
let transformDragging = false;
|
|
274
|
+
const reportTransformGesture = (event: THREE.Event): void => {
|
|
275
|
+
const dragging = (event as unknown as { value?: unknown }).value === true;
|
|
276
|
+
transformDragging = dragging;
|
|
277
|
+
if (!publishes()) return;
|
|
278
|
+
reportCollaborationPresence({
|
|
279
|
+
gesture: dragging ? { type: 'transform', selection: [...store.shell.selectedEntityIds] } : null,
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
viewport.transformControls.addEventListener('dragging-changed', reportTransformGesture);
|
|
283
|
+
|
|
284
|
+
const pointerRaycaster = new THREE.Raycaster();
|
|
285
|
+
const pointerNdc = new THREE.Vector2();
|
|
286
|
+
const reportPointerRay = (event: PointerEvent): void => {
|
|
287
|
+
if (transformDragging || event.buttons !== 0 || !publishes()) return;
|
|
288
|
+
const bounds = canvas.getBoundingClientRect();
|
|
289
|
+
if (bounds.width <= 0 || bounds.height <= 0) return;
|
|
290
|
+
pointerNdc.set(
|
|
291
|
+
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
|
292
|
+
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
|
|
293
|
+
);
|
|
294
|
+
pointerRaycaster.setFromCamera(pointerNdc, viewport.renderCamera);
|
|
295
|
+
const ray = pointerRaycaster.ray;
|
|
296
|
+
const pose = readVisibleCameraPose();
|
|
297
|
+
reportCollaborationPresence({
|
|
298
|
+
gesture: {
|
|
299
|
+
type: 'pointer-ray',
|
|
300
|
+
originX: ray.origin.x,
|
|
301
|
+
originY: ray.origin.y,
|
|
302
|
+
originZ: ray.origin.z,
|
|
303
|
+
directionX: ray.direction.x,
|
|
304
|
+
directionY: ray.direction.y,
|
|
305
|
+
directionZ: ray.direction.z,
|
|
306
|
+
length: Math.max(2, pose.position.distanceTo(pose.target)),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
};
|
|
310
|
+
const clearPointerRay = (): void => {
|
|
311
|
+
if (!transformDragging && publishes()) reportCollaborationPresence({ gesture: null });
|
|
312
|
+
};
|
|
313
|
+
container.addEventListener('pointermove', reportPointerRay);
|
|
314
|
+
container.addEventListener('pointerleave', clearPointerRay);
|
|
315
|
+
reportCamera();
|
|
316
|
+
|
|
317
|
+
// Remote editors are represented in the scene with their real reported
|
|
318
|
+
// camera frustum and a billboard label. This is spatial presence, not a
|
|
319
|
+
// guessed avatar location: participants without a live camera publish no
|
|
320
|
+
// marker. The collaboration snapshot is already refreshed by the page's
|
|
321
|
+
// presence surface, so the render loop only projects new sequences.
|
|
322
|
+
const remoteCameraMarkers = new Map<string, RemoteCameraMarker>();
|
|
323
|
+
const remoteSelectionMarkers = new Map<string, RemoteSelectionMarker>();
|
|
324
|
+
const remoteRayMarkers = new Map<string, RemoteRayMarker>();
|
|
325
|
+
let remotePresenceSequence = -1;
|
|
326
|
+
const syncRemoteSpatialMarkers = (): void => {
|
|
327
|
+
const snapshot = collaborationSnapshot();
|
|
328
|
+
if (!snapshot || snapshot.sequence === remotePresenceSequence) return;
|
|
329
|
+
remotePresenceSequence = snapshot.sequence;
|
|
330
|
+
/** THE PAINT CONDITION: a participant is on the stage its own presence
|
|
331
|
+
* names, so the same person's marker follows them from the Scene to a
|
|
332
|
+
* model document instead of being drawn on both. */
|
|
333
|
+
const here = snapshot.participants.filter(
|
|
334
|
+
(participant) =>
|
|
335
|
+
participant.participantId !== EDITOR_PARTICIPANT_ID &&
|
|
336
|
+
participant.presence.document === documentId,
|
|
337
|
+
);
|
|
338
|
+
const visible = here.filter((participant) => participant.presence.camera);
|
|
339
|
+
const liveIds = new Set(visible.map((participant) => participant.participantId));
|
|
340
|
+
for (const [participantId, marker] of remoteCameraMarkers) {
|
|
341
|
+
if (liveIds.has(participantId)) continue;
|
|
342
|
+
disposeRemoteCameraMarker(marker);
|
|
343
|
+
remoteCameraMarkers.delete(participantId);
|
|
344
|
+
}
|
|
345
|
+
for (const participant of visible) {
|
|
346
|
+
let marker = remoteCameraMarkers.get(participant.participantId);
|
|
347
|
+
if (!marker) {
|
|
348
|
+
marker = createRemoteCameraMarker(participant);
|
|
349
|
+
remoteCameraMarkers.set(participant.participantId, marker);
|
|
350
|
+
scene.add(marker.helper, marker.label);
|
|
351
|
+
}
|
|
352
|
+
updateRemoteCameraMarker(marker, participant);
|
|
353
|
+
}
|
|
354
|
+
const selected = here.flatMap((participant) =>
|
|
355
|
+
participant.presence.selection.slice(0, 20).map((objectId) => ({
|
|
356
|
+
key: `${participant.participantId}:${objectId}`,
|
|
357
|
+
objectId,
|
|
358
|
+
color: participant.color,
|
|
359
|
+
})),
|
|
360
|
+
);
|
|
361
|
+
const selectedKeys = new Set(selected.map((entry) => entry.key));
|
|
362
|
+
for (const [key, marker] of remoteSelectionMarkers) {
|
|
363
|
+
if (selectedKeys.has(key)) continue;
|
|
364
|
+
disposeRemoteSelectionMarker(marker);
|
|
365
|
+
remoteSelectionMarkers.delete(key);
|
|
366
|
+
}
|
|
367
|
+
for (const entry of selected) {
|
|
368
|
+
const object = store.objectMap.get(entry.objectId);
|
|
369
|
+
const previous = remoteSelectionMarkers.get(entry.key);
|
|
370
|
+
if (!object) {
|
|
371
|
+
if (previous) {
|
|
372
|
+
disposeRemoteSelectionMarker(previous);
|
|
373
|
+
remoteSelectionMarkers.delete(entry.key);
|
|
374
|
+
}
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (previous?.object === object) continue;
|
|
378
|
+
if (previous) disposeRemoteSelectionMarker(previous);
|
|
379
|
+
const marker = createRemoteSelectionMarker(object, entry.color);
|
|
380
|
+
remoteSelectionMarkers.set(entry.key, marker);
|
|
381
|
+
scene.add(marker.helper);
|
|
382
|
+
}
|
|
383
|
+
const rays = here.flatMap((participant) => {
|
|
384
|
+
const ray = pointerRayGesture(participant.presence.gesture);
|
|
385
|
+
return ray
|
|
386
|
+
? [{ participantId: participant.participantId, color: participant.color, ray }]
|
|
387
|
+
: [];
|
|
388
|
+
});
|
|
389
|
+
const rayIds = new Set(rays.map((entry) => entry.participantId));
|
|
390
|
+
for (const [participantId, marker] of remoteRayMarkers) {
|
|
391
|
+
if (rayIds.has(participantId)) continue;
|
|
392
|
+
disposeRemoteRayMarker(marker);
|
|
393
|
+
remoteRayMarkers.delete(participantId);
|
|
394
|
+
}
|
|
395
|
+
for (const entry of rays) {
|
|
396
|
+
let marker = remoteRayMarkers.get(entry.participantId);
|
|
397
|
+
if (!marker) {
|
|
398
|
+
marker = createRemoteRayMarker(entry.color);
|
|
399
|
+
remoteRayMarkers.set(entry.participantId, marker);
|
|
400
|
+
scene.add(marker.line);
|
|
401
|
+
}
|
|
402
|
+
updateRemoteRayMarker(marker, entry.ray);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// "Join" in the header's people control lands the viewport on that
|
|
407
|
+
// participant's real reported camera.
|
|
408
|
+
const joinRequested = (event: Event): void => {
|
|
409
|
+
if (!publishes()) return;
|
|
410
|
+
const participantId = (event as CustomEvent<{ participantId?: unknown }>).detail?.participantId;
|
|
411
|
+
if (typeof participantId !== 'string') return;
|
|
412
|
+
const camera = collaborationSnapshot()?.participants.find(
|
|
413
|
+
(participant) => participant.participantId === participantId,
|
|
414
|
+
)?.presence.camera;
|
|
415
|
+
if (!camera) return;
|
|
416
|
+
store.shell.setCameraPose(
|
|
417
|
+
{ x: camera.x, y: camera.y, z: camera.z },
|
|
418
|
+
{ x: camera.targetX, y: camera.targetY, z: camera.targetZ },
|
|
419
|
+
camera.fov,
|
|
420
|
+
);
|
|
421
|
+
};
|
|
422
|
+
window.addEventListener('editor:collaboration-join', joinRequested);
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
reportCamera,
|
|
426
|
+
syncMarkers: (dtSeconds) => {
|
|
427
|
+
syncRemoteSpatialMarkers();
|
|
428
|
+
for (const marker of remoteCameraMarkers.values())
|
|
429
|
+
animateRemoteCameraMarker(marker, dtSeconds);
|
|
430
|
+
for (const marker of remoteSelectionMarkers.values()) {
|
|
431
|
+
contentWorldBounds(marker.object, marker.box);
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
live: () => remoteCameraMarkers.size + remoteSelectionMarkers.size + remoteRayMarkers.size > 0,
|
|
435
|
+
dispose: () => {
|
|
436
|
+
viewport.orbitControls.removeEventListener('change', reportCamera);
|
|
437
|
+
viewport.transformControls.removeEventListener('dragging-changed', reportTransformGesture);
|
|
438
|
+
container.removeEventListener('pointermove', reportPointerRay);
|
|
439
|
+
container.removeEventListener('pointerleave', clearPointerRay);
|
|
440
|
+
window.removeEventListener('editor:collaboration-join', joinRequested);
|
|
441
|
+
for (const marker of remoteCameraMarkers.values()) disposeRemoteCameraMarker(marker);
|
|
442
|
+
remoteCameraMarkers.clear();
|
|
443
|
+
for (const marker of remoteSelectionMarkers.values()) disposeRemoteSelectionMarker(marker);
|
|
444
|
+
remoteSelectionMarkers.clear();
|
|
445
|
+
for (const marker of remoteRayMarkers.values()) disposeRemoteRayMarker(marker);
|
|
446
|
+
remoteRayMarkers.clear();
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function createRemoteRayMarker(color: string): RemoteRayMarker {
|
|
452
|
+
const positions = new THREE.Float32BufferAttribute(new Float32Array(6), 3);
|
|
453
|
+
const geometry = new THREE.BufferGeometry();
|
|
454
|
+
geometry.setAttribute('position', positions);
|
|
455
|
+
const material = new THREE.LineBasicMaterial({
|
|
456
|
+
color: new THREE.Color(color),
|
|
457
|
+
depthTest: false,
|
|
458
|
+
transparent: true,
|
|
459
|
+
opacity: 0.9,
|
|
460
|
+
});
|
|
461
|
+
const line = new THREE.Line(geometry, material);
|
|
462
|
+
line.name = 'Collaboration pointer ray';
|
|
463
|
+
line.userData['editorOnly'] = true;
|
|
464
|
+
line.renderOrder = 10_001;
|
|
465
|
+
return { line, positions };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function updateRemoteRayMarker(
|
|
469
|
+
marker: RemoteRayMarker,
|
|
470
|
+
ray: { origin: THREE.Vector3; end: THREE.Vector3 },
|
|
471
|
+
): void {
|
|
472
|
+
marker.positions.setXYZ(0, ray.origin.x, ray.origin.y, ray.origin.z);
|
|
473
|
+
marker.positions.setXYZ(1, ray.end.x, ray.end.y, ray.end.z);
|
|
474
|
+
marker.positions.needsUpdate = true;
|
|
475
|
+
marker.line.geometry.computeBoundingSphere();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function disposeRemoteRayMarker(marker: RemoteRayMarker): void {
|
|
479
|
+
marker.line.removeFromParent();
|
|
480
|
+
marker.line.geometry.dispose();
|
|
481
|
+
(marker.line.material as THREE.Material).dispose();
|
|
482
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a three stage adds to its document's viewport
|
|
3
|
+
* (`@volter/editor-sdk/kit/document-viewports`): the transform-tool driver, read
|
|
4
|
+
* synchronously, and the shelf strip and header wells behind `lazy()`, so a
|
|
5
|
+
* bounded host that mounts a stage never loads the editor's toolbar.
|
|
6
|
+
*/
|
|
7
|
+
import { lazy } from 'react';
|
|
8
|
+
import { documentStageContext, type StageTransformDriver, stageTransformDriver } from '@volter/editor-sdk/kit/stage-context';
|
|
9
|
+
import { threeStoreForHost } from '../three-state';
|
|
10
|
+
|
|
11
|
+
/** What the host's transform tools drive on this document's stage. */
|
|
12
|
+
export function threeStageTransformDriver(documentId: string): StageTransformDriver {
|
|
13
|
+
const store = threeStoreForHost();
|
|
14
|
+
return store ? stageTransformDriver(documentStageContext(store.shell, documentId)) : 'none';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TransformTools = lazy(() =>
|
|
18
|
+
import('./stage-transform-tools').then((m) => ({ default: m.ThreeStageTransformTools })),
|
|
19
|
+
);
|
|
20
|
+
const TransformControls = lazy(() =>
|
|
21
|
+
import('./stage-transform-tools').then((m) => ({ default: m.ThreeStageTransformControls })),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export function threeStageTransformChrome(documentId: string) {
|
|
25
|
+
return {
|
|
26
|
+
transformTools: () => threeStageTransformDriver(documentId),
|
|
27
|
+
TransformTools,
|
|
28
|
+
TransformControls,
|
|
29
|
+
} as const;
|
|
30
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A THREE STAGE'S TRANSFORM TOOLS, as the stage contributes them to its
|
|
3
|
+
* document's chrome (`@volter/editor-sdk/kit/document-viewports`): what the
|
|
4
|
+
* host's transform tools drive on it (`stage-context.ts`'s one answer), the tool
|
|
5
|
+
* strip on its shelf and the gizmo's header wells. Every tool writes THIS
|
|
6
|
+
* stage's own store (`stage-store-registry.ts`); the world root's stage runs on
|
|
7
|
+
* the session store, so the Scene document is unchanged either way.
|
|
8
|
+
*/
|
|
9
|
+
import { stageTransformDoor } from '@volter/editor-sdk/contributions';
|
|
10
|
+
import { type ViewportInteraction, viewPresentation } from '@volter/editor-sdk/kit/viewport-presentation';
|
|
11
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
12
|
+
import { useEditorStore } from '@volter/editor-sdk/kit/editor-runtime';
|
|
13
|
+
import type { EditorShellStore } from '../editor-shell-store';
|
|
14
|
+
import { threeStageTransformDriver } from './stage-transform-chrome';
|
|
15
|
+
import { stageStore, stageStoresVersion, subscribeStageStores } from '@volter/editor-sdk/kit/stage-store-registry';
|
|
16
|
+
import { threeStateOf } from '../three-state';
|
|
17
|
+
import { ToolStrip, TransformHeaderControls } from '@volter/editor-sdk/kit/components/Toolbar';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* THE VIEWS WHOSE SHELF HAS ALREADY OPENED, per store — keyed by the VIEW as well, because
|
|
21
|
+
* several views can share one store (measured: a restored `main` armed the shared store, and
|
|
22
|
+
* HeroBox opened after it never got its own boot tool). A `WeakMap` because a store outlives
|
|
23
|
+
* nothing here.
|
|
24
|
+
*/
|
|
25
|
+
const shelfOpened = new WeakMap<EditorShellStore, Set<string>>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* THE SHELF'S BOOT TOOL IS THE STAGE'S FUNCTION (its view presentation's
|
|
29
|
+
* `interaction.bootTool`, ARCHITECTURE.md rule 7), applied the first time this stage's shelf
|
|
30
|
+
* draws. Blender's tool shelf opens on Select Box, so a selected object carries no transform
|
|
31
|
+
* gizmo until one is armed (the Blender stage's starting values); this editor's opens on the
|
|
32
|
+
* combined gizmo. A BOOT default and never a standing switch: once a view's shelf has opened,
|
|
33
|
+
* the map above keeps this from ever second-guessing the person in that view. Views that share
|
|
34
|
+
* one store share its tool, so a view opening for the first time sets the tool it opens on for
|
|
35
|
+
* them all — which is the stage the person just opened.
|
|
36
|
+
*/
|
|
37
|
+
function armShelfBootTool(store: EditorShellStore | null, documentId: string): void {
|
|
38
|
+
if (store === null) return;
|
|
39
|
+
const opened = shelfOpened.get(store) ?? new Set<string>();
|
|
40
|
+
shelfOpened.set(store, opened);
|
|
41
|
+
if (opened.has(documentId)) return;
|
|
42
|
+
opened.add(documentId);
|
|
43
|
+
const mode = {
|
|
44
|
+
select: 'select',
|
|
45
|
+
transform: 'combined',
|
|
46
|
+
move: 'translate',
|
|
47
|
+
rotate: 'rotate',
|
|
48
|
+
scale: 'scale',
|
|
49
|
+
} as const satisfies Record<ViewportInteraction['bootTool'], string>;
|
|
50
|
+
store.shell.setTransformMode(mode[viewPresentation(documentId).interaction.bootTool]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ThreeStageTransformTools({ documentId }: { readonly documentId: string }) {
|
|
54
|
+
useSyncExternalStore(subscribeStageStores, stageStoresVersion, stageStoresVersion);
|
|
55
|
+
const shell = useEditorStore();
|
|
56
|
+
const driver = threeStageTransformDriver(documentId);
|
|
57
|
+
const own = stageStore(documentId);
|
|
58
|
+
// In an effect rather than in the body because arming notifies the store, and
|
|
59
|
+
// a store notification during another component's render is React's own
|
|
60
|
+
// "cannot update while rendering" case.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (driver === 'gizmo') armShelfBootTool(own && threeStateOf(own), documentId);
|
|
63
|
+
}, [driver, own, documentId]);
|
|
64
|
+
if (driver === 'none') return null;
|
|
65
|
+
const door = driver === 'modal' ? stageTransformDoor(documentId) : null;
|
|
66
|
+
return <ToolStrip store={own ?? shell} {...(door ? { door } : {})} />;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function ThreeStageTransformControls({ documentId }: { readonly documentId: string }) {
|
|
70
|
+
useSyncExternalStore(subscribeStageStores, stageStoresVersion, stageStoresVersion);
|
|
71
|
+
const shell = useEditorStore();
|
|
72
|
+
return <TransformHeaderControls store={stageStore(documentId) ?? shell} />;
|
|
73
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE VIEW'S NAME as a target's own chrome words it (the look's `stage.chrome.viewName`; the
|
|
3
|
+
* forms are read from the reference frames, `docs/VIEWPORT-STAGE.md`):
|
|
4
|
+
*
|
|
5
|
+
* - `long`: Godot's "⋮ Perspective" pill and Unreal's "Perspective" pill. An orthographic view
|
|
6
|
+
* down an axis is named by the axis ("Top"), any other by its projection.
|
|
7
|
+
* - `short`: Unity's label under the scene gizmo, "Persp" or "Iso", and the axis in an
|
|
8
|
+
* orthographic view down one (`SceneViewOrthoTopAndSide.png`: "Top", "Right").
|
|
9
|
+
*
|
|
10
|
+
* Blender's view text ("User Perspective", "Front Orthographic") is `ViewportFurniture`'s own.
|
|
11
|
+
*/
|
|
12
|
+
import * as THREE from 'three';
|
|
13
|
+
import { axisViewName } from '../asset-workflow/model-inspection';
|
|
14
|
+
import type { EditorViewport } from '../editor-viewport';
|
|
15
|
+
|
|
16
|
+
export function stageViewName(
|
|
17
|
+
viewport: EditorViewport,
|
|
18
|
+
projection: 'perspective' | 'orthographic',
|
|
19
|
+
form: 'long' | 'short',
|
|
20
|
+
): string {
|
|
21
|
+
if (projection === 'orthographic') {
|
|
22
|
+
const axis = axisViewName(
|
|
23
|
+
viewport.camera.position.clone().sub(viewport.orbitControls.target),
|
|
24
|
+
new THREE.Vector3(0, 1, 0).applyQuaternion(viewport.camera.quaternion),
|
|
25
|
+
);
|
|
26
|
+
if (axis !== null) return axis;
|
|
27
|
+
return form === 'short' ? 'Iso' : 'Orthographic';
|
|
28
|
+
}
|
|
29
|
+
return form === 'short' ? 'Persp' : 'Perspective';
|
|
30
|
+
}
|