feat: Update SceneModule to manage camera state and viewport, refactor WebGL layer to utilize Scene methods

This commit is contained in:
Azgaar 2026-03-13 02:39:40 +01:00
parent 42557881bb
commit 52708e50c5
10 changed files with 128 additions and 26 deletions

View file

@ -50,12 +50,15 @@ so that split layer surfaces can share resources without depending on one map SV
- Keep the runtime compatible with the current global-variable model. New TypeScript code must use ambient globals directly, not `window` or `globalThis` wrappers.
- New TypeScript modules must follow the project Global Module Pattern: declare global, implement a class, then assign `window.ModuleName = new ModuleClass()`.
- Avoid grouped SVG buckets or speculative abstractions here. The goal is bootstrap ownership only.
- Keep the implementation lean. Do not expand this story into a broad runtime framework, compatibility bridge, or camera API beyond the host ownership needed for Story 1.1.
- Prefer direct code over wrappers. Do not add helpers, comments, or indirection unless at least two concrete call sites need them.
### Architecture Compliance
- This story is the Phase 1 foundation from the layered-map architecture: create one scene container, one defs host, and stable references before any layer split begins.
- `svg` and `viewbox` remain compatibility-era globals after this story. They must stop being the architectural source of truth, but they are not removed yet.
- The runtime defs host must live outside individual layer surfaces so later split SVG shells can reference shared resources by stable ID.
- Keep ownership narrow: this story covers bootstrap ownership only. Do not fold in layer registry, compatibility lookups, export behavior, or transform semantics.
### Project Structure Notes
@ -73,7 +76,8 @@ so that split layer surfaces can share resources without depending on one map SV
- The architecture document explicitly defers formal test work for this tranche.
- Do manual verification for fresh load, saved-map load, and relief visibility.
- If a pure helper is extracted while implementing bootstrap ownership, keep it testable, but do not expand scope into a new test suite in this story.
- Do not add Playwright coverage, browser harnesses, or new automated regression suites in this story.
- If a pure helper is extracted while implementing bootstrap ownership, keep it simple and locally testable later, but do not expand scope into a new test suite in this story.
### References

View file

@ -1,6 +1,6 @@
# Story 1.2: Add Scene Module for Shared Camera State
Status: ready-for-dev
Status: in-progress
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
@ -17,18 +17,18 @@ so that all layer surfaces consume one authoritative transform contract.
## Tasks / Subtasks
- [ ] Create a narrow Scene runtime module.
- [ ] Expose shared camera and viewport getters derived from the authoritative globals.
- [ ] Expose scene-host references established in Story 1.1 through the same contract.
- [ ] Centralize transform ownership.
- [ ] Move reusable camera-bound calculations behind Scene methods instead of ad hoc DOM reads.
- [ ] Keep `scale`, `viewX`, `viewY`, `graphWidth`, and `graphHeight` as the underlying truth during migration.
- [ ] Update runtime consumers that already operate across surfaces.
- [ ] Rewire the current WebGL layer framework to consume the Scene contract instead of reading transform meaning from DOM structure.
- [ ] Ensure zoom and pan updates publish one consistent state for all registered surfaces.
- [ ] Keep compatibility intact.
- [ ] Preserve existing `viewbox` transform application while older code still depends on it.
- [ ] Do not break callers that still read the existing globals directly.
- [x] Create a narrow Scene runtime module.
- [x] Expose shared camera and viewport getters derived from the authoritative globals.
- [x] Expose scene-host references established in Story 1.1 through the same contract.
- [x] Centralize transform ownership.
- [x] Move reusable camera-bound calculations behind Scene methods instead of ad hoc DOM reads.
- [x] Keep `scale`, `viewX`, `viewY`, `graphWidth`, and `graphHeight` as the underlying truth during migration.
- [x] Update runtime consumers that already operate across surfaces.
- [x] Rewire the current WebGL layer framework to consume the Scene contract instead of reading transform meaning from DOM structure.
- [x] Ensure zoom and pan updates publish one consistent state for all registered surfaces.
- [x] Keep compatibility intact.
- [x] Preserve existing `viewbox` transform application while older code still depends on it.
- [x] Do not break callers that still read the existing globals directly.
- [ ] Perform manual smoke verification.
- [ ] Zoom and pan keep SVG and WebGL content aligned.
- [ ] Startup and resize continue to use the correct viewport bounds.
@ -47,12 +47,14 @@ so that all layer surfaces consume one authoritative transform contract.
- Use bare ambient globals declared in `src/types/global.ts`. Do not introduce `window.scale` or `globalThis.viewX` usage.
- Keep the abstraction narrow: Scene owns shared camera and viewport state, not layer ordering, defs ownership, or export assembly.
- Prefer pure helper methods for transform math so later stories can reuse them without DOM coupling.
- Keep the module terse. Do not let `Scene` accumulate bootstrap, compatibility, registry, or export responsibilities in this story.
### Architecture Compliance
- This story implements the architecture decision that transform ownership moves from `#viewbox` to scene state.
- `viewbox` remains a compatibility-era render target, not the source of truth.
- The Scene API should be sufficient for both SVG and WebGL consumers once split surfaces arrive.
- Developer productivity is architecture here: expose only the few scene methods current consumers actually need.
### Previous Story Intelligence
@ -75,6 +77,7 @@ so that all layer surfaces consume one authoritative transform contract.
- Formal automated test work is out of scope for this tranche.
- Keep transform calculation logic pure enough for later coverage.
- Manual verification should cover zoom, pan, resize, and relief alignment.
- Do not add Playwright coverage or new browser-driven tests in this story.
### Dependencies
@ -92,12 +95,21 @@ so that all layer surfaces consume one authoritative transform contract.
### Agent Model Used
TBD
GPT-5.4
### Debug Log References
### Completion Notes List
- Story context prepared on 2026-03-13.
- Scene now exposes shared camera, viewport, and compatibility transform access while reusing the stable scene-host references from Story 1.1.
- WebGL camera sync now consumes `Scene.getCameraBounds()` and legacy zoom handling routes `viewbox` transform application through `Scene.applyViewboxTransform()`.
- Automated tests were removed and no tests or Playwright checks were run per user instruction.
- Manual smoke verification remains pending before the story can move to review.
### File List
- public/main.js
- src/modules/scene.ts
- src/modules/webgl-layer.ts
- src/modules/scene.test.ts (removed)

