StackLight Refactoring Plan v3 — Final

Domain stays framework-agnostic. Babylon wiring lives in the View layer.
Async factory makeStackLightBabylonView encapsulates scene resolution + asset loading + mesh binding + PM subscription.
One consumer call: await createBabylonStackLight(id, appObjects)
Version bump: 1.0.1 1.1.0

170→35
Lines in dev/main.ts
1
Consumer call to create
0
Args to view.load()
+3
New files
3
Modified src files

Key Design Decisions

💡 Decision 1: Domain factory stays pure.
The StackLightFeatureFactory's instance factory creates Entity + UCs + PM only. No Babylon imports. No framework knowledge. This is domain setup.
💡 Decision 2: Framework wiring is separate.
createBabylonStackLight(id, appObjects) creates the domain instance via the repo, then adds the Babylon view via the async makeStackLightBabylonView(). This opens the door to createThreeJsStackLight, createReactStackLight, etc.
💡 Decision 3: Async factory encapsulates load.
makeStackLightBabylonView(ao) becomes async. It instantiates the View, then calls view.load() internally — resolving Scene from BabylonEntity singleton, fetching the GLB, importing meshes, binding, and subscribing to the PM. Nobody outside the View ever calls load().

Architecture: Domain vs Framework

┌─── Domain Layer (framework-agnostic) ───────────────────────┐ StackLightFeatureFactory └─ instanceFactory(id) ├─ makeStackLightEntity(ao) ├─ makeStackLightControlUC(ao) ├─ makeStackLightBlinkLoopUC(ao) └─ makeStackLightPM(ao) createStackLight(id, appObjects) ← sync controller └─ repo.createStackLightEntity(id) └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─── Framework Layer (Babylon-specific) ──────────────────────┐ createBabylonStackLight(id, appObjects) ← async ├─ createStackLight(id, appObjects) ← domain call └─ await makeStackLightBabylonView(ao) ← async factory ├─ new StackLightViewImp(ao) └─ await view.load() ├─ BabylonEntity.get(appObjects) │ └─ cast .scene as Scene ├─ getAssetBlobURL(assetId, appObjects) ├─ SceneLoader.ImportMeshAsync(...) ├─ bindMeshes(meshes) └─ stackLightPMAdapter.subscribe(...) └─────────────────────────────────────────────────────────────┘

Consumer API: 3-Way Comparison

✗ Current (3+ manual steps)
// 1. Create entity via repo
const repo = StackLightRepo.get(appObjects);
repo.createStackLightEntity(id);

// 2. Create view manually
const ao = appObjects.getOrCreate(id);
const view = makeStackLightBabylonView(ao);
await view.setupView();

// 3. Load GLB + bind meshes
const blobURL = await getAssetBlobURL(
  assetId, appObjects
);
const result = await SceneLoader
  .ImportMeshAsync(
    "", blobURL, "", scene,
    undefined, ".glb"
  );
view.bindMeshes(result.meshes);
ABB6700 Pattern (2 steps)
// 1. Create instance
const ao = createABB6700(id, appObjects);

// 2. Load (must pass scene)
const view = ABB6700BabylonView.get(ao);
await view.load(scene);




// Consumer must have a Scene ref
// and pass it in explicitly.
// View is created by the domain
// factory (coupled).

✓ Target v3 (1 call)
// One line. Done.
const ao = await createBabylonStackLight(
  id, appObjects,
);




// No scene param.
// No view.load() call.
// No framework knowledge needed.
// Domain and Babylon cleanly
// separated.

File Impact Map

dev/ ├── main.ts ← REWRITE (170 → ~35 lines) ├── setupBabylon.ts ← NEW (extracted scene setup) └── setupInspector.ts ← NEW (extracted inspector logic) src/ ├── createBabylonStackLight.ts ← NEW (framework-layer entry point) ├── index.ts ← ADD exports ├── component.config.ts ├── Controllers/ │ ├── createStackLight.ts (no changes — stays sync, domain-only) │ ├── setLightActive.ts │ ├── setLightBlinking.ts │ └── turnAllOff.ts ├── Entities/ (no changes) ├── Factory/ │ └── StackLightFeatureFactory.ts (no changes — domain stays pure) ├── PMs/ (no changes) ├── UCs/ (no changes) └── Views/ └── StackLightBabylonView.ts ← REFACTOR: async factory + zero-arg load()
✔ Note: StackLightFeatureFactory.ts is unchanged in v3. The domain instance factory stays inline, creating Entity + UCs + PM. No setupStackLightInstanceFactory.ts needed.

