@volter/editor-threejs 0.5.66 → 0.5.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/contributions/animation-timeline.utility.tsx +44 -0
  2. package/contributions/three-integration.service.ts +13 -0
  3. package/package.json +96 -6
  4. package/src/adapter/renderer-config.ts +3 -4
  5. package/src/adapter/three-contract.ts +72 -0
  6. package/src/ecs/object-marks.ts +1 -1
  7. package/src/ecs/user-data.ts +0 -9
  8. package/src/host-hierarchy-objects.ts +31 -0
  9. package/src/kit/animation/three-clips-subject.ts +190 -0
  10. package/src/kit/asset-compare.ts +294 -0
  11. package/src/kit/asset-preview-command.ts +265 -0
  12. package/src/kit/asset-preview-framing.ts +357 -0
  13. package/src/kit/asset-preview.ts +2802 -0
  14. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  15. package/src/kit/authoring/component-instance-root.ts +171 -0
  16. package/src/kit/authoring/design-time-settle.ts +343 -0
  17. package/src/kit/authoring/live-object-transform.ts +62 -0
  18. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  19. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  20. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  21. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  22. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  23. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  24. package/src/kit/authoring/three-projection-core.ts +226 -0
  25. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  26. package/src/kit/authoring/viewport-raycast.ts +240 -0
  27. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  28. package/src/kit/camera-authoring.ts +175 -0
  29. package/src/kit/components/CameraInfo.tsx +56 -0
  30. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  31. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  32. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  33. package/src/kit/components/StageHost.tsx +2547 -0
  34. package/src/kit/components/StageOverlays.tsx +21 -0
  35. package/src/kit/components/StatsOverlay.tsx +78 -0
  36. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  37. package/src/kit/components/ViewportFurniture.tsx +655 -0
  38. package/src/kit/components/ViewportOverlay.tsx +215 -0
  39. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  40. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  41. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  42. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  43. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  44. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  45. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  46. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  47. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  48. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  49. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  50. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  51. package/src/kit/components/stage-keyboard.tsx +40 -0
  52. package/src/kit/components/stage-overlay-set.tsx +105 -0
  53. package/src/kit/components/stage-presence-markers.ts +482 -0
  54. package/src/kit/components/stage-transform-chrome.ts +30 -0
  55. package/src/kit/components/stage-transform-tools.tsx +73 -0
  56. package/src/kit/components/stage-view-name.ts +30 -0
  57. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  58. package/src/kit/components/world-root-binding.ts +64 -0
  59. package/src/kit/constraint-helper.ts +338 -0
  60. package/src/kit/editor-shell-store.ts +814 -0
  61. package/src/kit/editor-viewport.ts +6621 -0
  62. package/src/kit/entity-lod.ts +31 -0
  63. package/src/kit/entity-object.ts +92 -0
  64. package/src/kit/hierarchy-mark-reader.ts +74 -0
  65. package/src/kit/instanced-presentation.ts +164 -0
  66. package/src/kit/live-module-source.ts +230 -0
  67. package/src/kit/model-thumbnail.ts +539 -0
  68. package/src/kit/play-camera-flight.ts +300 -0
  69. package/src/kit/projection/three.ts +898 -0
  70. package/src/kit/reflection-probe-helper.ts +142 -0
  71. package/src/kit/scene-document-viewport.ts +51 -0
  72. package/src/kit/scene-framing.ts +315 -0
  73. package/src/kit/scene-view-fog.ts +89 -0
  74. package/src/kit/spatial-handle-visuals.ts +332 -0
  75. package/src/kit/stories/three-story-model.ts +66 -0
  76. package/src/kit/three-canvas-render.ts +44 -0
  77. package/src/kit/three-hierarchy-row-media.ts +26 -0
  78. package/src/kit/three-inspection-media.ts +73 -0
  79. package/src/kit/three-integration.ts +86 -0
  80. package/src/kit/three-state.ts +33 -0
  81. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  82. package/src/kit/three-viewport/camera-fit.ts +41 -0
  83. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  84. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  85. package/src/kit/three-viewport/selection-outline.ts +333 -0
  86. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  87. package/src/kit/three-viewport/source-color.ts +197 -0
  88. package/src/kit/three-viewport/studio-environment.ts +96 -0
  89. package/src/kit/trigger-volume-helper.ts +116 -0
  90. package/src/kit/viewport-actions.ts +128 -0
  91. package/src/kit/viewport-authoring-policy.ts +154 -0
  92. package/src/kit/viewport-commands.ts +318 -0
  93. package/src/kit/viewport-hotkeys.ts +119 -0
  94. package/src/kit/viewport-shading-boundary.ts +12 -0
  95. package/src/kit/viewport-status-facet.ts +53 -0
  96. package/src/object3d-contributions.ts +494 -0
  97. package/src/render/viewport-shading.ts +6 -2
  98. package/src/viewport-api.ts +92 -0
  99. package/src/viewport-door.ts +237 -0