View file

@ -48,6 +48,7 @@ so that visibility, order, and surface ownership are managed consistently instea
- Keep the public contract minimal. Do not add export metadata yet; that belongs to Epic 4.
- Store actual surface handles, not just selectors, so later stories can mount independent SVG shells and WebGL surfaces under one contract.
- Ensure reorder application is atomic from the user perspective. Avoid partial states where one surface has moved and another has not.
- Keep the registry boring. No factory layers, schema systems, or speculative metadata beyond `id`, `kind`, `order`, `visible`, and `surface`.
### Architecture Compliance
@ -77,6 +78,7 @@ so that visibility, order, and surface ownership are managed consistently instea
- Manual verification is sufficient for this tranche.
- Verify the existing sortable Layers UI still works and that no layer disappears from the visible stack after a reorder.
- Do not add Playwright coverage or new automated test infrastructure in this story.
### Dependencies

View file

@ -47,6 +47,7 @@ so that individual layers can be created, mounted, updated, and disposed without
- Avoid generic factories or strategy trees. One lifecycle owner with SVG and WebGL-capable implementations is enough for this phase.
- Do not force feature modules to pass renderer flags around. If two surface kinds need separate logic, isolate that inside the lifecycle owner.
- Preserve the current `WebGLLayer` canvas and shared context budget.
- Keep the API compact. Do not add lifecycle hooks or extension points that this migration does not use yet.
### Architecture Compliance
@ -75,6 +76,7 @@ so that individual layers can be created, mounted, updated, and disposed without
- Manual validation is sufficient.
- Focus on mount, redraw, hide/show, and cleanup behavior for relief because that is the current live mixed-render surface.
- Do not add Playwright coverage or new automated test harnesses in this story.
### Dependencies

View file

@ -48,6 +48,7 @@ so that existing workflows keep working while code migrates to the new scene and
- Return real layer surfaces or layer-local SVG roots where available. Do not fake a new canonical SVG document.
- `queryMap(selector)` should be controlled and predictable. It must not silently reintroduce global DOM coupling as a hidden permanent pattern.
- Preserve current IDs and selectors where possible so callers can migrate incrementally.
- Keep the bridge thin and temporary. Do not add convenience helpers beyond the three architecture-approved lookups.
### Architecture Compliance
@ -78,6 +79,7 @@ so that existing workflows keep working while code migrates to the new scene and
- Manual verification is sufficient.
- Validate save/export, labels/text paths, and at least one legacy selector-heavy workflow after the helpers are introduced.
- Do not add Playwright coverage or new automated regression suites in this story.
### Dependencies

View file

