From cdb16ff9f46ffd4e02943437e3942a4a894bcc93 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 8 May 2026 13:38:25 -0700 Subject: [PATCH] artcraft: move engine events + EngineStoreBridge into ui-pagescene The Chromium-style EventListenerMap bus and the per-event-type bridge both move into the lib. Class identity is single-sourced from @storyteller/ui-pagescene so emit/subscribe lookups share the same constructor key whether they're called from inside the lib or from the artcraft host (e.g. PageEditor's grid-toggle GridVisibleChangedEvent). The two apps/PageScene/engine files left in place become tiny re-export shims pointing at the lib so the existing ~50+ importers (engine internals, action dispatchers, PageEditor) keep working unchanged during the staged migration -- they'll be deleted in a future commit once their consumers move into the lib too. Workspace plumbing: - frontend/tsconfig.json: nx sync added pagescene to the root project references list. - pagescene/tsconfig.{json,lib.json}: nx sync trimmed the references to only the libs the new code actually uses (api-enums, model-list, common). - node_modules/@storyteller/ui-pagescene needs to be a Windows directory junction (mklink /J) for the workspace to resolve the package; the prior bash `ln -s` produced a copy on Windows that didn't reflect the lib's live state and caused misleading "no exported member" errors. Drive-by: nx sync also added a missing `gravatar` reference under lightbox-modal -- unrelated cleanup riding along. Verified: artcraft typecheck error count back to 124 baseline (no new errors from this move). --- .../PageScene/engine/EngineStoreBridge.ts | 165 +--------------- .../PageScene/engine/events/EngineEvent.ts | 187 +----------------- .../PageScene/engine/events/EngineEventBus.ts | 59 +----- .../components/lightbox-modal/tsconfig.json | 3 + .../lightbox-modal/tsconfig.lib.json | 3 + .../libs/components/pagescene/src/index.ts | 39 ++++ .../src/lib/engine/EngineStoreBridge.ts | 157 +++++++++++++++ .../src/lib/engine/events/EngineEvent.ts | 183 +++++++++++++++++ .../src/lib/engine/events/EngineEventBus.ts | 50 +++++ .../libs/components/pagescene/tsconfig.json | 50 +---- .../components/pagescene/tsconfig.lib.json | 50 +---- frontend/tsconfig.json | 3 + 12 files changed, 461 insertions(+), 488 deletions(-) create mode 100644 frontend/libs/components/pagescene/src/lib/engine/EngineStoreBridge.ts create mode 100644 frontend/libs/components/pagescene/src/lib/engine/events/EngineEvent.ts create mode 100644 frontend/libs/components/pagescene/src/lib/engine/events/EngineEventBus.ts diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/EngineStoreBridge.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/EngineStoreBridge.ts index 30e42772e7..acae076ab0 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/EngineStoreBridge.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/EngineStoreBridge.ts @@ -1,157 +1,8 @@ -// EngineStoreBridge — single boundary between the 3D engine and the -// Zustand store. It is the ONLY file inside engine/ that imports -// usePageSceneStore. Engine subsystems emit typed EngineEvents on the -// bus; this bridge subscribes once per event class at construction and -// translates each event into a one-line store mutation. -// -// One handler per event class — no instanceof chains. The bus routes -// each emit to the matching handler set in O(1) (Map lookup), so the -// hot per-frame events (CameraUpdatedEvent, etc.) avoid the linear -// type-guard scan that the old switch-style had. - -import { usePageSceneStore } from "../PageSceneStore"; -import { EngineEventBus } from "./events/EngineEventBus"; -import { - AssetModalVisibilityChangedEvent, - CameraAspectRatioChangedEvent, - CameraUpdatedEvent, - CamerasReplacedEvent, - EditorLoaderEvent, - EditorStateChangedEvent, - EngineInitializedEvent, - GridVisibleChangedEvent, - InspectorPanelChangedEvent, - ObjectAddedEvent, - ObjectRemovedEvent, - OutlinerItemLockToggledEvent, - OutlinerItemVisibilityToggledEvent, - OutlinerRefreshedEvent, - OutlinerSelectedItemChangedEvent, - PoseControlsVisibilityChangedEvent, - PoseModeChangedEvent, - SceneLoadedEvent, - SceneResetEvent, - SelectedCameraChangedEvent, - SelectedModeChangedEvent, - SelectionChangedEvent, - TransformModeChangedEvent, - TransformSpaceChangedEvent, -} from "./events/EngineEvent"; - -export type EngineStoreBridgeDeps = { - bus: EngineEventBus; -}; - -export class EngineStoreBridge { - private unsubscribeAll: () => void; - - constructor(deps: EngineStoreBridgeDeps) { - const { bus } = deps; - const unsubs: Array<() => void> = [ - // selection / inspector - bus.subscribe(SelectionChangedEvent, (e) => - usePageSceneStore.getState().setSelectedObject(e.selection), - ), - bus.subscribe(InspectorPanelChangedEvent, (e) => { - const store = usePageSceneStore.getState(); - if (e.panel) { - store.updateObjectPanel(e.panel); - store.showObjectPanel(e.panel); - } else { - store.hideObjectPanel(); - } - }), - - // outliner - bus.subscribe(OutlinerRefreshedEvent, (e) => - usePageSceneStore.getState().setOutlinerItems(e.items), - ), - bus.subscribe(OutlinerSelectedItemChangedEvent, (e) => - usePageSceneStore.getState().setOutlinerSelectedItem(e.item), - ), - bus.subscribe(OutlinerItemLockToggledEvent, (e) => - usePageSceneStore.getState().toggleOutlinerLock(e.uuid), - ), - bus.subscribe(OutlinerItemVisibilityToggledEvent, (e) => - usePageSceneStore.getState().toggleOutlinerVisibility(e.uuid), - ), - - // scene tree - bus.subscribe(ObjectAddedEvent, (e) => { - const store = usePageSceneStore.getState(); - if (e.object.kind === "character") store.addCharacter(e.object); - else if (e.object.kind === "shape") store.addShape(e.object); - else store.addObject(e.object); - }), - bus.subscribe(ObjectRemovedEvent, (e) => - usePageSceneStore.getState().removeSceneObject(e.uuid), - ), - bus.subscribe(SceneResetEvent, () => - usePageSceneStore.getState().resetScene(), - ), - - // mode / overlays - bus.subscribe(TransformModeChangedEvent, (e) => - usePageSceneStore.getState().setTransformMode(e.mode), - ), - bus.subscribe(TransformSpaceChangedEvent, (e) => - usePageSceneStore.getState().setTransformSpace(e.space), - ), - bus.subscribe(SelectedModeChangedEvent, (e) => - usePageSceneStore.getState().setSelectedMode(e.mode), - ), - bus.subscribe(EditorStateChangedEvent, (e) => - usePageSceneStore.getState().setEditorState(e.state), - ), - bus.subscribe(EditorLoaderEvent, (e) => { - const store = usePageSceneStore.getState(); - if (e.visible) store.showEditorLoader(e.message); - else store.hideEditorLoader(); - }), - bus.subscribe(PoseControlsVisibilityChangedEvent, (e) => - usePageSceneStore.getState().setShowPoseControls(e.visible), - ), - bus.subscribe(PoseModeChangedEvent, (e) => - usePageSceneStore.getState().setPoseMode(e.mode), - ), - bus.subscribe(AssetModalVisibilityChangedEvent, (e) => { - const store = usePageSceneStore.getState(); - store.setAssetModalVisible(e.visible); - store.setAssetModalVisibleDuringDrag(e.visibleDuringDrag); - }), - bus.subscribe(GridVisibleChangedEvent, (e) => - usePageSceneStore.getState().setGridVisible(e.visible), - ), - - // camera - bus.subscribe(CameraAspectRatioChangedEvent, (e) => - usePageSceneStore.getState().setCameraAspectRatio(e.ratio), - ), - bus.subscribe(CamerasReplacedEvent, (e) => - usePageSceneStore.setState({ cameras: e.cameras }), - ), - bus.subscribe(SelectedCameraChangedEvent, (e) => - usePageSceneStore.getState().setSelectedCameraId(e.cameraId), - ), - bus.subscribe(CameraUpdatedEvent, (e) => - usePageSceneStore.getState().updateCamera(e.cameraId, e.updates), - ), - - // lifecycle - bus.subscribe(EngineInitializedEvent, (e) => - usePageSceneStore.getState().setIs3DEditorInitialized(e.initialized), - ), - bus.subscribe(SceneLoadedEvent, (e) => - usePageSceneStore.getState().setIs3DSceneLoaded(e.loaded), - ), - ]; - - this.unsubscribeAll = () => { - for (const u of unsubs) u(); - }; - } - - dispose(): void { - this.unsubscribeAll(); - } -} +// Re-export shim. Single source of truth lives in +// @storyteller/ui-pagescene. +export { + EngineStoreBridge, +} from "@storyteller/ui-pagescene"; +export type { + EngineStoreBridgeDeps, +} from "@storyteller/ui-pagescene"; diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/events/EngineEvent.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/events/EngineEvent.ts index 778f4720c8..82c617e2de 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/events/EngineEvent.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/events/EngineEvent.ts @@ -1,183 +1,4 @@ -// Typed event protocol for the 3D engine. -// -// Architecture (Command/polymorphism — same shape as engine/editor/ -// actions/UndoableAction): one class per event kind. Engine sites emit -// `bus.emit(new SelectionChangedEvent(...))`; EngineStoreBridge is the -// single subscriber that translates events to Zustand store mutations. -// Adding a new event kind = one new class file here + one new branch -// in EngineStoreBridge.handle. No other files change. -// -// Engine code never imports the Zustand store; emitting events is the -// only way for the engine to push state changes outward. - -import type { CameraAspectRatio, EditorStates } from "../../enums"; -import type { - ObjectPanelObject, - OutlinerItem, - PoseMode, - SceneObject, - SelectedSceneObject, - TransformMode, - TransformSpace, -} from "../../PageSceneStore"; -import type { Camera } from "@storyteller/common"; - -export abstract class EngineEvent { - readonly timestamp: number = performance.now(); -} - -// ─── lifecycle ──────────────────────────────────────────────────────── - -export class EngineInitializedEvent extends EngineEvent { - constructor(readonly initialized: boolean) { - super(); - } -} - -export class SceneLoadedEvent extends EngineEvent { - constructor(readonly loaded: boolean) { - super(); - } -} - -export class SceneResetEvent extends EngineEvent {} - -// ─── selection / inspector ───────────────────────────────────────────── - -export class SelectionChangedEvent extends EngineEvent { - constructor(readonly selection: SelectedSceneObject | null) { - super(); - } -} - -export class InspectorPanelChangedEvent extends EngineEvent { - constructor(readonly panel: ObjectPanelObject | null) { - super(); - } -} - -// ─── scene tree ──────────────────────────────────────────────────────── - -export class ObjectAddedEvent extends EngineEvent { - constructor(readonly object: SceneObject) { - super(); - } -} - -export class ObjectRemovedEvent extends EngineEvent { - constructor(readonly uuid: string) { - super(); - } -} - -export class OutlinerRefreshedEvent extends EngineEvent { - constructor(readonly items: OutlinerItem[]) { - super(); - } -} - -export class OutlinerSelectedItemChangedEvent extends EngineEvent { - constructor(readonly item: OutlinerItem | null) { - super(); - } -} - -export class OutlinerItemLockToggledEvent extends EngineEvent { - constructor(readonly uuid: string) { - super(); - } -} - -export class OutlinerItemVisibilityToggledEvent extends EngineEvent { - constructor(readonly uuid: string) { - super(); - } -} - -// ─── mode / transform / overlays ────────────────────────────────────── - -export class TransformModeChangedEvent extends EngineEvent { - constructor(readonly mode: TransformMode) { - super(); - } -} - -export class TransformSpaceChangedEvent extends EngineEvent { - constructor(readonly space: TransformSpace) { - super(); - } -} - -export class SelectedModeChangedEvent extends EngineEvent { - constructor(readonly mode: string) { - super(); - } -} - -export class EditorStateChangedEvent extends EngineEvent { - constructor(readonly state: EditorStates) { - super(); - } -} - -export class EditorLoaderEvent extends EngineEvent { - constructor(readonly visible: boolean, readonly message?: string) { - super(); - } -} - -export class PoseControlsVisibilityChangedEvent extends EngineEvent { - constructor(readonly visible: boolean) { - super(); - } -} - -export class PoseModeChangedEvent extends EngineEvent { - constructor(readonly mode: PoseMode) { - super(); - } -} - -export class AssetModalVisibilityChangedEvent extends EngineEvent { - constructor( - readonly visible: boolean, - readonly visibleDuringDrag: boolean, - ) { - super(); - } -} - -export class GridVisibleChangedEvent extends EngineEvent { - constructor(readonly visible: boolean) { - super(); - } -} - -// ─── camera ──────────────────────────────────────────────────────────── - -export class CameraAspectRatioChangedEvent extends EngineEvent { - constructor(readonly ratio: CameraAspectRatio) { - super(); - } -} - -export class CamerasReplacedEvent extends EngineEvent { - constructor(readonly cameras: Camera[]) { - super(); - } -} - -export class SelectedCameraChangedEvent extends EngineEvent { - constructor(readonly cameraId: string) { - super(); - } -} - -export class CameraUpdatedEvent extends EngineEvent { - constructor( - readonly cameraId: string, - readonly updates: Partial, - ) { - super(); - } -} +// Re-export shim. Single source of truth lives in +// @storyteller/ui-pagescene; class identity is single-sourced so +// emit/subscribe lookups share the same constructor key. +export * from "@storyteller/ui-pagescene"; diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/events/EngineEventBus.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/events/EngineEventBus.ts index a338ad44b8..d63ca6bd52 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/events/EngineEventBus.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/events/EngineEventBus.ts @@ -1,50 +1,9 @@ -// Per-event-type pub/sub for EngineEvent. Mirrors Chromium's -// EventListenerMap: each event class has its own listener set, and -// emit() walks only that set — no global fan-out, no instanceof -// chains in subscribers. We key by constructor reference instead of -// the DOM's string `type` because identity is faster than string -// hashing and we have no user-visible type strings to intern. -// -// emit() iterates the Set directly. ES Set iteration is well-defined -// under concurrent add/delete (deleted entries skipped, added entries -// visited later in the same iteration) — fine for our engine, since -// no current subscriber self-modifies the listener set mid-dispatch. - -import type { EngineEvent } from "./EngineEvent"; - -export type EngineEventCtor = new ( - ...args: never[] -) => T; - -export type EngineEventListener = ( - event: T, -) => void; - -export class EngineEventBus { - private byCtor = new Map< - EngineEventCtor, - Set> - >(); - - subscribe( - ctor: EngineEventCtor, - listener: EngineEventListener, - ): () => void { - let set = this.byCtor.get(ctor); - if (!set) { - set = new Set(); - this.byCtor.set(ctor, set); - } - const erased = listener as EngineEventListener; - set.add(erased); - return () => { - set!.delete(erased); - }; - } - - emit(event: T): void { - const set = this.byCtor.get(event.constructor as EngineEventCtor); - if (!set) return; - for (const fn of set) fn(event); - } -} +// Re-export shim. Single source of truth lives in +// @storyteller/ui-pagescene. +export { + EngineEventBus, +} from "@storyteller/ui-pagescene"; +export type { + EngineEventCtor, + EngineEventListener, +} from "@storyteller/ui-pagescene"; diff --git a/frontend/libs/components/lightbox-modal/tsconfig.json b/frontend/libs/components/lightbox-modal/tsconfig.json index 8e831d8302..4714534703 100644 --- a/frontend/libs/components/lightbox-modal/tsconfig.json +++ b/frontend/libs/components/lightbox-modal/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../toaster" }, + { + "path": "../gravatar" + }, { "path": "../../api" }, diff --git a/frontend/libs/components/lightbox-modal/tsconfig.lib.json b/frontend/libs/components/lightbox-modal/tsconfig.lib.json index de2edb6234..fa3c7f261f 100644 --- a/frontend/libs/components/lightbox-modal/tsconfig.lib.json +++ b/frontend/libs/components/lightbox-modal/tsconfig.lib.json @@ -55,6 +55,9 @@ { "path": "../toaster/tsconfig.lib.json" }, + { + "path": "../gravatar/tsconfig.lib.json" + }, { "path": "../../api/tsconfig.lib.json" }, diff --git a/frontend/libs/components/pagescene/src/index.ts b/frontend/libs/components/pagescene/src/index.ts index 73a1f60d0c..54b8dd8324 100644 --- a/frontend/libs/components/pagescene/src/index.ts +++ b/frontend/libs/components/pagescene/src/index.ts @@ -41,3 +41,42 @@ export type { XYZ, Simple3DVector } from "./lib/datastructures/common"; // Models (lib-internal types — only what's needed at the public boundary) export type { MediaItem, AudioMediaItem } from "./lib/models/assets"; + +// Engine event bus + event classes (host wrappers can emit +// GridVisibleChangedEvent etc. through editor.bus to keep the +// one-way write flow). Class identity is single-sourced from the +// lib so emit/subscribe lookups share the same constructor key. +export { EngineEventBus } from "./lib/engine/events/EngineEventBus"; +export { EngineStoreBridge } from "./lib/engine/EngineStoreBridge"; +export type { EngineStoreBridgeDeps } from "./lib/engine/EngineStoreBridge"; +export type { + EngineEventCtor, + EngineEventListener, +} from "./lib/engine/events/EngineEventBus"; +export { + EngineEvent, + EngineInitializedEvent, + SceneLoadedEvent, + SceneResetEvent, + SelectionChangedEvent, + InspectorPanelChangedEvent, + ObjectAddedEvent, + ObjectRemovedEvent, + OutlinerRefreshedEvent, + OutlinerSelectedItemChangedEvent, + OutlinerItemLockToggledEvent, + OutlinerItemVisibilityToggledEvent, + TransformModeChangedEvent, + TransformSpaceChangedEvent, + SelectedModeChangedEvent, + EditorStateChangedEvent, + EditorLoaderEvent, + PoseControlsVisibilityChangedEvent, + PoseModeChangedEvent, + AssetModalVisibilityChangedEvent, + GridVisibleChangedEvent, + CameraAspectRatioChangedEvent, + CamerasReplacedEvent, + SelectedCameraChangedEvent, + CameraUpdatedEvent, +} from "./lib/engine/events/EngineEvent"; diff --git a/frontend/libs/components/pagescene/src/lib/engine/EngineStoreBridge.ts b/frontend/libs/components/pagescene/src/lib/engine/EngineStoreBridge.ts new file mode 100644 index 0000000000..30e42772e7 --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/engine/EngineStoreBridge.ts @@ -0,0 +1,157 @@ +// EngineStoreBridge — single boundary between the 3D engine and the +// Zustand store. It is the ONLY file inside engine/ that imports +// usePageSceneStore. Engine subsystems emit typed EngineEvents on the +// bus; this bridge subscribes once per event class at construction and +// translates each event into a one-line store mutation. +// +// One handler per event class — no instanceof chains. The bus routes +// each emit to the matching handler set in O(1) (Map lookup), so the +// hot per-frame events (CameraUpdatedEvent, etc.) avoid the linear +// type-guard scan that the old switch-style had. + +import { usePageSceneStore } from "../PageSceneStore"; +import { EngineEventBus } from "./events/EngineEventBus"; +import { + AssetModalVisibilityChangedEvent, + CameraAspectRatioChangedEvent, + CameraUpdatedEvent, + CamerasReplacedEvent, + EditorLoaderEvent, + EditorStateChangedEvent, + EngineInitializedEvent, + GridVisibleChangedEvent, + InspectorPanelChangedEvent, + ObjectAddedEvent, + ObjectRemovedEvent, + OutlinerItemLockToggledEvent, + OutlinerItemVisibilityToggledEvent, + OutlinerRefreshedEvent, + OutlinerSelectedItemChangedEvent, + PoseControlsVisibilityChangedEvent, + PoseModeChangedEvent, + SceneLoadedEvent, + SceneResetEvent, + SelectedCameraChangedEvent, + SelectedModeChangedEvent, + SelectionChangedEvent, + TransformModeChangedEvent, + TransformSpaceChangedEvent, +} from "./events/EngineEvent"; + +export type EngineStoreBridgeDeps = { + bus: EngineEventBus; +}; + +export class EngineStoreBridge { + private unsubscribeAll: () => void; + + constructor(deps: EngineStoreBridgeDeps) { + const { bus } = deps; + const unsubs: Array<() => void> = [ + // selection / inspector + bus.subscribe(SelectionChangedEvent, (e) => + usePageSceneStore.getState().setSelectedObject(e.selection), + ), + bus.subscribe(InspectorPanelChangedEvent, (e) => { + const store = usePageSceneStore.getState(); + if (e.panel) { + store.updateObjectPanel(e.panel); + store.showObjectPanel(e.panel); + } else { + store.hideObjectPanel(); + } + }), + + // outliner + bus.subscribe(OutlinerRefreshedEvent, (e) => + usePageSceneStore.getState().setOutlinerItems(e.items), + ), + bus.subscribe(OutlinerSelectedItemChangedEvent, (e) => + usePageSceneStore.getState().setOutlinerSelectedItem(e.item), + ), + bus.subscribe(OutlinerItemLockToggledEvent, (e) => + usePageSceneStore.getState().toggleOutlinerLock(e.uuid), + ), + bus.subscribe(OutlinerItemVisibilityToggledEvent, (e) => + usePageSceneStore.getState().toggleOutlinerVisibility(e.uuid), + ), + + // scene tree + bus.subscribe(ObjectAddedEvent, (e) => { + const store = usePageSceneStore.getState(); + if (e.object.kind === "character") store.addCharacter(e.object); + else if (e.object.kind === "shape") store.addShape(e.object); + else store.addObject(e.object); + }), + bus.subscribe(ObjectRemovedEvent, (e) => + usePageSceneStore.getState().removeSceneObject(e.uuid), + ), + bus.subscribe(SceneResetEvent, () => + usePageSceneStore.getState().resetScene(), + ), + + // mode / overlays + bus.subscribe(TransformModeChangedEvent, (e) => + usePageSceneStore.getState().setTransformMode(e.mode), + ), + bus.subscribe(TransformSpaceChangedEvent, (e) => + usePageSceneStore.getState().setTransformSpace(e.space), + ), + bus.subscribe(SelectedModeChangedEvent, (e) => + usePageSceneStore.getState().setSelectedMode(e.mode), + ), + bus.subscribe(EditorStateChangedEvent, (e) => + usePageSceneStore.getState().setEditorState(e.state), + ), + bus.subscribe(EditorLoaderEvent, (e) => { + const store = usePageSceneStore.getState(); + if (e.visible) store.showEditorLoader(e.message); + else store.hideEditorLoader(); + }), + bus.subscribe(PoseControlsVisibilityChangedEvent, (e) => + usePageSceneStore.getState().setShowPoseControls(e.visible), + ), + bus.subscribe(PoseModeChangedEvent, (e) => + usePageSceneStore.getState().setPoseMode(e.mode), + ), + bus.subscribe(AssetModalVisibilityChangedEvent, (e) => { + const store = usePageSceneStore.getState(); + store.setAssetModalVisible(e.visible); + store.setAssetModalVisibleDuringDrag(e.visibleDuringDrag); + }), + bus.subscribe(GridVisibleChangedEvent, (e) => + usePageSceneStore.getState().setGridVisible(e.visible), + ), + + // camera + bus.subscribe(CameraAspectRatioChangedEvent, (e) => + usePageSceneStore.getState().setCameraAspectRatio(e.ratio), + ), + bus.subscribe(CamerasReplacedEvent, (e) => + usePageSceneStore.setState({ cameras: e.cameras }), + ), + bus.subscribe(SelectedCameraChangedEvent, (e) => + usePageSceneStore.getState().setSelectedCameraId(e.cameraId), + ), + bus.subscribe(CameraUpdatedEvent, (e) => + usePageSceneStore.getState().updateCamera(e.cameraId, e.updates), + ), + + // lifecycle + bus.subscribe(EngineInitializedEvent, (e) => + usePageSceneStore.getState().setIs3DEditorInitialized(e.initialized), + ), + bus.subscribe(SceneLoadedEvent, (e) => + usePageSceneStore.getState().setIs3DSceneLoaded(e.loaded), + ), + ]; + + this.unsubscribeAll = () => { + for (const u of unsubs) u(); + }; + } + + dispose(): void { + this.unsubscribeAll(); + } +} diff --git a/frontend/libs/components/pagescene/src/lib/engine/events/EngineEvent.ts b/frontend/libs/components/pagescene/src/lib/engine/events/EngineEvent.ts new file mode 100644 index 0000000000..778f4720c8 --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/engine/events/EngineEvent.ts @@ -0,0 +1,183 @@ +// Typed event protocol for the 3D engine. +// +// Architecture (Command/polymorphism — same shape as engine/editor/ +// actions/UndoableAction): one class per event kind. Engine sites emit +// `bus.emit(new SelectionChangedEvent(...))`; EngineStoreBridge is the +// single subscriber that translates events to Zustand store mutations. +// Adding a new event kind = one new class file here + one new branch +// in EngineStoreBridge.handle. No other files change. +// +// Engine code never imports the Zustand store; emitting events is the +// only way for the engine to push state changes outward. + +import type { CameraAspectRatio, EditorStates } from "../../enums"; +import type { + ObjectPanelObject, + OutlinerItem, + PoseMode, + SceneObject, + SelectedSceneObject, + TransformMode, + TransformSpace, +} from "../../PageSceneStore"; +import type { Camera } from "@storyteller/common"; + +export abstract class EngineEvent { + readonly timestamp: number = performance.now(); +} + +// ─── lifecycle ──────────────────────────────────────────────────────── + +export class EngineInitializedEvent extends EngineEvent { + constructor(readonly initialized: boolean) { + super(); + } +} + +export class SceneLoadedEvent extends EngineEvent { + constructor(readonly loaded: boolean) { + super(); + } +} + +export class SceneResetEvent extends EngineEvent {} + +// ─── selection / inspector ───────────────────────────────────────────── + +export class SelectionChangedEvent extends EngineEvent { + constructor(readonly selection: SelectedSceneObject | null) { + super(); + } +} + +export class InspectorPanelChangedEvent extends EngineEvent { + constructor(readonly panel: ObjectPanelObject | null) { + super(); + } +} + +// ─── scene tree ──────────────────────────────────────────────────────── + +export class ObjectAddedEvent extends EngineEvent { + constructor(readonly object: SceneObject) { + super(); + } +} + +export class ObjectRemovedEvent extends EngineEvent { + constructor(readonly uuid: string) { + super(); + } +} + +export class OutlinerRefreshedEvent extends EngineEvent { + constructor(readonly items: OutlinerItem[]) { + super(); + } +} + +export class OutlinerSelectedItemChangedEvent extends EngineEvent { + constructor(readonly item: OutlinerItem | null) { + super(); + } +} + +export class OutlinerItemLockToggledEvent extends EngineEvent { + constructor(readonly uuid: string) { + super(); + } +} + +export class OutlinerItemVisibilityToggledEvent extends EngineEvent { + constructor(readonly uuid: string) { + super(); + } +} + +// ─── mode / transform / overlays ────────────────────────────────────── + +export class TransformModeChangedEvent extends EngineEvent { + constructor(readonly mode: TransformMode) { + super(); + } +} + +export class TransformSpaceChangedEvent extends EngineEvent { + constructor(readonly space: TransformSpace) { + super(); + } +} + +export class SelectedModeChangedEvent extends EngineEvent { + constructor(readonly mode: string) { + super(); + } +} + +export class EditorStateChangedEvent extends EngineEvent { + constructor(readonly state: EditorStates) { + super(); + } +} + +export class EditorLoaderEvent extends EngineEvent { + constructor(readonly visible: boolean, readonly message?: string) { + super(); + } +} + +export class PoseControlsVisibilityChangedEvent extends EngineEvent { + constructor(readonly visible: boolean) { + super(); + } +} + +export class PoseModeChangedEvent extends EngineEvent { + constructor(readonly mode: PoseMode) { + super(); + } +} + +export class AssetModalVisibilityChangedEvent extends EngineEvent { + constructor( + readonly visible: boolean, + readonly visibleDuringDrag: boolean, + ) { + super(); + } +} + +export class GridVisibleChangedEvent extends EngineEvent { + constructor(readonly visible: boolean) { + super(); + } +} + +// ─── camera ──────────────────────────────────────────────────────────── + +export class CameraAspectRatioChangedEvent extends EngineEvent { + constructor(readonly ratio: CameraAspectRatio) { + super(); + } +} + +export class CamerasReplacedEvent extends EngineEvent { + constructor(readonly cameras: Camera[]) { + super(); + } +} + +export class SelectedCameraChangedEvent extends EngineEvent { + constructor(readonly cameraId: string) { + super(); + } +} + +export class CameraUpdatedEvent extends EngineEvent { + constructor( + readonly cameraId: string, + readonly updates: Partial, + ) { + super(); + } +} diff --git a/frontend/libs/components/pagescene/src/lib/engine/events/EngineEventBus.ts b/frontend/libs/components/pagescene/src/lib/engine/events/EngineEventBus.ts new file mode 100644 index 0000000000..a338ad44b8 --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/engine/events/EngineEventBus.ts @@ -0,0 +1,50 @@ +// Per-event-type pub/sub for EngineEvent. Mirrors Chromium's +// EventListenerMap: each event class has its own listener set, and +// emit() walks only that set — no global fan-out, no instanceof +// chains in subscribers. We key by constructor reference instead of +// the DOM's string `type` because identity is faster than string +// hashing and we have no user-visible type strings to intern. +// +// emit() iterates the Set directly. ES Set iteration is well-defined +// under concurrent add/delete (deleted entries skipped, added entries +// visited later in the same iteration) — fine for our engine, since +// no current subscriber self-modifies the listener set mid-dispatch. + +import type { EngineEvent } from "./EngineEvent"; + +export type EngineEventCtor = new ( + ...args: never[] +) => T; + +export type EngineEventListener = ( + event: T, +) => void; + +export class EngineEventBus { + private byCtor = new Map< + EngineEventCtor, + Set> + >(); + + subscribe( + ctor: EngineEventCtor, + listener: EngineEventListener, + ): () => void { + let set = this.byCtor.get(ctor); + if (!set) { + set = new Set(); + this.byCtor.set(ctor, set); + } + const erased = listener as EngineEventListener; + set.add(erased); + return () => { + set!.delete(erased); + }; + } + + emit(event: T): void { + const set = this.byCtor.get(event.constructor as EngineEventCtor); + if (!set) return; + for (const fn of set) fn(event); + } +} diff --git a/frontend/libs/components/pagescene/tsconfig.json b/frontend/libs/components/pagescene/tsconfig.json index 162ab9faec..ff50143b12 100644 --- a/frontend/libs/components/pagescene/tsconfig.json +++ b/frontend/libs/components/pagescene/tsconfig.json @@ -2,12 +2,6 @@ "files": [], "include": [], "references": [ - { - "path": "../../common" - }, - { - "path": "../../api" - }, { "path": "../../api-enums" }, @@ -15,49 +9,7 @@ "path": "../../model-list" }, { - "path": "../../soundboard" - }, - { - "path": "../promptbox" - }, - { - "path": "../popover" - }, - { - "path": "../loading" - }, - { - "path": "../toaster" - }, - { - "path": "../gallery-modal" - }, - { - "path": "../model-selector" - }, - { - "path": "../help-menu" - }, - { - "path": "../login-modal" - }, - { - "path": "../settings-modal" - }, - { - "path": "../modal" - }, - { - "path": "../tooltip" - }, - { - "path": "../button" - }, - { - "path": "../input" - }, - { - "path": "../slider-v2" + "path": "../../common" }, { "path": "./tsconfig.lib.json" diff --git a/frontend/libs/components/pagescene/tsconfig.lib.json b/frontend/libs/components/pagescene/tsconfig.lib.json index 17841e8e11..4499a4cef3 100644 --- a/frontend/libs/components/pagescene/tsconfig.lib.json +++ b/frontend/libs/components/pagescene/tsconfig.lib.json @@ -44,12 +44,6 @@ "src/**/*.tsx" ], "references": [ - { - "path": "../../common/tsconfig.lib.json" - }, - { - "path": "../../api/tsconfig.lib.json" - }, { "path": "../../api-enums/tsconfig.lib.json" }, @@ -57,49 +51,7 @@ "path": "../../model-list/tsconfig.lib.json" }, { - "path": "../../soundboard/tsconfig.lib.json" - }, - { - "path": "../promptbox/tsconfig.lib.json" - }, - { - "path": "../popover/tsconfig.lib.json" - }, - { - "path": "../loading/tsconfig.lib.json" - }, - { - "path": "../toaster/tsconfig.lib.json" - }, - { - "path": "../gallery-modal/tsconfig.lib.json" - }, - { - "path": "../model-selector/tsconfig.lib.json" - }, - { - "path": "../help-menu/tsconfig.lib.json" - }, - { - "path": "../login-modal/tsconfig.lib.json" - }, - { - "path": "../settings-modal/tsconfig.lib.json" - }, - { - "path": "../modal/tsconfig.lib.json" - }, - { - "path": "../tooltip/tsconfig.lib.json" - }, - { - "path": "../button/tsconfig.lib.json" - }, - { - "path": "../input/tsconfig.lib.json" - }, - { - "path": "../slider-v2/tsconfig.lib.json" + "path": "../../common/tsconfig.lib.json" } ] } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index af096a2048..ed31bb3e37 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -188,6 +188,9 @@ }, { "path": "./libs/components/checkbox" + }, + { + "path": "./libs/components/pagescene" } ] }