Phase 1: Refactor View — Async Factory + Zero-Arg Load

Step 1 Refactor src/Views/StackLightBabylonView.ts

Two changes: (a) load() takes zero arguments, resolves scene from BabylonEntity; (b) makeStackLightBabylonView becomes async and calls load() internally.

Abstract base class

Before
export abstract class StackLightBabylonView
  extends AppObjectView
{
  static readonly type = "StackLightBabylonView";

  abstract setupView(): Promise<void>;
  abstract bindMeshes(meshes: AbstractMesh[]): void;

  static get(appObj: AppObject)
    : StackLightBabylonView | undefined {
    return appObj.getComponent<StackLightBabylonView>(
      this.type,
    );
  }
}
After
export abstract class StackLightBabylonView
  extends AppObjectView
{
  static readonly type = "StackLightBabylonView";

  /** Fully self-contained — resolves scene,
    * loads GLB, binds meshes, subscribes PM */
  abstract load(): Promise<void>;

  static get(appObj: AppObject)
    : StackLightBabylonView | undefined {
    return appObj.getComponent<StackLightBabylonView>(
      this.type,
    );
  }
}

Factory function — becomes async

Before
export function makeStackLightBabylonView(
  appObject: AppObject,
): StackLightBabylonView {
  return new StackLightViewImp(appObject);
}

// Consumer must then call:
// await view.setupView()
// await SceneLoader.ImportMeshAsync(...)
// view.bindMeshes(meshes)
After
export async function makeStackLightBabylonView(
  appObject: AppObject,
): Promise<StackLightBabylonView> {
  const view = new StackLightViewImp(appObject);
  await view.load();
  return view;
}

// Nothing else to call — fully loaded on return.

Implementation: load() — zero arguments, fully self-contained

New load() implementation
import { BabylonEntity, getAssetBlobURL } from "@vived/app";
import { SceneLoader } from "@babylonjs/core";
import type { Scene } from "@babylonjs/core";
import componentConfig from "../component.config";

async load(): Promise<void> {
  // 1. Resolve Babylon Scene from the platform singleton
  const babylonEntity = BabylonEntity.get(this.appObjects);
  if (!babylonEntity?.scene) {
    this.error("BabylonEntity not found or scene not set");
    return;
  }
  const scene = babylonEntity.scene as Scene; // local cast — View layer only

  // 2. Subscribe to PM adapter
  stackLightPMAdapter.subscribe(
    this.appObject.id,
    this.appObjects,
    this.updateView,
  );

  // 3. Fetch asset blob URL via VIVED asset pipeline
  const assetId = componentConfig.assets[0].id;
  const blobURL = await getAssetBlobURL(assetId, this.appObjects);

  // 4. Import GLB into the resolved scene
  const importResult = await SceneLoader.ImportMeshAsync(
    "", blobURL, "", scene, undefined, ".glb",
  );

  // 5. Bind meshes (private — same logic as current bindMeshes)
  this.bindMeshes(importResult.meshes);
}
ℹ Why this is safe:

Phase 2: Create Framework-Layer Entry Point

Step 2 Create src/createBabylonStackLight.ts

The single async function that creates a domain instance + adds the Babylon view. Lives at the src root — not in Controllers (it crosses layers intentionally).

New: src/createBabylonStackLight.ts
import { AppObject, AppObjectRepo } from "@vived/core";
import { StackLightRepo } from "./Entities/StackLightRepo";
import { makeStackLightBabylonView } from "./Views/StackLightBabylonView";

/**
 * Creates a fully-loaded Babylon-rendered Stack Light.
 *
 * 1. Creates the domain instance (Entity + UCs + PM) via the repo
 * 2. Adds the Babylon view (async — resolves scene, loads GLB, binds meshes)
 *
 * This is the framework-specific entry point. The domain factory
 * (StackLightFeatureFactory) stays framework-agnostic.
 */
