@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,142 @@
1
+ import { EDITOR_LAYER } from '@volter/editor-threejs/viewport/editor-layers';
2
+ import { reflectionProbeOf } from '@volter/editor-threejs/adapter/reflection-probe';
3
+ import { setUserData } from '@volter/editor-threejs/ecs/user-data';
4
+ import * as THREE from 'three';
5
+
6
+ const INFLUENCE = new THREE.Color(0x4aa8ff);
7
+ const INFLUENCE_SELECTED = new THREE.Color(0x75c4ff);
8
+ const PARALLAX = new THREE.Color(0xffb84a);
9
+ const CAPTURE = new THREE.Color(0xffffff);
10
+
11
+ /**
12
+ * Editor-only visualization for a project-owned reflection probe. It follows
13
+ * the live Object3D mark but owns every line and material it draws, so no
14
+ * helper enters the game's JSX tree or its runtime camera.
15
+ */
16
+ export class ReflectionProbeHelper extends THREE.Group {
17
+ private signature = '';
18
+ private selected = false;
19
+
20
+ constructor(readonly source: THREE.Object3D) {
21
+ super();
22
+ this.name = 'Reflection Probe Helper';
23
+ this.matrixAutoUpdate = false;
24
+ this.renderOrder = 998;
25
+ setUserData(this, 'editorHelper', true);
26
+ setUserData(this, 'editorHelperType', 'reflection-probes');
27
+ this.layers.set(EDITOR_LAYER);
28
+ this.refresh(true);
29
+ }
30
+
31
+ setSelected(selected: boolean): void {
32
+ if (this.selected === selected) return;
33
+ this.selected = selected;
34
+ this.refresh(true);
35
+ }
36
+
37
+ update(): void {
38
+ this.refresh(false);
39
+ this.source.updateWorldMatrix(true, false);
40
+ this.matrix.copy(this.source.matrixWorld);
41
+ this.matrixWorldNeedsUpdate = true;
42
+ }
43
+
44
+ dispose(): void {
45
+ this.clearVisuals();
46
+ this.removeFromParent();
47
+ }
48
+
49
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: each branch draws one independently meaningful probe volume.
50
+ private refresh(force: boolean): void {
51
+ const probe = reflectionProbeOf(this.source);
52
+ if (!probe) return;
53
+ const config = probe.config;
54
+ const next = JSON.stringify(config);
55
+ if (!force && next === this.signature) return;
56
+ this.signature = next;
57
+ this.clearVisuals();
58
+
59
+ const influenceColor = this.selected ? INFLUENCE_SELECTED : INFLUENCE;
60
+ const influenceOpacity = this.selected ? 1 : 0.62;
61
+ const blend = Math.max(0, config.blendDistance);
62
+
63
+ if (config.shape === 'sphere') {
64
+ const radius = Math.max(0.01, config.radius);
65
+ this.addWire(new THREE.SphereGeometry(radius, 24, 16), influenceColor, influenceOpacity);
66
+ if (blend > 0 && radius - blend > 0.01) {
67
+ this.addWire(
68
+ new THREE.SphereGeometry(radius - blend, 20, 12),
69
+ influenceColor,
70
+ this.selected ? 0.55 : 0.28,
71
+ );
72
+ }
73
+ } else {
74
+ const [x, y, z] = config.size.map((value) => Math.max(0.01, value)) as [
75
+ number,
76
+ number,
77
+ number,
78
+ ];
79
+ this.addWire(new THREE.BoxGeometry(x, y, z), influenceColor, influenceOpacity);
80
+ const inner = [x - blend * 2, y - blend * 2, z - blend * 2] as const;
81
+ if (blend > 0 && inner.every((value) => value > 0.01)) {
82
+ this.addWire(
83
+ new THREE.BoxGeometry(inner[0], inner[1], inner[2]),
84
+ influenceColor,
85
+ this.selected ? 0.55 : 0.28,
86
+ );
87
+ }
88
+ }
89
+
90
+ if (config.parallaxProjection) {
91
+ const [x, y, z] = config.parallaxSize.map((value) => Math.max(0.01, value)) as [
92
+ number,
93
+ number,
94
+ number,
95
+ ];
96
+ const box = this.addWire(
97
+ new THREE.BoxGeometry(x, y, z),
98
+ PARALLAX,
99
+ this.selected ? 0.9 : 0.45,
100
+ );
101
+ box.position.fromArray(config.parallaxOffset);
102
+ }
103
+
104
+ const origin = this.addWire(
105
+ new THREE.OctahedronGeometry(this.selected ? 0.16 : 0.11),
106
+ CAPTURE,
107
+ this.selected ? 1 : 0.72,
108
+ );
109
+ origin.position.fromArray(config.captureOffset);
110
+ this.traverse((object) => object.layers.set(EDITOR_LAYER));
111
+ }
112
+
113
+ private addWire(
114
+ sourceGeometry: THREE.BufferGeometry,
115
+ color: THREE.Color,
116
+ opacity: number,
117
+ ): THREE.LineSegments {
118
+ const geometry = new THREE.EdgesGeometry(sourceGeometry);
119
+ sourceGeometry.dispose();
120
+ const material = new THREE.LineBasicMaterial({
121
+ color,
122
+ depthTest: false,
123
+ transparent: true,
124
+ opacity,
125
+ toneMapped: false,
126
+ });
127
+ const wire = new THREE.LineSegments(geometry, material);
128
+ wire.renderOrder = this.renderOrder;
129
+ this.add(wire);
130
+ return wire;
131
+ }
132
+
133
+ private clearVisuals(): void {
134
+ for (const child of [...this.children]) {
135
+ child.removeFromParent();
136
+ const line = child as THREE.LineSegments;
137
+ line.geometry?.dispose();
138
+ const materials = Array.isArray(line.material) ? line.material : [line.material];
139
+ for (const material of materials) material?.dispose();
140
+ }
141
+ }
142
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * THE SCENE'S VIEWPORT (`@volter/editor-sdk/kit/document-viewports`): the world
3
+ * stage draws the Scene through the session store's own camera, shading and
4
+ * grid, and frames the shell's selection. It keeps no selection of its own, so a
5
+ * view's selection is the shell's.
6
+ */
7
+ import type { DocumentViewport } from '@volter/editor-sdk/kit/document-viewports';
8
+ import type { EditorShellStore } from './editor-shell-store';
9
+ import { viewGridVisible } from '@volter/editor-sdk/kit/viewport-presentation';
10
+
11
+ type ShadingMode = Parameters<EditorShellStore['setShadingMode']>[0];
12
+
13
+ const SCENE_MODES = new Set<string>(['solid', 'clay', 'unlit', 'wireframe', 'normals', 'overdraw']);
14
+
15
+ export function sceneDocumentViewport(store: EditorShellStore, documentId: string): DocumentViewport {
16
+ return {
17
+ read: () => {
18
+ const pose = store.cameraPose;
19
+ if (!pose) return null;
20
+ const { position, target } = pose;
21
+ return {
22
+ camera: {
23
+ position: { x: position.x, y: position.y, z: position.z },
24
+ target: { x: target.x, y: target.y, z: target.z },
25
+ ...(pose.fov ? { fov: pose.fov } : {}),
26
+ },
27
+ diagnostic: store.shadingMode,
28
+ grid: viewGridVisible(documentId),
29
+ };
30
+ },
31
+ setDiagnostic: (diagnostic) => {
32
+ if (!SCENE_MODES.has(diagnostic)) return false;
33
+ store.setShadingMode(diagnostic as ShadingMode);
34
+ return true;
35
+ },
36
+ setCamera: (camera) => {
37
+ if (typeof camera !== 'string') {
38
+ store.shell.setCameraPose(camera.position, camera.target, camera.fov);
39
+ return true;
40
+ }
41
+ if (camera === 'isometric') return false;
42
+ store.shell.setViewPreset(camera);
43
+ return true;
44
+ },
45
+ frame: (target) => {
46
+ if (target === 'document') return false;
47
+ store.shell.focusOnSelection();
48
+ return true;
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * What the editor camera should look at when it adopts a world nobody authored
3
+ * here — the two answers behind `EditorViewport.focusOnScene`.
4
+ *
5
+ * ## The measured defect
6
+ *
7
+ * Auto-frame framed the game's ENTIRE content AABB. Measured on a Cuberun
8
+ * mount: a 2,000-radius BackSide skybox sphere, a sun mesh at `z = -2000` and a
9
+ * hyperspace prop at `z ≈ -5365` stretched that AABB to ~8,400 units deep, so
10
+ * the fit distance landed the camera 12,870 units away from a ~1,000-unit
11
+ * gameplay corridor. 93% of the Scene viewport was black, and the one visible
12
+ * thing was the OUTSIDE of the skybox. The same world framed at working
13
+ * distance is vivid and correct. (Descent measured the same looseness at a
14
+ * smaller scale: a camera at ~2,580 for an ~800-unit world.)
15
+ *
16
+ * The union is the wrong instrument because a backdrop is not content the
17
+ * reader is looking FOR — it is content that exists to be far away. One prop
18
+ * 5,000 units out costs the whole framing, and no amount of care in the fit
19
+ * math can recover it.
20
+ *
21
+ * ## The two answers, in order
22
+ *
23
+ * 1. {@link viewFromGameCamera} — a game's author already decided the
24
+ * interesting view, so seeding the orbit camera from the world matrix of a
25
+ * camera the game itself placed opens the Scene tab where the game looks.
26
+ * {@link pickGameCamera} decides which camera that is.
27
+ * 2. {@link frameableContentBounds} — the fallback for a game whose camera we
28
+ * cannot name. It trims outliers off the union before framing.
29
+ *
30
+ * ## Why gap-trimming and not a percentile
31
+ *
32
+ * A blanket percentile (frame the inner 90% of content) would tighten the
33
+ * framing of EVERY world, including the uniform ones that are framed correctly
34
+ * today. This trims only across a spatial GAP: a face of the bounding box moves
35
+ * inward only when a small share of boxes sits beyond a void spanning a large
36
+ * fraction of the current diagonal. A uniformly-filled world has no such gap,
37
+ * so it comes back byte-identical to the plain union — that identity is the
38
+ * first property the tests pin.
39
+ */
40
+
41
+ import { isEditorOwnedObject } from '@volter/editor-threejs/viewport/editor-layers';
42
+ import * as THREE from 'three';
43
+
44
+ /** Tuning for {@link frameableContentBounds}. Defaults are the shipped values. */
45
+ export interface FrameableBoundsOptions {
46
+ /** Largest share of the boxes all trimming together may discard. */
47
+ maxOutlierFraction?: number;
48
+ /** A gap must span this share of the current content diagonal to be a gap. */
49
+ minGapFraction?: number;
50
+ /** Below this many boxes there is no distribution to judge; nothing trims. */
51
+ minSampleSize?: number;
52
+ /** Cap on trim passes, so cost stays bounded on a huge world. */
53
+ maxPasses?: number;
54
+ }
55
+
56
+ const DEFAULTS: Required<FrameableBoundsOptions> = {
57
+ maxOutlierFraction: 0.1,
58
+ minGapFraction: 0.2,
59
+ minSampleSize: 8,
60
+ maxPasses: 8,
61
+ };
62
+
63
+ /** Orbit pivot distance when there is no content to measure one from. */
64
+ const EMPTY_CONTENT_PIVOT_DISTANCE = 10;
65
+
66
+ /** How far down the view axis the content must start for the pivot to mean
67
+ * anything — as a share of the content's own radius. */
68
+ const MIN_PIVOT_RADIUS_FRACTION = 0.01;
69
+
70
+ type Axis = 'x' | 'y' | 'z';
71
+ const AXES: readonly Axis[] = ['x', 'y', 'z'];
72
+
73
+ function unionOf(boxes: readonly THREE.Box3[], indices: Iterable<number>): THREE.Box3 {
74
+ const out = new THREE.Box3();
75
+ for (const i of indices) out.union(boxes[i] as THREE.Box3);
76
+ return out;
77
+ }
78
+
79
+ /** One candidate trim: the boxes beyond the widest gap on one box face. */
80
+ interface GapCut {
81
+ gap: number;
82
+ discard: number[];
83
+ }
84
+
85
+ /**
86
+ * The widest gap found scanning inward from any of the six faces, across at
87
+ * most `budget` boxes.
88
+ *
89
+ * Scanning from a FACE (rather than clustering centers) is what catches a
90
+ * skybox: its centre sits with the content, and only its extent is an outlier.
91
+ */
92
+ function widestOuterGap(
93
+ boxes: readonly THREE.Box3[],
94
+ kept: ReadonlySet<number>,
95
+ budget: number,
96
+ ): GapCut | null {
97
+ let best: GapCut | null = null;
98
+ for (const axis of AXES) {
99
+ for (const side of ['min', 'max'] as const) {
100
+ const values: Array<{ i: number; v: number }> = [];
101
+ for (const i of kept) values.push({ i, v: (boxes[i] as THREE.Box3)[side][axis] });
102
+ // Outliers are the smallest values on a min face and the largest on a max
103
+ // face; sort so index 0 is always the outermost.
104
+ values.sort((a, b) => (side === 'min' ? a.v - b.v : b.v - a.v));
105
+ const reach = Math.min(budget, values.length - 1);
106
+ for (let k = 0; k < reach; k++) {
107
+ const gap = Math.abs((values[k + 1] as { v: number }).v - (values[k] as { v: number }).v);
108
+ if (best && gap <= best.gap) continue;
109
+ best = { gap, discard: values.slice(0, k + 1).map((e) => e.i) };
110
+ }
111
+ }
112
+ }
113
+ return best;
114
+ }
115
+
116
+ /**
117
+ * The box worth FRAMING, given one world-space box per content node.
118
+ *
119
+ * Returns the plain union whenever there is nothing defensible to trim: too few
120
+ * boxes to judge, no gap wide enough, or a budget of zero. Never returns an
121
+ * empty box for non-empty input.
122
+ */
123
+ export function frameableContentBounds(
124
+ boxes: readonly THREE.Box3[],
125
+ options: FrameableBoundsOptions = {},
126
+ ): THREE.Box3 {
127
+ const opts = { ...DEFAULTS, ...options };
128
+ const content = boxes.filter((b) => !b.isEmpty());
129
+ const all = content.map((_, i) => i);
130
+ const full = unionOf(content, all);
131
+ if (content.length < opts.minSampleSize) return full;
132
+
133
+ let budget = Math.floor(content.length * opts.maxOutlierFraction);
134
+ if (budget < 1) return full;
135
+
136
+ const kept = new Set(all);
137
+ let bounds = full;
138
+ const size = new THREE.Vector3();
139
+ for (let pass = 0; pass < opts.maxPasses && budget > 0; pass++) {
140
+ const diagonal = bounds.getSize(size).length();
141
+ if (!(diagonal > 0)) break;
142
+ const cut = widestOuterGap(content, kept, budget);
143
+ if (!cut || cut.gap <= opts.minGapFraction * diagonal) break;
144
+ for (const i of cut.discard) kept.delete(i);
145
+ budget -= cut.discard.length;
146
+ bounds = unionOf(content, kept);
147
+ }
148
+ return bounds.isEmpty() ? full : bounds;
149
+ }
150
+
151
+ /** An orbit-camera pose: where the eye sits and what it orbits around. */
152
+ export interface SeededView {
153
+ position: THREE.Vector3;
154
+ target: THREE.Vector3;
155
+ }
156
+
157
+ /**
158
+ * The editor orbit pose that reproduces `camera`'s own view.
159
+ *
160
+ * The eye is the game camera's world position and the view direction is its
161
+ * own; the only thing invented is the orbit PIVOT, which is placed at the depth
162
+ * of the content along that view direction — the point the reader is already
163
+ * looking at, so the first orbit drag turns around the world rather than
164
+ * around a point behind their head.
165
+ *
166
+ * Returns `null` when that pivot cannot be placed: the world sits BEHIND the
167
+ * camera (a view showing none of the game is not worth adopting), or it sits
168
+ * ON it, which leaves an orbit pivot at the eye — measured as a target
169
+ * 4.6e-14 units out, an orbit control that can only spin in place. Both fall
170
+ * back to {@link frameableContentBounds}.
171
+ */
172
+ export function viewFromGameCamera(camera: THREE.Camera, content: THREE.Box3): SeededView | null {
173
+ const forward = camera.getWorldDirection(new THREE.Vector3());
174
+ const position = new THREE.Vector3().setFromMatrixPosition(camera.matrixWorld);
175
+ if (!Number.isFinite(position.lengthSq()) || forward.lengthSq() === 0) return null;
176
+ if (content.isEmpty()) {
177
+ return {
178
+ position,
179
+ target: position.clone().addScaledVector(forward, EMPTY_CONTENT_PIVOT_DISTANCE),
180
+ };
181
+ }
182
+ const sphere = content.getBoundingSphere(new THREE.Sphere());
183
+ const along = sphere.center.clone().sub(position).dot(forward);
184
+ if (!(along > sphere.radius * MIN_PIVOT_RADIUS_FRACTION)) return null;
185
+ return { position, target: position.clone().addScaledVector(forward, along) };
186
+ }
187
+
188
+ /**
189
+ * The NDC points a seeded view is sampled at: the centre plus a ring at 60% of
190
+ * the way to each edge/corner. Nine rays — enough that a view whose middle is
191
+ * blocked by one prop still reads as open, cheap enough to run on every seed
192
+ * attempt over a whole game graph.
193
+ */
194
+ const OCCLUSION_SAMPLES: ReadonlyArray<readonly [number, number]> = [
195
+ [0, 0],
196
+ [-0.6, 0.6],
197
+ [0.6, 0.6],
198
+ [-0.6, -0.6],
199
+ [0.6, -0.6],
200
+ [0, 0.6],
201
+ [0, -0.6],
202
+ [-0.6, 0],
203
+ [0.6, 0],
204
+ ];
205
+
206
+ /**
207
+ * How near a hit has to be, as a share of the view's own pivot distance, to
208
+ * count as "pressed against the lens" rather than "part of the world". At 2%,
209
+ * a camera looking 100 units into a world is blocked only by something inside
210
+ * 2 units of it.
211
+ */
212
+ const NEAR_FIELD_FRACTION = 0.02;
213
+
214
+ const _raycaster = new THREE.Raycaster();
215
+ const _ndc = new THREE.Vector2();
216
+
217
+ /** Three's raycast reports hits on invisible objects; a hidden collider proxy
218
+ * in front of the lens blocks nothing a reader would see. */
219
+ function isVisibleInWorld(object: THREE.Object3D): boolean {
220
+ for (let node: THREE.Object3D | null = object; node; node = node.parent) {
221
+ if (!node.visible) return false;
222
+ }
223
+ return true;
224
+ }
225
+
226
+ /**
227
+ * Does a view posed HERE show the world, or a surface pressed against the lens?
228
+ *
229
+ * The measured defect: a game's own camera is placed for the game's own moment
230
+ * — inside a cockpit, behind a wall it is about to drive through, under
231
+ * terrain that has not settled — and adopting it as the editor's opening view
232
+ * can hand the reader a full-bleed rectangle of rock. The view is a perfectly
233
+ * good gameplay camera and a useless first look, and nothing in the pose
234
+ * itself says which.
235
+ *
236
+ * The instrument is geometric, not pixels: nine rays through the frustum, and
237
+ * a view SHOWS THE WORLD when at least one of them either escapes (sky,
238
+ * backdrop, open space) or reaches something farther than
239
+ * {@link NEAR_FIELD_FRACTION} of what this view claims to be looking at. The
240
+ * threshold is relative to the view's own pivot distance, so it needs no
241
+ * world-scale constant and reads the same on a 10-unit room and a 10,000-unit
242
+ * canyon.
243
+ *
244
+ * Deliberately conservative: EVERY sample must be blocked in the near field
245
+ * before the view is refused, so an ordinary view with a prop in the middle or
246
+ * ground filling its lower half still passes. A refusal here costs the reader
247
+ * only the game's own camera — the caller falls back to
248
+ * {@link frameableContentBounds}, which always shows something.
249
+ *
250
+ * Three's raycast honours `material.side` exactly as the renderer does, and
251
+ * that alignment is the reason this instrument is honest rather than merely
252
+ * cheap: a camera sealed inside a front-faced solid is invisible to BOTH — the
253
+ * reader sees through the shell, the rays escape through it, and the view is
254
+ * correctly not called blocked. The failure this catches is the one that is
255
+ * actually on screen: front faces, filling the frame, right at the lens.
256
+ *
257
+ * (The screenshot lane measures the same failure from the other side, on
258
+ * PIXELS, in `composite-screenshot.ts`. That instrument needs a drawn frame
259
+ * and a GPU readback, and it cannot tell a legitimately flat picture from a
260
+ * blocked one — which is why the decision to adopt a pose is made here, before
261
+ * anything is drawn.)
262
+ */
263
+ export function seededViewShowsWorld(
264
+ camera: THREE.Camera,
265
+ content: readonly THREE.Object3D[],
266
+ pivotDistance: number,
267
+ ): boolean {
268
+ if (!(pivotDistance > 0) || content.length === 0) return true;
269
+ const nearField = pivotDistance * NEAR_FIELD_FRACTION;
270
+ for (const [x, y] of OCCLUSION_SAMPLES) {
271
+ _ndc.set(x, y);
272
+ _raycaster.setFromCamera(_ndc, camera);
273
+ const hits = _raycaster.intersectObjects(content as THREE.Object3D[], true);
274
+ const blocker = hits.find((hit) => isVisibleInWorld(hit.object));
275
+ if (!blocker || blocker.distance > nearField) return true;
276
+ }
277
+ return false;
278
+ }
279
+
280
+ /** Every camera in the game's own scene, in scene order, editor furniture and
281
+ * its subtrees excluded. */
282
+ function gameCameras(node: THREE.Object3D, out: THREE.Camera[] = []): THREE.Camera[] {
283
+ if (isEditorOwnedObject(node)) return out;
284
+ if ((node as THREE.Camera).isCamera) out.push(node as THREE.Camera);
285
+ for (const child of node.children) gameCameras(child, out);
286
+ return out;
287
+ }
288
+
289
+ /**
290
+ * The camera whose view an ingest mount should adopt — one the game itself
291
+ * CREATED AND PLACED, which means one that is in the game's own scene graph.
292
+ *
293
+ * The capture's own camera (the one the first `render(scene, camera)` used) is
294
+ * preferred WHEN it is one of those, and is otherwise ignored, because on the
295
+ * first rendered frame it can be a reconciler's placeholder rather than the
296
+ * author's camera. Measured on Cuberun: the captured camera came back with an
297
+ * identity world matrix at the origin while the game's own
298
+ * `<PerspectiveCamera position={[0, 10, -10]}>` was sitting in the scene at
299
+ * (0, 8, 3.5) looking down the corridor — seeding from the capture opened on a
300
+ * flat wall of ground plane, seeding from the scene's camera opened on the
301
+ * game.
302
+ *
303
+ * Editor furniture is never adopted: the capture trap sits on the shared
304
+ * `WebGLRenderer.prototype`, so the editor's own cameras are the one thing
305
+ * that must never come back from here.
306
+ */
307
+ export function pickGameCamera(
308
+ captured: THREE.Camera | null | undefined,
309
+ scene: THREE.Object3D,
310
+ ): THREE.Camera | null {
311
+ const cameras = gameCameras(scene);
312
+ if (cameras.length === 0) return null;
313
+ if (captured && cameras.includes(captured)) return captured;
314
+ return cameras[0] as THREE.Camera;
315
+ }
@@ -0,0 +1,89 @@
1
+ import type * as THREE from 'three';
2
+
3
+ /**
4
+ * Why the Scene viewport draws a world's fog OUT.
5
+ *
6
+ * ## The measured defect
7
+ *
8
+ * Fog is calibrated in metres from THE GAME'S OWN CAMERA. The racing-game
9
+ * mount declares `<fog args={['white', 0, 500]}>`, which is exactly right at
10
+ * its chase camera 20 units behind the car and meaningless anywhere else: its
11
+ * world is ~800 units across, so the editor framing that shows the whole track
12
+ * sits ~700 units back, and every visible surface is past the fog's far plane.
13
+ * Measured on that mount (2026-08-15): the framed Scene view is a featureless
14
+ * white blob — the track, the canyon and the horizon are all the same white.
15
+ *
16
+ * The Scene camera is not the game's camera. It is placed by framing, by a
17
+ * seed, or by the reader's own orbit, at whatever distance the world needs —
18
+ * so a distance-calibrated effect authored for another camera is not a look,
19
+ * it is an erasure.
20
+ *
21
+ * ## Why a view policy and not fog-aware framing
22
+ *
23
+ * The alternative was to have the framing respect the fog range — pick a
24
+ * distance inside it. It cannot work, and the numbers say so rather than an
25
+ * argument: this world's content diagonal is ~1,100 units against a fog far
26
+ * plane of 500, so no distance both shows the track and stays readable. Any
27
+ * fog-aware framing would have to stop showing the world to keep the fog,
28
+ * which is backwards — the first look exists to show the world.
29
+ *
30
+ * So the fog goes, as a VIEW POLICY, in the same class as the shading modes:
31
+ * presentation-only, restored before anything else can observe it, and applied
32
+ * only to the editor's own draw. This is also the established answer in the
33
+ * genre — Unity's Scene view renders fog OFF by default and offers it as a
34
+ * scene-view effect toggle, for exactly this reason.
35
+ *
36
+ * The game's own look is not touched anywhere it belongs: the Game tab, play
37
+ * mode and every exported build render the game's camera through the game's
38
+ * fog. Only the authoring camera is exempted, and there is deliberately no
39
+ * toggle for it — a Scene view that can be washed white by a setting is a
40
+ * fault the reader has to diagnose, and nothing here is worth that.
41
+ *
42
+ * ## Why the parameters and not `scene.fog = null`
43
+ *
44
+ * `!!scene.fog` is part of three's shader-program cache key, so nulling it for
45
+ * one draw and restoring it for the next compiles and keeps a SECOND program
46
+ * variant for every material in the world. Neutralising the fog's own numbers
47
+ * leaves the program identical and the fog term at zero.
48
+ */
49
+
50
+ /** Far enough that no real depth reaches it; `near < far` keeps the linear
51
+ * fog's `smoothstep(near, far, depth)` well-defined. */
52
+ const NEUTRAL_FOG_NEAR = 1e20;
53
+ const NEUTRAL_FOG_FAR = 2e20;
54
+
55
+ type LinearFog = THREE.Fog & { isFog: true };
56
+ type ExpFog = THREE.FogExp2 & { isFogExp2: true };
57
+
58
+ /**
59
+ * Run `draw` with `scene`'s fog contributing nothing, then put it back exactly
60
+ * as it was — including when `draw` throws.
61
+ *
62
+ * A scene with no fog costs one property read.
63
+ */
64
+ export function withSceneFogNeutralized(scene: THREE.Scene, draw: () => void): void {
65
+ const fog = scene.fog;
66
+ if (!fog) {
67
+ draw();
68
+ return;
69
+ }
70
+ const linear = (fog as LinearFog).isFog ? (fog as LinearFog) : null;
71
+ const exponential = (fog as ExpFog).isFogExp2 ? (fog as ExpFog) : null;
72
+ const near = linear?.near;
73
+ const far = linear?.far;
74
+ const density = exponential?.density;
75
+ if (linear) {
76
+ linear.near = NEUTRAL_FOG_NEAR;
77
+ linear.far = NEUTRAL_FOG_FAR;
78
+ }
79
+ if (exponential) exponential.density = 0;
80
+ try {
81
+ draw();
82
+ } finally {
83
+ if (linear && near !== undefined && far !== undefined) {
84
+ linear.near = near;
85
+ linear.far = far;
86
+ }
87
+ if (exponential && density !== undefined) exponential.density = density;
88
+ }
89
+ }