@@ -0,0 +1,655 @@
1
+ /**
2
+ * THE VIEWPORT'S FURNITURE — what every 3D area carries besides its content
3
+ * (Blender's 3D Viewport: the view text at the top-left, "User Perspective"
4
+ * over the collection and active object; the navigation cluster under the
5
+ * axis gizmo at the top-right — zoom, pan, frame, projection). Drawn by the
6
+ * 3D document host (`StageHost`) over its stage for every
7
+ * document with chrome; nothing here holds state of its own. Hidden while the
8
+ * host has no viewport yet.
9
+ *
10
+ * WHICH OBJECT A BUTTON DRIVES IS THE QUESTION TO ASK BEFORE ADDING ONE.
11
+ * `StageHost` is this component's only caller and every stage it hosts is
12
+ * painted by `Object3DDocumentSession.renderViewport` — through
13
+ * `session.camera()`, not through `viewport.renderCamera`. So a verb whose
14
+ * effect is the CAMERA'S POSE reaches the picture through the viewport (the
15
+ * session's cameras are derived from that pose every frame), while a verb
16
+ * that changes WHICH camera paints must go to the session. The projection
17
+ * button got that wrong and was inert everywhere it was drawn; see its own
18
+ * comment below for the measurement, and do not route a new control to
19
+ * `viewport.*` without checking which side of that line it falls on.
20
+ */
21
+ import type { ToolCameraView, ToolViewportStatistic } from '../../object3d-contributions';
22
+ import {
23
+ EditorIcon,
24
+ editorIcons,
25
+ IconButton,
26
+ spaceVar,
27
+ Tooltip,
28
+ themeVars,
29
+ } from '@volter/editor-sdk/widgets';
30
+ import { Fragment, type PointerEvent as ReactPointerEvent, useCallback, useEffect, useState, useSyncExternalStore } from 'react';
31
+ import * as THREE from 'three';
32
+ import { axisViewName } from '../asset-workflow/model-inspection';
33
+ import type { Object3DDocumentSession } from '../authoring/object3d-document-session';
34
+ import type { ShellStore } from '@volter/editor-sdk/kit/shell-store';
35
+ import {
36
+ COMPASS_CENTER_RIGHT_PX,
37
+ COMPASS_CLUSTER_TOP_PX,
38
+ COMPASS_INK_BOTTOM_PX,
39
+ type EditorViewport,
40
+ } from '../editor-viewport';
41
+ import { faBars, faChevronLeft } from '@fortawesome/free-solid-svg-icons';
42
+ import { stageViewName } from './stage-view-name';
43
+ import {
44
+ subscribeViewportPresentation,
45
+ viewPresentation,
46
+ viewportPresentationVersion,
47
+ } from '@volter/editor-sdk/kit/viewport-presentation';
48
+ import { ViewportViewMenu } from './ViewportViewMenu';
49
+ import {
50
+ lookDeclaresViewportColors,
51
+ lookPaintsLightViewport,
52
+ subscribeNativeSelectionTheme,
53
+ useViewportChrome,
54
+ } from '@volter/editor-sdk/kit/native-selection-style';
55
+ import type { ThreeViewportProjection } from '@volter/editor-sdk/kit/three-viewport-presentation';
56
+
57
+ /** The cluster sits 8px under the compass — one number, owned by the
58
+ * compass's own box (`editor-viewport.ts`, measured against Blender's
59
+ * navigation gizmo), never restated here. */
60
+ const CLUSTER_TOP = COMPASS_CLUSTER_TOP_PX;
61
+
62
+ /** Module-level for `useSyncExternalStore`'s stable identity; the theme root
63
+ * is global, so no stage's element narrows it (same shape as `StageHost`'s
64
+ * own subscription to the viewport group). */
65
+ function subscribeThemeViewportGroup(onChange: () => void): () => void {
66
+ return subscribeNativeSelectionTheme(null, onChange);
67
+ }
68
+
69
+ /** Stable no-session stand-ins, so the subscription hook keeps one identity
70
+ * across renders of a document that has no session. */
71
+ const NO_SESSION_SUBSCRIBE = (): (() => void) => () => {};
72
+ const NO_SESSION_SNAPSHOT = (): number => 0;
73
+
74
+ /**
75
+ * THE STATISTICS BLOCK'S GEOMETRY, measured on `sculpting.png` — the ONE
76
+ * reference frame that photographs the overlay's counts (every other frame
77
+ * has the Statistics overlay off; `modeling-front-ortho.png`'s third line is
78
+ * the ortho grid's unit, not a count). Device px at native 2x, CSS halved:
79
+ *
80
+ * `User Perspective` ink y 181..201, x 138..306 (baseline ~196.5)
81
+ * `(1) Cube | Cube` ink y 211..237, x 139..300 (baseline ~231.5)
82
+ * `Vertices` `8` ink y 273..288, label x 136..216, value x 252..262
83
+ * `Faces` `6` ink y 308..323, label x 138..194, value x 252..261
84
+ *
85
+ * Three facts that become the two constants below. The line step INSIDE a
86
+ * block is 35 device (17.5 CSS) — the same step the two identity lines use.
87
+ * The step from the subject line to `Vertices` is 56.5 device, so the stats
88
+ * block stands one extra 21.5 device (10.75 CSS) off the lines above it;
89
+ * `space-5` is 10, the nearest rung, and the scale has nothing at 11.
90
+ * And the VALUE is a COLUMN, not a space: both rows start their number at
91
+ * exactly x 252 device while their labels end at 216 and 194, so the label
92
+ * cell is a fixed 115 device / 57.5 CSS measured from the block's own left
93
+ * edge (x 137) — 58 at the rounding the rest of this file uses.
94
+ */
95
+ const STATISTICS_BLOCK_OFFSET = 5 as const;
96
+ /** See {@link STATISTICS_BLOCK_OFFSET}: Blender's value column, CSS px from
97
+ * the overlay block's left edge. */
98
+ const STATISTICS_LABEL_COLUMN_PX = 58;
99
+
100
+ /** What the overlay lines read off the camera, so a move that changes one re-renders them. */
101
+ function cameraSignature(viewport: EditorViewport, session: Object3DDocumentSession | null): string {
102
+ const camera = session?.camera() ?? viewport.renderCamera;
103
+ const position = viewport.camera.position;
104
+ const target = viewport.orbitControls.target;
105
+ const zoom = (camera as THREE.OrthographicCamera).isOrthographicCamera
106
+ ? orthographicWorldPerDevicePixel(camera as THREE.OrthographicCamera, viewport, session)
107
+ : 0;
108
+ return [position.x, position.y, position.z, target.x, target.y, target.z, zoom].map((n) => n.toPrecision(6)).join(',');
109
+ }
110
+
111
+ /** World units across one device pixel of an orthographic view: its height over the drawing
112
+ * buffer's (the session's canvas, in device pixels), else the controls' element at the
113
+ * display's ratio. */
114
+ function orthographicWorldPerDevicePixel(
115
+ camera: THREE.OrthographicCamera,
116
+ viewport: EditorViewport,
117
+ session: Object3DDocumentSession | null,
118
+ ): number {
119
+ const element = viewport.orbitControls.domElement;
120
+ const height = Math.max(
121
+ 1,
122
+ session?.renderer.domElement.height ??
123
+ (element?.clientHeight ?? 1) * (element?.ownerDocument.defaultView?.devicePixelRatio ?? 1),
124
+ );
125
+ return (camera.top - camera.bottom) / camera.zoom / height;
126
+ }
127
+
128
+ export interface ViewportFurnitureProps {
129
+ readonly viewport: EditorViewport | null;
130
+ /** The stage's document, whose view menu the look's view-name pill opens. */
131
+ readonly documentId: string;
132
+ readonly session: Object3DDocumentSession | null;
133
+ readonly store: ShellStore;
134
+ readonly projection: ThreeViewportProjection;
135
+ readonly displayName: string;
136
+ readonly objectName: (id: string) => string | null;
137
+ /** The active document's own counts. See `ToolObject3DAuthoringProps.statistics`. */
138
+ readonly statistics?: readonly ToolViewportStatistic[];
139
+ /** The document's own subject line. See `ToolObject3DAuthoringProps.subject`. */
140
+ readonly subject?: string;
141
+ /** The document's grid-step name. See `ToolObject3DAuthoringProps.gridScale`. */
142
+ readonly gridScale?: (worldPerDevicePixel: number) => string | null;
143
+ }
144
+
145
+ export function ViewportFurniture({
146
+ viewport,
147
+ documentId,
148
+ session,
149
+ store,
150
+ projection,
151
+ displayName,
152
+ objectName,
153
+ statistics,
154
+ subject: documentSubject,
155
+ gridScale,
156
+ }: ViewportFurnitureProps) {
157
+ useSyncExternalStore(store.subscribe, store.getShellSnapshot ?? store.getSnapshot);
158
+ // THE LINES FOLLOW THE CAMERA: the view text names the axis the view looks down and the grid
159
+ // line names the step at the current zoom, and neither is a store or session change.
160
+ useSyncExternalStore(
161
+ useCallback(
162
+ (listener: () => void) => {
163
+ const controls = viewport?.orbitControls;
164
+ controls?.addEventListener('change', listener);
165
+ return () => controls?.removeEventListener('change', listener);
166
+ },
167
+ [viewport],
168
+ ),
169
+ () => (viewport ? cameraSignature(viewport, session) : ''),
170
+ );
171
+ // The VIEW TEXT must follow the projection the stage is actually drawing
172
+ // with. A document session carries its own (`session.camera()` returns its
173
+ // orthographic camera off it), and the viewport's flag says nothing about
174
+ // it — so the text read "User Perspective" over an orthographic stage the
175
+ // moment the document toolbar's own projection menu was used.
176
+ useSyncExternalStore(
177
+ session?.subscribe ?? NO_SESSION_SUBSCRIBE,
178
+ session?.getSnapshot ?? NO_SESSION_SNAPSHOT,
179
+ session?.getSnapshot ?? NO_SESSION_SNAPSHOT,
180
+ );
181
+ // THE VIEW TEXT IS THE BRIGHTEST THING IN BLENDER'S FRAME, and it is the one
182
+ // place Blender goes to pure white: measured on `modeling-edit-none.png` at
183
+ // native 2x, both overlay lines plateau at 255 (240 px at 255 against 296 at
184
+ // its AA neighbour 253) where its panel text inks 229. Ours drew
185
+ // `content.primary` — one step back, over the busiest backdrop in the
186
+ // window.
187
+ //
188
+ // `content.onAccent` IS THE MEMBER THAT HOLDS 255, and its NAME is
189
+ // misleading for this role: Blender spends that ink mostly NOT on an accent.
190
+ // DO NOT RENAME IT — it is a pre-v3 core key in all three palette lists and
191
+ // every shipped palette document names it, so a rename breaks every skin on
192
+ // disk to make a docblock read better. This comment is the correction.
193
+ //
194
+ // AND IT IS GATED, because the member's real role is "ink that must read
195
+ // over a fill the PALETTE chose". Measured: Classic's `content.onAccent` is
196
+ // #101820 — near-black, correct against its light accent chip and ruinous
197
+ // over a mid-grey viewport. The gate is the predicate that already decides
198
+ // every other viewport paint question (`lookDeclaresViewportColors`, the
199
+ // same one the grid's distance fade reads): a look that declares what the
200
+ // viewport is painted has answered for both values, and a look that leaves
201
+ // the backdrop to the editor keeps the ink it had. Classic is bit-identical.
202
+ const lookPaintsViewport = useSyncExternalStore(
203
+ subscribeThemeViewportGroup,
204
+ lookDeclaresViewportColors,
205
+ );
206
+ // A LIGHT declared viewport (a paper ground) is the one case the on-accent ink cannot serve:
207
+ // there it is the ground's own colour, so the ordinary ink reads instead.
208
+ const lookPaintsLight = useSyncExternalStore(subscribeThemeViewportGroup, lookPaintsLightViewport);
209
+ const overlayInk = lookPaintsViewport && !lookPaintsLight ? themeVars.content.onAccent : themeVars.content.primary;
210
+ // WHICH OF THIS FURNITURE THE TARGET DRAWS is the look's (`stage.chrome`): the view text is
211
+ // Blender's, the zoom and pan cluster Blender's alone; Godot names the view in a pill that
212
+ // opens the view menu, Unity under its scene gizmo.
213
+ const chrome = useViewportChrome();
214
+ // Whether the zoom and pan buttons are drawn at all is the VIEW's (`overlays.navigationControls`).
215
+ useSyncExternalStore(subscribeViewportPresentation, viewportPresentationVersion, viewportPresentationVersion);
216
+ const navigationControls = viewPresentation(documentId).overlays.navigationControls;
217
+ if (!viewport) return null;
218
+ // Blender's view text names the DIRECTION as well as the projection —
219
+ // "Front Orthographic" on numpad 1 (`modeling-front-ortho.png`), "User
220
+ // Perspective" the moment the view is orbited off that axis. Derived from
221
+ // the live camera, so it reverts on the first drag the way Blender's does.
222
+ const axis =
223
+ axisViewName(
224
+ viewport.camera.position.clone().sub(viewport.orbitControls.target),
225
+ new THREE.Vector3(0, 1, 0).applyQuaternion(viewport.camera.quaternion),
226
+ ) ?? 'User';
227
+ const drawn = session?.projection() ?? projection;
228
+ // A CAMERA VIEW names itself as Blender's does: "Camera Perspective" / "Camera Orthographic",
229
+ // after the camera's own projection.
230
+ const through = session?.cameraView() ?? null;
231
+ const viewText = through
232
+ ? `Camera ${through.projection === 'perspective' ? 'Perspective' : 'Orthographic'}`
233
+ : `${axis} ${drawn === 'perspective' ? 'Perspective' : 'Orthographic'}`;
234
+ // THE SUBJECT LINE. Blender's is `(frame) <active collection> | <active
235
+ // object>` — THREE parts, and which part is which was settled by CONTRAST
236
+ // across the frames, never from one of them. `modeling-object-none.png`,
237
+ // `modeling-object-selected.png` and `layout.png` all read
238
+ // `(1) Collection | Cube`; `sculpting.png` reads `(1) Cube | Cube` only
239
+ // because THAT file's collection is itself named Cube. So the leading part
240
+ // is a CONTAINER and the trailing part is the ACTIVE OBJECT — neither is
241
+ // the mesh datablock, which Blender never puts on this line at all.
242
+ //
243
+ // We have one of Blender's three. There is no frame (no timeline) and no
244
+ // collection; the DOCUMENT is the only container an object on this stage
245
+ // belongs to, so it takes the leading slot — a world-root stage names a
246
+ // scene there and the selection names an object inside it.
247
+ //
248
+ // AND THE CONTAINER SLOT IS DROPPED WHEN IT IS NOT A CONTAINER. A Model
249
+ // document names its own subject, so both slots resolve to ONE string from
250
+ // ONE
251
+ // source and the overlay rendered `cube | cube` — one entity printed
252
+ // twice, which is not Blender's pair. Measured live on a cold models build
253
+ // before this change. Blender would happily print two identical words
254
+ // there (sculpting.png does); ours would be printing one thing twice, so
255
+ // it says that one thing once.
256
+ //
257
+ // THE TRAILING PART IS THE ACTIVE OBJECT, not the oldest selected one.
258
+ // `store.selectedEntityId` is the store's own active id — `[...selection]
259
+ // .at(-1)`, what the transform gizmo binds to and what the Outliner marks
260
+ // as its active row. This read `[...selectedEntityIds][0]`, the OLDEST id,
261
+ // which is the same defect `inspector-selection.ts`'s docblock records
262
+ // fixing for the inspector on 2026-09-18 (click A, ctrl-click B, and the
263
+ // gizmo moved to B while the inspector kept showing A). Back-applied here,
264
+ // the third reader of that selection. A Model document holds one entity,
265
+ // so this half is not visible in its frame; it shows on any stage where
266
+ // two objects can be selected at once.
267
+ const activeId = store.selectedEntityId;
268
+ const activeName = (activeId === null ? null : objectName(activeId)) || null;
269
+ const subject =
270
+ documentSubject ??
271
+ (activeName === null || activeName === displayName ? displayName : `${displayName} | ${activeName}`);
272
+ // THE GRID'S STEP, where Blender names it: an orthographic view down an axis
273
+ // (`draw_grid_unit_name`, `!rv3d->is_persp && RV3D_VIEW_IS_AXIS`). What the step is called
274
+ // is the document's; the host hands it the world units one device pixel spans.
275
+ const drawnCamera = session?.camera() ?? viewport.renderCamera;
276
+ const gridLine =
277
+ gridScale && !through && axis !== 'User' && (drawnCamera as THREE.OrthographicCamera).isOrthographicCamera
278
+ ? gridScale(orthographicWorldPerDevicePixel(drawnCamera as THREE.OrthographicCamera, viewport, session))
279
+ : null;
280
+
281
+ // BLENDER'S MAGNIFIER IS A DRAG (`view3d.zoom` from the navigation gizmo, the factory
282
+ // `USER_ZOOM_DOLLY` style, `viewzoom_scale_value`): with `len` the pointer's height below the
283
+ // region's top plus 5, the distance is the one the drag started at times
284
+ // `2 * (len / len0 - 1) + 1` — down backs away, up closes in.
285
+ const startZoom = (event: ReactPointerEvent<HTMLButtonElement>): void => {
286
+ event.preventDefault();
287
+ const element = event.currentTarget;
288
+ element.setPointerCapture(event.pointerId);
289
+ const regionTop = viewport.orbitControls.domElement?.getBoundingClientRect().top ?? 0;
290
+ const lenOld = Math.max(5 + event.clientY - regionTop, 1);
291
+ const camera = viewport.camera;
292
+ const target = viewport.orbitControls.target;
293
+ const offset = camera.position.clone().sub(target);
294
+ const ortho = !session && viewport.renderCamera instanceof THREE.OrthographicCamera ? viewport.renderCamera : null;
295
+ const zoom0 = ortho?.zoom ?? 1;
296
+ // In a camera view the same drag zooms the camera's frame (`view_zoom_to_window_xy_camera`).
297
+ const frameZoom0 = session?.cameraViewZoom() ?? null;
298
+ const move = (moveEvent: PointerEvent): void => {
299
+ const lenNew = 5 + moveEvent.clientY - regionTop;
300
+ const factor = Math.max(0.01, 2 * (lenNew / lenOld - 1) + 1);
301
+ if (frameZoom0 !== null && session?.cameraView()) {
302
+ session.setCameraViewZoom(frameZoom0 / factor);
303
+ } else if (ortho) {
304
+ ortho.zoom = zoom0 / factor;
305
+ ortho.updateProjectionMatrix();
306
+ } else camera.position.copy(target).addScaledVector(offset, factor);
307
+ viewport.orbitControls.update();
308
+ };
309
+ const end = (): void => {
310
+ element.removeEventListener('pointermove', move);
311
+ element.removeEventListener('pointerup', end);
312
+ element.removeEventListener('pointercancel', end);
313
+ element.removeEventListener('lostpointercapture', end);
314
+ };
315
+ element.addEventListener('pointermove', move);
316
+ element.addEventListener('pointerup', end);
317
+ element.addEventListener('pointercancel', end);
318
+ element.addEventListener('lostpointercapture', end);
319
+ };
320
+
321
+ const startPan = (event: ReactPointerEvent<HTMLButtonElement>): void => {
322
+ event.preventDefault();
323
+ const element = event.currentTarget;
324
+ element.setPointerCapture(event.pointerId);
325
+ let lastX = event.clientX;
326
+ let lastY = event.clientY;
327
+ const right = new THREE.Vector3();
328
+ const up = new THREE.Vector3();
329
+ const move = (moveEvent: PointerEvent): void => {
330
+ const dx = moveEvent.clientX - lastX;
331
+ const dy = moveEvent.clientY - lastY;
332
+ lastX = moveEvent.clientX;
333
+ lastY = moveEvent.clientY;
334
+ // In a camera view a pan moves the camera's frame with the pointer (`view_move`).
335
+ if (session?.cameraView()) {
336
+ const region = session.renderer.domElement;
337
+ session.panCameraView(dx / Math.max(region.clientWidth, 1), dy / Math.max(region.clientHeight, 1));
338
+ return;
339
+ }
340
+ const camera = viewport.camera;
341
+ const target = viewport.orbitControls.target;
342
+ const distance = camera.position.distanceTo(target);
343
+ const height = Math.max(1, element.ownerDocument.defaultView?.innerHeight ?? 1);
344
+ const worldPerPixel =
345
+ (2 * distance * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2))) / height;
346
+ right.setFromMatrixColumn(camera.matrixWorld, 0).multiplyScalar(-dx * worldPerPixel);
347
+ up.setFromMatrixColumn(camera.matrixWorld, 1).multiplyScalar(dy * worldPerPixel);
348
+ camera.position.add(right).add(up);
349
+ target.add(right).add(up);
350
+ viewport.orbitControls.update();
351
+ };
352
+ const end = (): void => {
353
+ element.removeEventListener('pointermove', move);
354
+ element.removeEventListener('pointerup', end);
355
+ element.removeEventListener('pointercancel', end);
356
+ element.removeEventListener('lostpointercapture', end);
357
+ };
358
+ element.addEventListener('pointermove', move);
359
+ element.addEventListener('pointerup', end);
360
+ element.addEventListener('pointercancel', end);
361
+ element.addEventListener('lostpointercapture', end);
362
+ };
363
+
364
+ return (
365
+ <>
366
+ {chrome.viewName === 'text' ? (
367
+ <div
368
+ data-testid="viewport-view-text"
369
+ aria-hidden="true"
370
+ style={{
371
+ position: 'absolute',
372
+ top: 'var(--vgai-space-3)',
373
+ // Clear of the tool rail at the stage's left edge (Blender's text
374
+ // starts past its toolbar): the rail is one control wide plus its
375
+ // own inset.
376
+ left: 'calc(var(--vgai-space-4) + var(--vgai-control-comfortable-height) * 2 + var(--vgai-space-4))',
377
+ display: 'flex',
378
+ flexDirection: 'column',
379
+ gap: 'var(--vgai-space-1)',
380
+ fontSize: 'var(--vgai-font-sm)',
381
+ color: overlayInk,
382
+ textShadow: 'var(--vgai-content-text-shadow, none)',
383
+ pointerEvents: 'none',
384
+ userSelect: 'none',
385
+ }}
386
+ >
387
+ <span>{viewText}</span>
388
+ <span>{subject}</span>
389
+ {gridLine ? <span data-testid="viewport-grid-scale">{gridLine}</span> : null}
390
+ {statistics && statistics.length > 0 ? (
391
+ <div
392
+ data-testid="viewport-statistics"
393
+ style={{
394
+ display: 'grid',
395
+ gridTemplateColumns: `${STATISTICS_LABEL_COLUMN_PX}px auto`,
396
+ // The column's own rows keep the block's line step; the block
397
+ // itself stands off the lines above it. See
398
+ // `STATISTICS_BLOCK_OFFSET`.
399
+ rowGap: 'var(--vgai-space-1)',
400
+ marginTop: spaceVar[STATISTICS_BLOCK_OFFSET],
401
+ }}
402
+ >
403
+ {statistics.map((statistic) => (
404
+ <Fragment key={statistic.id}>
405
+ <span>{statistic.label}</span>
406
+ <span>{statistic.value}</span>
407
+ </Fragment>
408
+ ))}
409
+ </div>
410
+ ) : null}
411
+ </div>
412
+ ) : null}
413
+ {chrome.viewName === 'menu' ? (
414
+ <div
415
+ className="vgai-viewport-view-pill"
416
+ style={{
417
+ position: 'absolute',
418
+ top: 'var(--vgai-viewport-overlay-top, var(--vgai-space-4))',
419
+ // Past the shelf rail when it draws anything, as the view text is; at the edge when
420
+ // it is empty (`data-vgai-stage-rail`, `workspace-surfaces.css`).
421
+ left:
422
+ 'var(--vgai-stage-name-left, calc(var(--vgai-space-4) + var(--vgai-control-comfortable-height) * 2 + var(--vgai-space-4)))',
423
+ zIndex: 'calc(var(--vgai-z-dropdown, 1000) - 1)',
424
+ pointerEvents: 'auto',
425
+ }}
426
+ >
427
+ <ViewportViewMenu shell={store} documentId={documentId} label={stageViewName(viewport, drawn, 'long')} kebab />
428
+ </div>
429
+ ) : null}
430
+ {chrome.viewName === 'gizmo' ? (
431
+ // UNITY'S LABEL UNDER THE SCENE GIZMO (`Editor-SceneGizmo.png`): the projection's mark and
432
+ // its name, and a click toggles the projection, as Unity's does.
433
+ <button
434
+ type="button"
435
+ data-testid="viewport-view-name"
436
+ className="vgai-viewport-gizmo-label"
437
+ aria-label={drawn === 'perspective' ? 'Switch to orthographic' : 'Switch to perspective'}
438
+ onClick={() => {
439
+ const next = drawn === 'perspective' ? 'orthographic' : 'perspective';
440
+ if (session) session.setProjection(next);
441
+ else viewport.setProjection(next);
442
+ }}
443
+ style={{
444
+ position: 'absolute',
445
+ top: COMPASS_INK_BOTTOM_PX + 4,
446
+ right: COMPASS_CENTER_RIGHT_PX,
447
+ transform: 'translateX(50%)',
448
+ display: 'inline-flex',
449
+ alignItems: 'center',
450
+ gap: 'var(--vgai-space-1)',
451
+ padding: 0,
452
+ border: 0,
453
+ background: 'none',
454
+ fontSize: 'var(--vgai-font-sm)',
455
+ color: overlayInk,
456
+ textShadow: 'var(--vgai-content-text-shadow, none)',
457
+ cursor: 'pointer',
458
+ pointerEvents: 'auto',
459
+ }}
460
+ >
461
+ <EditorIcon size="xs" icon={drawn === 'perspective' ? faChevronLeft : faBars} />
462
+ {stageViewName(viewport, drawn, 'short')}
463
+ </button>
464
+ ) : null}
465
+ {/* BLENDER'S NAVIGATION CLUSTER (`view3d_gizmo_navigate.cc`): Zoom and Pan, each a drag;
466
+ Camera; and the projection toggle, whose mark is the projection the view has. Cell,
467
+ gap, pitch and capsule width are Blender's (`modeling-edit-none.png`: glyph boxes 16
468
+ CSS, pitch 30, a 28 CSS capsule). Framing is not here: its home is the document
469
+ header's view control and the Home and numpad-period keys, as Blender's is its View
470
+ menu. The Camera button is not drawn yet: looking through a scene camera is not a
471
+ view this stage has. */}
472
+ {navigationControls ? (
473
+ <div
474
+ data-testid="viewport-navigation"
475
+ role="toolbar"
476
+ aria-label="Navigation"
477
+ aria-orientation="vertical"
478
+ className="vgai-chrome-island vgai-viewport-navigation"
479
+ style={{
480
+ position: 'absolute',
481
+ top: CLUSTER_TOP,
482
+ // 12, not 8: PAINTED EDGE to PAINTED EDGE is how the reference was
483
+ // read, and this panel's rect runs ~3 CSS past the last pixel the
484
+ // viewport paints (the dock's sash). At 8 the gap measured 5.0 CSS
485
+ // against Blender's 9.0 (`modeling-edit-none.png`: the capsule's
486
+ // last column is device 2817 and the area's dark boundary starts at
487
+ // 2836); at 12 it is 9.0.
488
+ // UNGATED, like every other number this cluster carries: its 28 and
489
+ // 16 are Blender's for every skin already (`theme.css`'s
490
+ // `.vgai-viewport-navigation` block, no palette selector), and the
491
+ // estate's `css-style-identity-selector` gate forbids one anyway.
492
+ right: 'var(--vgai-space-6)',
493
+ display: 'flex',
494
+ flexDirection: 'column',
495
+ gap: 'var(--vgai-space-1)',
496
+ // Blender's capsule is exactly as wide as its buttons: it insets
497
+ // only along the column.
498
+ padding: 'var(--vgai-space-1) 0',
499
+ borderRadius: 'var(--vgai-radius-full)',
500
+ background: 'var(--vgai-island-surface, var(--vgai-surface-overlay))',
501
+ pointerEvents: 'auto',
502
+ }}
503
+ >
504
+ <Tooltip text="Zoom (drag)">
505
+ <IconButton size="comfortable" aria-label="Zoom the view" onPointerDown={startZoom}>
506
+ <EditorIcon size="2xl" icon={editorIcons.viewport.zoomIn} />
507
+ </IconButton>
508
+ </Tooltip>
509
+ <Tooltip text="Pan (drag)">
510
+ <IconButton size="comfortable" aria-label="Pan the view" onPointerDown={startPan}>
511
+ <EditorIcon size="2xl" icon={editorIcons.viewport.pan} />
512
+ </IconButton>
513
+ </Tooltip>
514
+ {/* THE PROJECTION THIS BUTTON MEANS IS THE STAGE'S, NOT THE
515
+ VIEWPORT'S. It drove `viewport.setProjection` and was INERT
516
+ wherever this cluster is drawn — `StageHost` is its only caller,
517
+ every stage it hosts is painted by
518
+ `Object3DDocumentSession.renderViewport` through `session.camera()`,
519
+ and that camera reads the SESSION's own `projection` (the comment
520
+ on the view text above already said so). Measured 2026-09-19 in a
521
+ cold models scaffold: the click flipped `aria-pressed` and left the
522
+ frame hash byte-identical, while the document header's Camera view
523
+ menu — `session.setProjection`, the same intent one row up —
524
+ changed both the picture and the view text. It was worse than
525
+ inert: `dolly` below branches on `viewport.renderCamera`, so while
526
+ this button was "pressed" Zoom in and Zoom out went dead too, and
527
+ flipping back nudged the camera as `_syncPerspectiveFromOrthographic`
528
+ copied the phantom pose home. Transcribed from the sibling door
529
+ (`Object3DDocumentToolbar`), with the viewport kept as the answer
530
+ for a stage that has no session. */}
531
+ {session?.hasCameraView() ? (
532
+ <Tooltip text={through ? 'Leave the camera view' : 'Look through the camera'}>
533
+ <IconButton size="comfortable" aria-label="Toggle the camera view" onClick={() => session.toggleCameraView()}>
534
+ {/* Blender's `VIEW_CAMERA_UNSELECTED` out of the camera view, `VIEW_CAMERA` in it. */}
535
+ <EditorIcon size="2xl" icon={through ? editorIcons.viewport.cameraView : editorIcons.viewport.camera} />
536
+ </IconButton>
537
+ </Tooltip>
538
+ ) : null}
539
+ {/* In a camera view, the lock that makes navigating it move the camera
540
+ (`View3D.lock_camera`): `VIEW_LOCKED` while it holds, `VIEW_UNLOCKED` otherwise. */}
541
+ {through && session?.cameraViewLocked() !== null ? (
542
+ <Tooltip text={session?.cameraViewLocked() ? 'Unlock the camera from the view' : 'Lock the camera to the view'}>
543
+ <IconButton size="comfortable" aria-label="Lock the camera to the view" onClick={() => session?.toggleCameraViewLock()}>
544
+ <EditorIcon
545
+ size="2xl"
546
+ icon={session?.cameraViewLocked() ? editorIcons.viewport.cameraLocked : editorIcons.viewport.cameraUnlocked}
547
+ />
548
+ </IconButton>
549
+ </Tooltip>
550
+ ) : null}
551
+ {/* A camera view has its camera's projection, so the toggle stands down in it; and where
552
+ the look puts the view's name under the gizmo, that label is the toggle. */}
553
+ {through || chrome.viewName === 'gizmo' ? null : (
554
+ <Tooltip text={drawn === 'perspective' ? 'Orthographic' : 'Perspective'}>
555
+ <IconButton
556
+ size="comfortable"
557
+ aria-label="Toggle perspective and orthographic"
558
+ onClick={() => {
559
+ const next = drawn === 'perspective' ? 'orthographic' : 'perspective';
560
+ if (session) session.setProjection(next);
561
+ else viewport.setProjection(next);
562
+ }}
563
+ >
564
+ {/* The mark names the projection the view HAS, as Blender's `VIEW_PERSPECTIVE` /
565
+ `VIEW_ORTHO` do; the button is never shown pressed. */}
566
+ <EditorIcon
567
+ size="2xl"
568
+ icon={drawn === 'orthographic' ? editorIcons.viewport.projectionOrthographic : editorIcons.viewport.projection}
569
+ />
570
+ </IconButton>
571
+ </Tooltip>
572
+ )}
573
+ </div>
574
+ ) : null}
575
+ {through && session ? (
576
+ <CameraFrame view={through} canvas={session.renderer.domElement} locked={session.cameraViewLocked() === true} />
577
+ ) : null}
578
+ </>
579
+ );
580
+ }
581
+
582
+ /**
583
+ * THE CAMERA'S FRAME over the region, as Blender's `drawviewborder` draws it: the passepartout
584
+ * outside it at the camera's opacity, and one device pixel outside the frame a solid box (only
585
+ * with a passepartout) under a dashed one (`dash_width` 6 at half, in device pixels).
586
+ */
587
+ function CameraFrame({ view, canvas, locked }: { view: ToolCameraView; canvas: HTMLCanvasElement; locked: boolean }) {
588
+ const [host, setHost] = useState<HTMLDivElement | null>(null);
589
+ // Positions are read from the layout, so a panel resize has to draw the frame again.
590
+ const [, setLayout] = useState(0);
591
+ useEffect(() => {
592
+ const observer = new ResizeObserver(() => setLayout((tick) => tick + 1));
593
+ observer.observe(canvas);
594
+ return () => observer.disconnect();
595
+ }, [canvas]);
596
+ const width = canvas.clientWidth;
597
+ const height = canvas.clientHeight;
598
+ const ratio = canvas.ownerDocument.defaultView?.devicePixelRatio ?? 1;
599
+ const canvasRect = canvas.getBoundingClientRect();
600
+ const hostRect = host?.getBoundingClientRect();
601
+ const px = 1 / ratio;
602
+ const x = view.frame.left * width - px;
603
+ const y = view.frame.top * height - px;
604
+ const w = view.frame.width * width + 2 * px;
605
+ const h = view.frame.height * height + 2 * px;
606
+ return (
607
+ <div ref={setHost} aria-hidden="true" style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
608
+ {hostRect ? (
609
+ <svg
610
+ data-testid="viewport-camera-frame"
611
+ width={width}
612
+ height={height}
613
+ style={{ position: 'absolute', left: canvasRect.left - hostRect.left, top: canvasRect.top - hostRect.top }}
614
+ >
615
+ {view.passepartout.opacity > 0 ? (
616
+ <path
617
+ d={`M0 0H${width}V${height}H0Z M${x} ${y}V${y + h}H${x + w}V${y}Z`}
618
+ fill={view.passepartout.color}
619
+ fillOpacity={view.passepartout.opacity}
620
+ fillRule="evenodd"
621
+ />
622
+ ) : null}
623
+ {view.passepartout.opacity > 0 ? (
624
+ <rect x={x} y={y} width={w} height={h} fill="none" stroke={view.border.solid} strokeWidth={px} shapeRendering="crispEdges" />
625
+ ) : null}
626
+ <rect
627
+ x={x}
628
+ y={y}
629
+ width={w}
630
+ height={h}
631
+ fill="none"
632
+ stroke={view.border.dashed}
633
+ strokeWidth={px}
634
+ strokeDasharray={`${3 * px} ${3 * px}`}
635
+ shapeRendering="crispEdges"
636
+ />
637
+ {/* A locked view's outer box, one pixel outside ("not to confuse with object selection"). */}
638
+ {locked ? (
639
+ <rect
640
+ x={x - px}
641
+ y={y - px}
642
+ width={w + 2 * px}
643
+ height={h + 2 * px}
644
+ fill="none"
645
+ stroke={view.border.locked}
646
+ strokeWidth={px}
647
+ strokeDasharray={`${3 * px} ${3 * px}`}
648
+ shapeRendering="crispEdges"
649
+ />
650
+ ) : null}
651
+ </svg>
652
+ ) : null}
653
+ </div>
654
+ );
655
+ }