export async function createBabylonStackLight(
  id: string,
  appObjects: AppObjectRepo,
): Promise<AppObject | undefined> {
  const repo = StackLightRepo.get(appObjects);
  if (!repo) {
    appObjects.submitWarning(
      "createBabylonStackLight",
      "StackLightRepo not found",
    );
    return undefined;
  }

  // Domain: Entity + UCs + PM (no Babylon knowledge)
  const entity = repo.createStackLightEntity(id);

  // Framework: Babylon view (async — loads everything)
  await makeStackLightBabylonView(entity.appObject);

  return entity.appObject;
}
✔ Clean separation: createBabylonStackLight imports makeStackLightBabylonView (framework layer) and StackLightRepo (domain layer). It's the bridge between them — that's its job. The domain factory never touches Babylon. The View never leaks outside this function.
Step 3 Update src/index.ts exports

Add the new entry point and promote controllers to public API.

// Framework-layer entry point (the main consumer API)
export { createBabylonStackLight } from "./createBabylonStackLight";

// Controllers (now public, matching ABB6700 pattern)
export { createStackLight } from "./Controllers/createStackLight";
export { setLightActive } from "./Controllers/setLightActive";
export { setLightBlinking } from "./Controllers/setLightBlinking";
export { turnAllOff } from "./Controllers/turnAllOff";

Phase 3: Refactor Dev App

Step 4 Create dev/setupBabylon.ts

Extract Babylon engine/scene/camera/lighting setup (~50 lines) from dev/main.ts.

New: dev/setupBabylon.ts
import {
  ArcRotateCamera, Color4, CubeTexture, Engine,
  HemisphericLight, ImageProcessingConfiguration,
  Scene, Vector3,
} from "@babylonjs/core";
import { AppObjectRepo } from "@vived/core";
import { BabylonEntity } from "@vived/app";

export function setupBabylon(
  canvas: HTMLCanvasElement,
  appObjects: AppObjectRepo,
): { engine: Engine; scene: Scene } {
  const engine = new Engine(canvas, true);
  const scene = new Scene(engine);
  scene.clearColor = new Color4(0.18, 0.18, 0.2, 1);

  // Environment
  const envTex = CubeTexture.CreateFromPrefilteredData("/studio.env", scene);
  envTex.level = 0.65;
  scene.environmentTexture = envTex;
  scene.imageProcessingConfiguration.toneMappingEnabled = true;
  scene.imageProcessingConfiguration.toneMappingType =
    ImageProcessingConfiguration.TONEMAPPING_ACES;
  scene.imageProcessingConfiguration.exposure = 0.65;

  // Camera
  const camera = new ArcRotateCamera(
    "camera", Math.PI / 2, Math.PI / 3, 5, Vector3.Zero(), scene,
  );
  camera.attachControl(canvas, true);
  camera.angularSensibilityX = 4000;
  camera.angularSensibilityY = 4000;
  camera.wheelPrecision = 120;
  camera.panningSensibility = 1500;

  // Lighting
  const mainLight = new HemisphericLight("mainLight", new Vector3(0, 1, 0), scene);
  mainLight.intensity = 0.35;

  // Register scene on BabylonEntity singleton so views can find it
  const babylonEntity = BabylonEntity.get(appObjects);
  if (babylonEntity) {
    babylonEntity.engine = engine;
    babylonEntity.scene = scene;
  }

  return { engine, scene };
}
ℹ Dev app note: In a real Slide App, the platform sets up BabylonEntity automatically. In our dev playground we do it manually inside setupBabylon so that view.load() can resolve the scene.
Step 5 Create dev/setupInspector.ts

Extract the ~80 lines of inspector property definitions and blink toggle logic.

💡 Change from current: Inspector properties move from individual meshes to the scene node — matching the ABB6700 pattern. This avoids redundant definitions on every mesh and puts controls where they belong: the scene-level inspector panel.
Current: properties on every mesh
for (const mesh of importResult.meshes) {
  defineLightInspectorProperty(mesh, "red");
  defineLightInspectorProperty(mesh, "yellow");
  defineLightInspectorProperty(mesh, "green");
  mesh.inspectableCustomProperties = [
    // ... duplicated on EVERY mesh ...
  ];
}
ABB6700: properties on the scene
Object.defineProperty(scene, `abb6700_j1`, {
  get: () => entity.j1.degrees,
  set: (deg) => setJointAngle(...),
});

