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
StackLightFeatureFactory's instance factory creates
Entity + UCs + PM only. No Babylon imports. No framework knowledge. This
is domain setup.
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.
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().
// 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);
// 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).
// 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.
StackLightFeatureFactory.ts is unchanged in
v3. The domain instance factory stays inline, creating Entity + UCs + PM.
No setupStackLightInstanceFactory.ts needed.
src/Views/StackLightBabylonView.ts
Two changes: (a) load() takes zero arguments, resolves
scene from BabylonEntity; (b)
makeStackLightBabylonView becomes
async and calls load() internally.
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,
);
}
}
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,
);
}
}
export function makeStackLightBabylonView(
appObject: AppObject,
): StackLightBabylonView {
return new StackLightViewImp(appObject);
}
// Consumer must then call:
// await view.setupView()
// await SceneLoader.ImportMeshAsync(...)
// view.bindMeshes(meshes)
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.
load() — zero arguments, fully
self-contained
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);
}
BabylonEntity is set up by the Slide App framework
before components load
as Scene cast stays in the View — the only layer
that imports @babylonjs/core
BabylonEntity is missing, load() logs
an error and returns gracefully
setupView() is removed;
bindMeshes() becomes private
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).
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;
}
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.
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";
dev/setupBabylon.ts
Extract Babylon engine/scene/camera/lighting setup (~50 lines) from
dev/main.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 };
}
BabylonEntity automatically. In our dev
playground we do it manually inside setupBabylon so that
view.load() can resolve the scene.
dev/setupInspector.ts
Extract the ~80 lines of inspector property definitions and blink toggle logic.
for (const mesh of importResult.meshes) {
defineLightInspectorProperty(mesh, "red");
defineLightInspectorProperty(mesh, "yellow");
defineLightInspectorProperty(mesh, "green");
mesh.inspectableCustomProperties = [
// ... duplicated on EVERY mesh ...
];
}
Object.defineProperty(scene, `abb6700_j1`, {
get: () => entity.j1.degrees,
set: (deg) => setJointAngle(...),
});
(scene as any).inspectableCustomProperties = [
// ... defined once on the scene ...
];
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,
);
},
},
];
}
dev/main.ts
Final
170 lines → ~35 lines. One call to
createBabylonStackLight. No view management.
// 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
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());
| 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).
|
makeStackLightBabylonView(ao) is now async —
returns Promise<StackLightBabylonView>. It calls
load() internally. Consumers no longer call
setupView() or bindMeshes().
setupView() and
bindMeshes(meshes) removed from public API. Replaced by
private load(): Promise<void>.
createStackLight,
setLightActive, setLightBlinking,
turnAllOff.
createBabylonStackLight(id, appObjects): Promise<AppObject |
undefined>
— the recommended consumer entry point.
BabylonEntity + getAssetBlobURL inside the
View).dev/main.ts. After refactoring, the
View imports it directly.@vived/app ^6.2.0 as a peer dep.
| 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. |
npm run test — all unit tests passnpm run dev — playground loads, GLB renders correctly
await createBabylonStackLight(id, appObjects) returns a
fully-loaded AppObject — one call
makeStackLightBabylonView(ao) is async and returns a loaded
view
BabylonEntity — no scene passed in
StackLightFeatureFactory unchanged — domain stays pure
npm run build — library builds, types emit correctlynpm run lint — no lint errors| 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 |