PageScene: extract SelectionBridge from editor.ts

Fourth step of the editor.ts decomposition. Pulls the engine→store
selection-and-outliner sync surface out of Editor into a dedicated
module.

SelectionBridge owns:
  selected (current inspected object)
  last_selected_sum (frame-to-frame transform-change check)

Methods take their dependencies as method arguments / closure-based
deps — no `editor` reference, no `import type Editor`, no circular
import. Constructor takes:
  getSceneManager: () => SceneManager | undefined  (lazy — created later)
  cameraName: string
  version: number

Methods:
  setSelected, publishSelect, updateSelectedUI, refreshOutliner,
  updateOutliner, getAssetType, getCharactersByUuid, isCharacterUuid.

Editor exposes the bridge as `editor.selection` and constructs it in
its constructor. Three facades stay on Editor for the external callers:
  editor.publishSelect()    — actions/selectObject.ts, helper.ts
  editor.updateOutliner()   — actions/add{Object,Character,Shape}.ts
  editor.updateSelectedUI() — helper.ts
ControlPanelSceneObject reads `editor.selected` via a forwarding getter
that returns this.selection.selected.

The four near-identical "render_outliner + setOutlinerItems" snippets
that used to live in Editor.initialize / newScene / loadScene /
deleteObject collapse into one place: selection.refreshOutliner().

Dead code removed during the move:
  - outliner_feature_flag was hardcoded `true` (with `// TODO REMOVE`),
    making the false-branch fallback paths in updateSelectedUI /
    publishSelect unreachable. Field and all `if` branches dropped.
  - Editor.last_selected: passed by value to MouseControls (which
    immediately overwrites it with []), and helper.ts wrote to it but
    nothing read. Pure write-only dead state.
  - The `return 0` on a void-returning method that triggered the
    "Not all code paths return a value" typecheck error — gone with
    updateSelectedUI's body.
  - Unused AssetType / ClipGroup imports in editor.ts.

helper.ts updates:
  - removeTransformControls drops the dead last_selected write.
  - switchCameraView and deleteObject now write
    editor.selection.selected (instead of editor.selected) so the
    forwarding getter still surfaces the current value.

Net: editor.ts down to ~1074 LOC. Typecheck baseline drops by 1
(the void-return error is gone).
This commit is contained in:
Tanish Manku committed 2026-04-30 14:00:56 -07:00
1 parent abf2e1d6c2
commit e06abd36bd
3 files changed
+186 -166

No files matched your search