(scene as any).inspectableCustomProperties = [
  // ... defined once on the scene ...
];
New: dev/setupInspector.ts
import { InspectableType, Scene } from "@babylonjs/core";
import { AppObjectRepo } from "@vived/core";
import {
  StackLightControlUC,
  StackLightBlinkLoopUC,
  StackLightRepo,
  type StackLightColor,
} from "../src";
import { setLightActive } from "../src/Controllers/setLightActive";
import { setLightBlinking } from "../src/Controllers/setLightBlinking";

export function setupInspector(
  instanceId: string,
  scene: Scene,
  appObjects: AppObjectRepo,
): void {
  const repo = StackLightRepo.get(appObjects);
  const getEntity = () => repo?.getById(instanceId);

  // Configure blink interval
  const blinkLoopUc = StackLightBlinkLoopUC.getById(instanceId, appObjects);
  if (blinkLoopUc) blinkLoopUc.blinkIntervalMs = 1000;

  // Define dynamic getters/setters on the scene node
  const colors: StackLightColor[] = ["red", "yellow", "green"];
  for (const color of colors) {
    Object.defineProperty(scene, `stackLight_${color}`, {
      configurable: true,
      enumerable: true,
      get: () => getEntity()?.getLightActive(color) ?? false,
      set: (active: boolean) => {
        setLightActive(instanceId, color, active, appObjects);
      },
    });
  }

  // Inspector UI — attached to the scene, not meshes
  (scene as unknown as Record<string, unknown>).inspectableCustomProperties = [
    {
      label: "Stack Light",
      propertyName: "stackLight_tab",
      type: InspectableType.Tab,
    },
    {
      label: "Red",
      propertyName: "stackLight_red",
      type: InspectableType.Checkbox,
    },
    {
      label: "Yellow",
      propertyName: "stackLight_yellow",
      type: InspectableType.Checkbox,
    },
    {
      label: "Green",
      propertyName: "stackLight_green",
      type: InspectableType.Checkbox,
    },
    {
      label: "Blink Red",
      propertyName: "stackLight_blink_red",
      type: InspectableType.Button,
      callback: () => {
        const entity = getEntity();
        if (!entity) return;
        setLightBlinking(
          instanceId, "red", !entity.getLightBlinking("red"), appObjects,
        );
      },
    },
    {
      label: "Blink Yellow",
      propertyName: "stackLight_blink_yellow",
      type: InspectableType.Button,
      callback: () => {
        const entity = getEntity();
        if (!entity) return;
        setLightBlinking(
          instanceId, "yellow", !entity.getLightBlinking("yellow"), appObjects,
        );
      },
    },
    {
      label: "Blink Green",
      propertyName: "stackLight_blink_green",
      type: InspectableType.Button,
      callback: () => {
        const entity = getEntity();
        if (!entity) return;
        setLightBlinking(
          instanceId, "green", !entity.getLightBlinking("green"), appObjects,
        );
      },
    },
  ];
}
Step 6 Rewrite dev/main.ts Final

170 lines → ~35 lines. One call to createBabylonStackLight. No view management.

Current dev/main.ts (170 lines)
// Inline Babylon setup (~50 lines)
const engine = new Engine(canvas, true);
const scene = new Scene(engine);
// ... camera, lights, environment ...

// Domain setup
const appObjects = makeAppObjectRepo();
// ...

// Manual entity creation via repo
const repo = StackLightRepo.get(appObjects);
repo.createStackLightEntity(STACK_LIGHT_ID);

// Manual view creation
const stackLightAO = appObjects.getOrCreate(id);
const view = makeStackLightBabylonView(stackLightAO);
await view.setupView();

// Manual asset loading (~10 lines)
const blobURL = await getAssetBlobURL(...);
const result = await SceneLoader.ImportMeshAsync(...);
view.bindMeshes(result.meshes);

// Inline inspector properties (~80 lines)
const defineLightInspectorProperty = ...
// ... toggles, checkboxes, buttons ...

// render loop, resize, cleanup
Target dev/main.ts (~35 lines)
import "@babylonjs/loaders/glTF";
import "@babylonjs/inspector";
import { makeAppObjectRepo, makeDomainFactoryRepo }
  from "@vived/core";