@ -52,6 +52,7 @@ so that split surfaces can keep using stable IDs for filters, masks, symbols, ma
- The dedicated defs host must be reachable by all layer surfaces after the map DOM is split.
- Prefer one focused defs owner module over scattered DOM writes.
- Avoid changing export behavior in this story beyond what is necessary to keep runtime resources consistent for later work.
- Keep defs ownership isolated. Do not use this story to introduce broader compatibility or export abstractions.
### Architecture Compliance
@ -83,6 +84,7 @@ so that split surfaces can keep using stable IDs for filters, masks, symbols, ma
- Manual validation is sufficient.
- Check at least one feature path mask, one text-path label case, one marker or symbol reference, and fogging/filter behavior.
- Do not add Playwright coverage or new automated browser tests in this story.
### Dependencies

View file

@ -42,7 +42,7 @@ story_location: /Users/azgaar/Fantasy-Map-Generator/_bmad-output/implementation-
development_status:
epic-1: in-progress
1-1-bootstrap-scene-container-and-defs-host: in-progress
1-2-add-scene-module-for-shared-camera-state: ready-for-dev
1-2-add-scene-module-for-shared-camera-state: in-progress
1-3-add-layers-registry-as-the-ordering-source-of-truth: ready-for-dev
1-4-add-layer-surface-lifecycle-ownership: ready-for-dev
1-5-add-compatibility-lookups-for-legacy-single-svg-callers: ready-for-dev

View file

@ -450,7 +450,7 @@ function findBurgForMFCG(params) {
}
function handleZoom(isScaleChanged, isPositionChanged) {
viewbox.attr("transform", `translate(${viewX} ${viewY}) scale(${scale})`);
Scene.applyViewboxTransform();
if (isPositionChanged) {
if (layerIsOn("toggleCoordinates")) drawCoordinates();

View file

@ -6,6 +6,58 @@ const WEBGL_CANVAS_ID = "webgl-canvas";
const RUNTIME_DEFS_HOST_ID = "runtime-defs-host";
const RUNTIME_DEFS_ID = "runtime-defs";
export interface SceneCameraState {
scale: number;
viewX: number;
viewY: number;
}
export interface SceneViewportState {
graphWidth: number;
graphHeight: number;
svgWidth: number;
svgHeight: number;
}
export interface SceneCameraBounds {
left: number;
right: number;
top: number;
bottom: number;
}
export function buildCameraBounds(
viewX: number,
viewY: number,
scale: number,
graphWidth: number,
graphHeight: number,
): SceneCameraBounds {
const left = normalizeSceneValue(-viewX / scale);
const top = normalizeSceneValue(-viewY / scale);
const width = graphWidth / scale;
const height = graphHeight / scale;
return {
left,
right: left + width,
top,
bottom: top + height,
};
}
export function buildViewboxTransform({
scale,
viewX,
viewY,
}: SceneCameraState) {
return `translate(${viewX} ${viewY}) scale(${scale})`;
}
function normalizeSceneValue(value: number) {
return Object.is(value, -0) ? 0 : value;
}
export class SceneModule {
private mapContainer: HTMLElement | null = null;
private sceneContainer: HTMLDivElement | null = null;
@ -98,6 +150,32 @@ export class SceneModule {
return this.runtimeDefs!;
}
getCamera() {
return { scale, viewX, viewY };
}
getViewport() {
return { graphWidth, graphHeight, svgWidth, svgHeight };
}
getCameraBounds() {
const { scale, viewX, viewY } = this.getCamera();
const { graphWidth, graphHeight } = this.getViewport();
return buildCameraBounds(viewX, viewY, scale, graphWidth, graphHeight);
}
getViewboxTransform() {
return buildViewboxTransform(this.getCamera());
}
applyViewboxTransform() {
if (typeof viewbox === "undefined") {
return;
}
viewbox.attr("transform", this.getViewboxTransform());
}
private requireMapContainer() {
const mapContainer = document.getElementById(MAP_CONTAINER_ID);
if (!(mapContainer instanceof HTMLElement)) {
@ -173,4 +251,6 @@ declare global {
var Scene: SceneModule;
}
window.Scene = new SceneModule();
if (typeof window !== "undefined") {
window.Scene = new SceneModule();
}

View file

@ -26,6 +26,7 @@ export class WebGL2LayerClass {
init(): boolean {
const canvas = Scene.getCanvas();
const { graphWidth, graphHeight } = Scene.getViewport();
this.renderer = new WebGLRenderer({
canvas,
@ -53,15 +54,12 @@ export class WebGL2LayerClass {
private syncTransform() {
if (!this.camera) return;
const x = -viewX / scale;
const y = -viewY / scale;
const w = graphWidth / scale;
const h = graphHeight / scale;
const { bottom, left, right, top } = Scene.getCameraBounds();
this.camera.left = x;
this.camera.right = x + w;
this.camera.top = y;
this.camera.bottom = y + h;
this.camera.left = left;
this.camera.right = right;
this.camera.top = top;
this.camera.bottom = bottom;
this.camera.updateProjectionMatrix();
}