@@ -9,7 +9,6 @@ import Scene from "./scene.js";
import { APIManager } from "./api_manager.js";
import { PointerLockControls } from "three/addons/controls/PointerLockControls.js";
import { CameraAspectRatio } from "~/pages/PageScene/enums";
import { AssetType, ClipGroup } from "~/enums";
import { XYZ } from "../datastructures/common";
import { SceneUtils } from "./helper";
import { MouseControls } from "./keybinds_controls";
@@ -27,6 +26,7 @@ import { SceneManager } from "./scene_manager_api";
import { ViewportController } from "./editor/ViewportController";
import { PostProcessingPipeline } from "./editor/PostProcessingPipeline";
import { GizmoController } from "./editor/GizmoController";
import { SelectionBridge } from "./editor/SelectionBridge";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { SparkRenderer } from "@sparkjsdev/spark";
@@ -65,9 +65,6 @@ class Editor {
last_cam_rot: THREE.Euler;
raycaster: THREE.Raycaster | undefined;
mouse: THREE.Vector2 | undefined;
selected: THREE.Object3D | undefined;
last_selected: THREE.Object3D | undefined;
last_selected_sum: number | undefined;
rendering: boolean;
api_manager: APIManager;
freeCamState: FreeCamControlState | null = null;
@@ -111,6 +108,13 @@ class Editor {
postProcessing: PostProcessingPipeline;
// Owns the TransformControls gizmo.
gizmo: GizmoController;
// Owns the selection-and-outliner sync into the Zustand store.
selection: SelectionBridge;
// Forwarding getter — ControlPanelSceneObject reads `editor.selected`.
get selected(): THREE.Object3D | undefined {
return this.selection.selected;
}
selectedCanvas: boolean;
startRenderHeight: number;
@@ -128,14 +132,6 @@ class Editor {
sceneManager: SceneManager | undefined;
///////////////////////////////////////////////
///////////////////////////////////////////////
public outliner_feature_flag: boolean;
///////////////////////////////////////////////
///////////////////////////////////////////////
focused: boolean = false;
renderIndex: number;
@@ -189,6 +185,11 @@ class Editor {
// load paths invoke updateSurfaceIdAttributeToMesh as a callback.
this.postProcessing = new PostProcessingPipeline();
this.gizmo = new GizmoController();
this.selection = new SelectionBridge({
getSceneManager: () => this.sceneManager,
cameraName: this.camera_name,
version: this.version,
});
this.activeScene = new Scene(
"" + this.version,
@@ -213,7 +214,6 @@ class Editor {
this.cap_fps = 60;
this.frames = 0;
this.lastFrameTime = 0;
this.last_selected_sum = 0;
this.selectedCanvas = false;
this.renderEventToken = -1;
this.shouldRender = false;
@@ -240,8 +240,6 @@ class Editor {
"((masterpiece, best quality, 8K, detailed)), colorful, epic, fantasy, (fox, red fox:1.2), no humans, 1other, ((koi pond)), outdoors, pond, rocks, stones, koi fish, ((watercolor))), lilypad, fish swimming around.";
this.media_upload = new MediaUploadApi();
// TODO REMOVE
this.outliner_feature_flag = true;
// New Rendering Pipeline Engine Work
this.globalSetTrackLengthSeconds = 7;
@@ -440,7 +438,7 @@ class Editor {
{
onChange: () => this.renderScene(),
onDraggingChanged: (dragging) => {
this.updateSelectedUI();
this.selection.updateSelectedUI();
this.camera_last_pos.copy(new THREE.Vector3(-99999, -99999, -99999));
this.focused = !dragging;
},
@@ -482,27 +480,25 @@ class Editor {
this.gizmo.control,
this.postProcessing.outlinePass,
this.activeScene.scene,
this.publishSelect.bind(this),
this.updateSelectedUI.bind(this),
this.selection.publishSelect.bind(this.selection),
this.selection.updateSelectedUI.bind(this.selection),
false,
this.last_selected,
this.getAssetType.bind(this),
this.setSelected.bind(this),
undefined,
this.selection.getAssetType.bind(this.selection),
this.selection.setSelected.bind(this.selection),
this.isMovable.bind(this),
this.enable_stats.bind(this),
);
if (this.outliner_feature_flag) {
this.sceneManager = new SceneManager(
this.version,
this.mouse_controls,
this.activeScene,
true,
this.updateOutliner.bind(this),
this.isCharacterUuid.bind(this),
); // Enabled dev mode.
this.mouse_controls.sceneManager = this.sceneManager;
}
this.sceneManager = new SceneManager(
this.version,
this.mouse_controls,
this.activeScene,
true,
this.selection.updateOutliner.bind(this.selection),
this.selection.isCharacterUuid.bind(this.selection),
); // Enabled dev mode.
this.mouse_controls.sceneManager = this.sceneManager;
// Add spark renderer as a child of the camera
// this.activeScene.scene.add(this.sparkRenderer);
@@ -512,12 +508,7 @@ class Editor {
console.log("Setting Scene is loaded");
this._isEngineDataLoaded = true;
if (this.outliner_feature_flag) {
const result = this.sceneManager?.render_outliner(
this.getCharactersByUuid(),
);
if (result) usePageSceneStore.getState().setOutlinerItems(result.items);
}
this.selection.refreshOutliner();
setIs3DSceneLoaded(true);
};
@@ -729,12 +720,7 @@ class Editor {
});
usePageSceneStore.getState().resetScene();
if (this.outliner_feature_flag) {
const result = this.sceneManager?.render_outliner(
this.getCharactersByUuid(),
);
if (result) usePageSceneStore.getState().setOutlinerItems(result.items);
}
this.selection.refreshOutliner();
}
public async loadCache(cacheJson: string) {
@@ -743,19 +729,7 @@ class Editor {
public async loadScene(scene_media_token: string) {
await this.save_manager.loadScene(scene_media_token);
if (this.outliner_feature_flag) {
const result = this.sceneManager?.render_outliner(
this.getCharactersByUuid(),
);
if (result) usePageSceneStore.getState().setOutlinerItems(result.items);
}
}
setSelected(object: THREE.Object3D[] | undefined) {
if (this.sceneManager) {
this.sceneManager.selected_objects = object;
}
this.selection.refreshOutliner();
}
isObjectLipsync(object_uuid: string) {
@@ -768,9 +742,7 @@ class Editor {
lockUnlockObject(object_uuid: string): boolean {
const res = this.utils.lockUnlockObject(object_uuid);
if (this.outliner_feature_flag) {
this.updateSelectedUI();
}
this.selection.updateSelectedUI();
return res;
}
@@ -849,12 +821,7 @@ class Editor {
this.mouse_controls?.clearFKVisuals();
this.mouse_controls?.removeTransformControls(true);
this.utils.deleteObject(uuid);
if (this.outliner_feature_flag) {
const result = this.sceneManager?.render_outliner(
this.getCharactersByUuid(),
);
if (result) usePageSceneStore.getState().setOutlinerItems(result.items);
}
this.selection.refreshOutliner();
}
async create_parim(name: string, pos: THREE.Vector3) {
@@ -955,10 +922,10 @@ class Editor {
this.cam_obj.scale.copy(new THREE.Vector3(1, 1, 1));
}
if (this.utils.getSelectedSum() !== this.last_selected_sum) {
this.updateSelectedUI();
if (this.utils.getSelectedSum() !== this.selection.last_selected_sum) {
this.selection.updateSelectedUI();
}
this.last_selected_sum = this.utils.getSelectedSum();
this.selection.last_selected_sum = this.utils.getSelectedSum();
await this.renderScene();
@@ -1089,107 +1056,18 @@ class Editor {
}
}
// Facades — external callers in actions/* and helper.ts target these
// names. Internals reach the bridge directly via this.selection.*.
updateOutliner() {
const result = this.sceneManager?.render_outliner(
this.getCharactersByUuid(),
);
if (result) usePageSceneStore.getState().setOutlinerItems(result.items);
this.updateSelectedUI();
this.selection.updateOutliner();
}
updateSelectedUI() {
let mainSelected;
if (this.outliner_feature_flag) {
if (this.sceneManager?.selected_objects === undefined) {
return;
}
if (this.sceneManager?.selected_objects.length <= 0) {
return 0;
}
mainSelected = this.sceneManager?.selected_objects[0];
} else {
if (this.selected == undefined) {
return 0;
}
mainSelected = this.selected;
}
this.selected = mainSelected;
const pos = mainSelected.position;
const rot = mainSelected.rotation;
const scale = mainSelected.scale;
// TODO this is a bug we need to only show when clicked on and use UPDATE when updating.
usePageSceneStore.getState().updateObjectPanel({
group:
mainSelected.name === this.camera_name
? ClipGroup.CAMERA
: ClipGroup.OBJECT, // TODO: add meta data to determine what it is a camera or a object or a character into prefab clips
object_uuid: mainSelected.uuid,
object_name: mainSelected.name,
version: String(this.version),
objectVectors: {
position: {
x: parseFloat(pos.x.toFixed(2)),
y: parseFloat(pos.y.toFixed(2)),
z: parseFloat(pos.z.toFixed(2)),
},
rotation: {
x: parseFloat(THREE.MathUtils.radToDeg(rot.x).toFixed(2)),
y: parseFloat(THREE.MathUtils.radToDeg(rot.y).toFixed(2)),
z: parseFloat(THREE.MathUtils.radToDeg(rot.z).toFixed(2)),
},
scale: {
x: parseFloat(scale.x.toFixed(6)),
y: parseFloat(scale.y.toFixed(6)),
z: parseFloat(scale.z.toFixed(6)),
},
},
}); //end updateObjectPanel
}
getAssetType(selected: THREE.Object3D<THREE.Object3DEventMap>): AssetType {
if (selected.type === "Mesh") {
return selected.name === "::CAM::" ? AssetType.CAMERA : AssetType.OBJECT;
}
return AssetType.CHARACTER;
this.selection.updateSelectedUI();
}
publishSelect() {
const store = usePageSceneStore.getState();
const target = this.outliner_feature_flag
? this.sceneManager?.selected_objects?.[0]
: this.selected;
if (target) {
store.setSelectedObject({
type: this.getAssetType(target),
id: target.uuid,
});
} else {
store.setSelectedObject(null);
}
}
// Replaces the deleted Timeline.isCharacter — checks the Zustand store's
// character list, which is the source of truth for which scene objects
// are characters.
isCharacterUuid(uuid: string): boolean {
return usePageSceneStore
.getState()
.characters.some((c) => c.id === uuid);
}
// Replaces Timeline.characters (a Record<uuid, ClipGroup>) — used by
// SceneManager.render_outliner to know which scene objects to render as
// characters.
getCharactersByUuid(): { [uuid: string]: ClipGroup } {
const characters = usePageSceneStore.getState().characters;
const result: { [uuid: string]: ClipGroup } = {};
for (const c of characters) {
result[c.id] = ClipGroup.CHARACTER;
}
return result;
this.selection.publishSelect();
}
}
@@ -0,0 +1,143 @@
// Owns the selection-and-outliner sync surface between the Three.js
// SceneManager and the Zustand store: which object is selected, the
// inspector panel ("Object Panel") values for that object, and the
// outliner row list. Centralizes the engine→store writes that were
// previously sprinkled across editor.ts.
//
// SceneManager is forwarded via a closure-based getter (it's constructed
// later in Editor.initialize); cameraName + version are stable values
// passed at construction time. No `editor` reference, no circular import.
import * as THREE from "three";
import { AssetType, ClipGroup } from "~/enums";
import { usePageSceneStore } from "../../PageSceneStore";
import type { SceneManager } from "../scene_manager_api";
export type SelectionEngineRefs = {
// Lazy: SceneManager is created in Editor.initialize, after the bridge.
getSceneManager: () => SceneManager | undefined;
// The reserved camera-entity name ("::CAM::") used to distinguish
// ClipGroup.CAMERA / AssetType.CAMERA from regular meshes.
cameraName: string;
// Editor's data version, written into the object panel.
version: number;
};
export class SelectionBridge {
// The currently inspected object. Read by ControlPanelSceneObject via
// a forwarding getter on Editor (`editor.selected`).
selected: THREE.Object3D | undefined;
// Frame-to-frame "did the selection's transform change" check —
// compared in renderSingleFrame against utils.getSelectedSum().
last_selected_sum: number = 0;
constructor(private readonly engine: SelectionEngineRefs) {}
setSelected(object: THREE.Object3D[] | undefined) {
const sceneManager = this.engine.getSceneManager();
if (sceneManager) sceneManager.selected_objects = object;
}
// Push the current selection (or null) into the store. Drives the
// global "what's selected?" state used by toolbars, panels, etc.
publishSelect() {
const target = this.engine.getSceneManager()?.selected_objects?.[0];
const store = usePageSceneStore.getState();
if (target) {
store.setSelectedObject({
type: this.getAssetType(target),
id: target.uuid,
});
} else {
store.setSelectedObject(null);
}
}
// Push the selected object's transform into the inspector panel.
// No-op if nothing is selected.
updateSelectedUI() {
const selected_objects = this.engine.getSceneManager()?.selected_objects;
if (selected_objects === undefined || selected_objects.length === 0) return;
const mainSelected = selected_objects[0];
this.selected = mainSelected;
const pos = mainSelected.position;
const rot = mainSelected.rotation;
const scale = mainSelected.scale;
usePageSceneStore.getState().updateObjectPanel({
// TODO: add metadata to determine whether this is a camera, an
// object, or a character into prefab clips.
group:
mainSelected.name === this.engine.cameraName
? ClipGroup.CAMERA
: ClipGroup.OBJECT,
object_uuid: mainSelected.uuid,
object_name: mainSelected.name,
version: String(this.engine.version),
objectVectors: {
position: {
x: parseFloat(pos.x.toFixed(2)),
y: parseFloat(pos.y.toFixed(2)),
z: parseFloat(pos.z.toFixed(2)),
},
rotation: {
x: parseFloat(THREE.MathUtils.radToDeg(rot.x).toFixed(2)),
y: parseFloat(THREE.MathUtils.radToDeg(rot.y).toFixed(2)),
z: parseFloat(THREE.MathUtils.radToDeg(rot.z).toFixed(2)),
},
scale: {
x: parseFloat(scale.x.toFixed(6)),
y: parseFloat(scale.y.toFixed(6)),
z: parseFloat(scale.z.toFixed(6)),
},
},
});
}
// Recompute outliner rows and push them into the store. Replaces the
// four near-identical copies of this snippet that used to live in
// Editor.initialize, newScene, loadScene, and deleteObject.
refreshOutliner() {
const result = this.engine
.getSceneManager()
?.render_outliner(this.getCharactersByUuid());
if (result) usePageSceneStore.getState().setOutlinerItems(result.items);
}
// Refresh outliner and inspector together — used after operations
// that may have changed both (e.g. an asset finishing loading).
updateOutliner() {
this.refreshOutliner();
this.updateSelectedUI();
}
getAssetType(selected: THREE.Object3D): AssetType {
if (selected.type === "Mesh") {
return selected.name === this.engine.cameraName
? AssetType.CAMERA
: AssetType.OBJECT;
}
return AssetType.CHARACTER;
}
// Replaces the deleted Timeline.characters (a Record<uuid, ClipGroup>) —
// used by SceneManager.render_outliner to know which scene objects to
// render as characters.
getCharactersByUuid(): { [uuid: string]: ClipGroup } {
const characters = usePageSceneStore.getState().characters;
const result: { [uuid: string]: ClipGroup } = {};
for (const c of characters) {
result[c.id] = ClipGroup.CHARACTER;
}
return result;
}
// Replaces the deleted Timeline.isCharacter — checks the Zustand
// store's character list, which is the source of truth for which
// scene objects are characters.
isCharacterUuid(uuid: string): boolean {
return usePageSceneStore.getState().characters.some((c) => c.id === uuid);
}
}
@@ -86,7 +86,6 @@ export class SceneUtils {
return;
}
if (remove_outline) {
this.editor.last_selected = this.editor.selected;
outlinePass.selectedObjects = [];
this.editor.publishSelect();
}
@@ -136,7 +135,7 @@ export class SceneUtils {
this.editor.cam_obj.scale.set(0, 0, 0);
this.removeTransformControls();
this.editor.selected = this.editor.cam_obj;
this.editor.selection.selected = this.editor.cam_obj;
this.editor.publishSelect();
this.editor.updateSelectedUI();
usePageSceneStore.getState().setEditorState(EditorStates.CAMERA_VIEW);
@@ -271,7 +270,7 @@ function removeObject3D(object3D) {
}
usePageSceneStore.getState().removeSceneObject(uuid);
this.editor.selected = undefined;
this.editor.selection.selected = undefined;
this.editor.publishSelect();
usePageSceneStore.getState().hideObjectPanel();
}