@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.
- package/contributions/animation-timeline.utility.tsx +44 -0
- package/contributions/three-integration.service.ts +13 -0
- package/package.json +96 -6
- package/src/adapter/renderer-config.ts +3 -4
- package/src/adapter/three-contract.ts +72 -0
- package/src/ecs/object-marks.ts +1 -1
- package/src/ecs/user-data.ts +0 -9
- 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-api.ts +92 -0
- 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
|
+
}
|