import {
  createBabylonStackLight,
  makeStackLightFeatureFactory,
} from "../src";
import { makeDevGetAssetBlobURLUC }
  from "./DevGetAssetBlobURLUC";
import { setupBabylon } from "./setupBabylon";
import { setupInspector } from "./setupInspector";

const INSTANCE_ID = "dev-light-1";
const canvas = document.getElementById("renderCanvas")
  as HTMLCanvasElement | null;
if (!canvas)
  throw new Error("Missing required DOM elements");

// ─── Domain Setup ─────────────────────────
const appObjects = makeAppObjectRepo();
const dfr = makeDomainFactoryRepo(appObjects);
makeStackLightFeatureFactory(appObjects);
dfr.setupDomain();
makeDevGetAssetBlobURLUC(appObjects);

// ─── Babylon Scene ────────────────────────
const { engine, scene } = setupBabylon(
  canvas, appObjects,
);

// ─── Create Babylon Stack Light ───────────
const instanceAO = await createBabylonStackLight(
  INSTANCE_ID, appObjects,
);
if (!instanceAO)
  throw new Error("Unable to create StackLight");

// ─── Inspector & Render Loop ──────────────
setupInspector(INSTANCE_ID, scene, appObjects);
await scene.debugLayer.show();
engine.runRenderLoop(() => scene.render());

Phase 4: Update Tests

Steps 7-9 Test adjustments
Test File Change Required
Views/StackLightBabylonView.test.ts (if exists) Update to test load() (no args). Mock BabylonEntity singleton + getAssetBlobURL.
createBabylonStackLight.test.ts (new) Test that it creates domain instance + Babylon view. Mock makeStackLightBabylonView.
Factory/StackLightFeatureFactory.test.ts No changes needed — domain factory is unchanged.
integration.test.ts May need update if it uses makeStackLightBabylonView (now async).

Breaking Changes Summary

⚠ API Change 1: makeStackLightBabylonView(ao) is now async — returns Promise<StackLightBabylonView>. It calls load() internally. Consumers no longer call setupView() or bindMeshes().
⚠ API Change 2: setupView() and bindMeshes(meshes) removed from public API. Replaced by private load(): Promise<void>.
⚠ API Change 3: Controllers promoted to public exports. Added: createStackLight, setLightActive, setLightBlinking, turnAllOff.
✔ New API: createBabylonStackLight(id, appObjects): Promise<AppObject | undefined> — the recommended consumer entry point.

Implementation Order & Dependencies

┌─────────────────────────────────────┐ │ Phase 1: View refactor │ Start here │ async factory + zero-arg load() │ │ (Step 1) │ └───────────────┬─────────────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ Phase 2: createBabylonStackLight │ Depends on Phase 1 │ + index.ts exports │ │ (Steps 2-3) │ └───────────────┬─────────────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ Phase 3: Dev app refactor │ Depends on Phase 2 │ setupBabylon + setupInspector │ │ + main.ts rewrite │ │ (Steps 4-6) │ └───────────────┬─────────────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ Phase 4: Tests │ Depends on Phase 1 + 2 │ (Steps 7-9) │ └─────────────────────────────────────┘

New Package Dependency

ℹ @vived/app ^6.2.0 becomes a peer dependency (for BabylonEntity + getAssetBlobURL inside the View).
Currently only used in dev/main.ts. After refactoring, the View imports it directly.
ABB6700 already lists @vived/app ^6.2.0 as a peer dep.

What Stays the Same

File Why
Factory/StackLightFeatureFactory.ts Domain factory stays pure. Instance factory creates Entity + UCs + PM only. No changes.
Controllers/createStackLight.ts Sync, framework-agnostic domain controller. Still useful for non-Babylon consumers. No changes.
Entities/* Domain entities are framework-agnostic. No changes.
UCs/* Use cases are framework-agnostic. No changes.
PMs/* Presentation managers are framework-agnostic. No changes.
Adapters/* PM adapters are framework-agnostic. No changes.

Verification Checklist

Out of Scope

Item Reason
StackLightToggleActiveUC Not exported or referenced — skip
Asset container caching Nice-to-have for multi-instance; defer
rootTransformNode accessors ABB6700 v1.1.0 feature — not needed yet
Non-Babylon view implementations Door is open, but no need to build now
setupStackLightInstanceFactory extraction Domain factory stays inline — no extraction needed