mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-23 23:57:23 +01:00
323 lines
16 KiB
Markdown
323 lines
16 KiB
Markdown
# Story 1.3: Layer Lifecycle — Register, Visibility, Render Loop
|
|
|
|
**Status:** done
|
|
**Epic:** 1 — WebGL Layer Framework Module
|
|
**Story Key:** 1-3-layer-lifecycle-register-visibility-render-loop
|
|
**Created:** 2026-03-12
|
|
**Developer:** Amelia (Dev Agent)
|
|
|
|
---
|
|
|
|
## Story
|
|
|
|
As a developer,
|
|
I want `register()`, `unregister()`, `setVisible()`, `clearLayer()`, `requestRender()`, `syncTransform()`, and the private per-frame `render()` fully implemented,
|
|
So that multiple layers can be registered, rendered each frame, toggled visible/invisible, and cleaned up without GPU state loss.
|
|
|
|
---
|
|
|
|
## Context
|
|
|
|
### Prior Art (Stories 1.1 & 1.2 — Complete)
|
|
|
|
Stories 1.1 and 1.2 delivered the complete scaffold in `src/modules/webgl-layer-framework.ts`:
|
|
|
|
- **Pure exports:** `buildCameraBounds`, `detectWebGL2`, `getLayerZIndex` — fully implemented and tested
|
|
- **`init()`:** Fully implemented — DOM wrapping, canvas creation, Three.js renderer/scene/camera, ResizeObserver, D3 zoom subscription, pendingConfigs processing
|
|
- **`register()`:** Fully implemented — queues pre-init, creates Group and registers post-init
|
|
- **`requestRender()`:** Stub (calls `this.render()` directly — no RAF coalescing yet)
|
|
- **`syncTransform()`:** Stub (no-op)
|
|
- **`setVisible()`:** Stub (no-op)
|
|
- **`clearLayer()`:** Stub (no-op)
|
|
- **`unregister()`:** Stub (no-op)
|
|
- **`render()` private:** Stub (no-op)
|
|
- **21 tests passing**; lint clean
|
|
|
|
### Files to Modify
|
|
|
|
- `src/modules/webgl-layer-framework.ts` — implement all stub methods listed above
|
|
- `src/modules/webgl-layer-framework.test.ts` — add Story 1.3 tests (RAF coalescing, syncTransform, render order, setVisible, clearLayer, unregister)
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
**AC1:** `register(config)` before `init()`
|
|
→ config is queued in `pendingConfigs[]` and processed by `init()` without error _(already implemented in Story 1.2; verify remains correct)_
|
|
|
|
**AC2:** `register(config)` after `init()`
|
|
→ a `THREE.Group` with `config.renderOrder` is created, `config.setup(group)` is called once, the group is added to `scene`, and the registration is stored in `layers: Map`
|
|
|
|
**AC3:** `setVisible('terrain', false)`
|
|
→ `layer.group.visible === false`; `config.dispose` is NOT called (no GPU teardown, NFR-P6); canvas is hidden only if ALL layers are invisible
|
|
|
|
**AC4:** `setVisible('terrain', true)` after hiding
|
|
→ `layer.group.visible === true`; `requestRender()` is triggered; toggle completes in <4ms (NFR-P3)
|
|
|
|
**AC5:** `clearLayer('terrain')`
|
|
→ `group.clear()` is called (removes all Mesh children); layer registration in `layers: Map` remains intact; `renderer.dispose()` is NOT called
|
|
|
|
**AC6:** `requestRender()` called three times in rapid succession
|
|
→ only one `requestAnimationFrame` is scheduled (RAF coalescing confirmed); `rafId` is reset to `null` after the frame executes
|
|
|
|
**AC7:** `render()` private execution order
|
|
→ `syncTransform()` is called first; then each visible layer's `config.render(group)` callback is dispatched (invisible layer callbacks are skipped); then `renderer.render(scene, camera)` is called last
|
|
|
|
**AC8:** `syncTransform()` with globals `viewX=0, viewY=0, scale=1, graphWidth=960, graphHeight=540`
|
|
→ camera `left/right/top/bottom` match `buildCameraBounds(0, 0, 1, 960, 540)` exactly; `camera.updateProjectionMatrix()` is called
|
|
|
|
**AC9:** `unregister('terrain')`
|
|
→ `config.dispose(group)` is called; the id is removed from `layers: Map`; if the unregistered layer was the last one, `canvas.style.display` is set to `"none"`
|
|
|
|
**AC10:** Framework coverage ≥80% (NFR-M5)
|
|
→ `npx vitest run --coverage src/modules/webgl-layer-framework.test.ts` reports ≥80% statement coverage for `webgl-layer-framework.ts`
|
|
|
|
**AC11:** `THREE.Group` is the sole abstraction boundary (NFR-M1)
|
|
→ `scene`, `renderer`, and `camera` are never exposed to layer callbacks; all three callbacks receive only `group: THREE.Group`
|
|
|
|
---
|
|
|
|
## Technical Notes
|
|
|
|
### `requestRender()` — RAF Coalescing
|
|
|
|
Replace the direct `this.render()` call (Story 1.2 stub) with the RAF-coalesced pattern:
|
|
|
|
```typescript
|
|
requestRender(): void {
|
|
if (this._fallback) return;
|
|
if (this.rafId !== null) return; // already scheduled — coalesce
|
|
this.rafId = requestAnimationFrame(() => {
|
|
this.rafId = null;
|
|
this.render();
|
|
});
|
|
}
|
|
```
|
|
|
|
**Why coalescing matters:** D3 zoom fires many events per second; `ResizeObserver` also calls `requestRender()`. Without coalescing, every event triggers a `renderer.render()` call. With coalescing, all calls within the same frame collapse to one GPU draw.
|
|
|
|
### `syncTransform()` — D3 → Camera Sync
|
|
|
|
Reads window globals (`viewX`, `viewY`, `scale`, `graphWidth`, `graphHeight`) and applies `buildCameraBounds()` to the orthographic camera:
|
|
|
|
```typescript
|
|
syncTransform(): void {
|
|
if (this._fallback || !this.camera) return
|
|
const bounds = buildCameraBounds(viewX, viewY, scale, graphWidth, graphHeight);
|
|
this.camera.left = bounds.left;
|
|
this.camera.right = bounds.right;
|
|
this.camera.top = bounds.top;
|
|
this.camera.bottom = bounds.bottom;
|
|
this.camera.updateProjectionMatrix();
|
|
}
|
|
```
|
|
|
|
**Guard note:** `globalThis as any` is required because `viewX`, `viewY`, `scale`, `graphWidth`, `graphHeight` are legacy window globals from the pre-TypeScript codebase. They are not typed. Use `?? 0` / `?? 1` / `?? 960` / `?? 540` defaults so tests can run in Node without setting them.
|
|
|
|
### `render()` — Per-Frame Dispatch
|
|
|
|
```typescript
|
|
private render(): void {
|
|
if (this._fallback || !this.renderer || !this.scene || !this.camera) return;
|
|
this.syncTransform();
|
|
for (const layer of this.layers.values()) {
|
|
if (layer.group.visible) {
|
|
layer.config.render(layer.group);
|
|
}
|
|
}
|
|
this.renderer.render(this.scene, this.camera);
|
|
}
|
|
```
|
|
|
|
**Order is enforced:** syncTransform → per-layer render callbacks → renderer.render. Never swap.
|
|
|
|
### `setVisible()` — GPU-Preserving Toggle
|
|
|
|
```typescript
|
|
setVisible(id: string, visible: boolean): void {
|
|
if (this._fallback) return;
|
|
const layer = this.layers.get(id);
|
|
if (!layer) return;
|
|
layer.group.visible = visible;
|
|
const anyVisible = [...this.layers.values()].some(l => l.group.visible);
|
|
if (this.canvas) this.canvas.style.display = anyVisible ? "block" : "none";
|
|
if (visible) this.requestRender();
|
|
}
|
|
```
|
|
|
|
**Critical:** `config.dispose` must NOT be called here. No GPU teardown. Only `group.visible` is toggled (Three.js skips invisible objects in draw dispatch automatically).
|
|
|
|
### `clearLayer()` — Wipe Geometry, Preserve Registration
|
|
|
|
```typescript
|
|
clearLayer(id: string): void {
|
|
if (this._fallback) return;
|
|
const layer = this.layers.get(id);
|
|
if (!layer) return;
|
|
layer.group.clear(); // removes all Mesh children; Three.js Group.clear() does NOT dispose GPU memory
|
|
}
|
|
```
|
|
|
|
**Note:** `group.clear()` does NOT call `.dispose()` on children. Story 2.x's `undrawRelief` calls this to empty geometry without GPU teardown — preserving VBO/texture memory per NFR-P6.
|
|
|
|
### `unregister()` — Full Cleanup
|
|
|
|
```typescript
|
|
unregister(id: string): void {
|
|
if (this._fallback) return;
|
|
const layer = this.layers.get(id);
|
|
if (!layer || !this.scene) return;
|
|
layer.config.dispose(layer.group); // caller disposes GPU memory (geometry, material, texture)
|
|
this.scene.remove(layer.group);
|
|
this.layers.delete(id);
|
|
const anyVisible = [...this.layers.values()].some(l => l.group.visible);
|
|
if (this.canvas && !anyVisible) this.canvas.style.display = "none";
|
|
}
|
|
```
|
|
|
|
### Removing `biome-ignore` Comments (T7)
|
|
|
|
Story 1.2 retained `biome-ignore lint/correctness/noUnusedPrivateClassMembers` on `camera` and `rafId`. Both are now fully used in this story:
|
|
|
|
- `camera` is read in `syncTransform()` and `render()`
|
|
- `rafId` is read and written in `requestRender()`
|
|
|
|
Remove both `biome-ignore` comments as part of this story.
|
|
|
|
### Test Strategy — Story 1.3 Tests
|
|
|
|
All new tests inject stub state onto private fields (same pattern as Stories 1.1 and 1.2). No real WebGL context needed.
|
|
|
|
**RAF coalescing test:** `vi.spyOn(globalThis, "requestAnimationFrame").mockReturnValue(1 as any)` to assert it is called only once for three rapid `requestRender()` calls.
|
|
|
|
**syncTransform test:** Stub `camera` with a plain object; set `globalThis.viewX = 0` etc. via `vi.stubGlobal()`; call `syncTransform()`; assert camera bounds match `buildCameraBounds(0,0,1,960,540)`.
|
|
|
|
**render() order test:** Spy on `syncTransform`, a layer's `render` callback, and `renderer.render`. Assert call order.
|
|
|
|
**setVisible test:** Already partially covered in Story 1.1; Story 1.3 adds the "canvas hidden when ALL invisible" edge case and the "requestRender triggered on show" case.
|
|
|
|
**unregister test:** Verify `dispose()` called, layer removed from Map, scene.remove() called.
|
|
|
|
---
|
|
|
|
## Tasks
|
|
|
|
- [x] **T1:** Implement `requestRender()` with RAF coalescing (replace Story 1.2 stub)
|
|
- [x] T1a: Guard on `_fallback`
|
|
- [x] T1b: Early return if `rafId !== null`
|
|
- [x] T1c: `requestAnimationFrame` call storing ID in `rafId`; reset to `null` in callback before calling `render()`
|
|
|
|
- [x] **T2:** Implement `syncTransform()` reading window globals
|
|
- [x] T2a: Guard on `_fallback` and `!this.camera`
|
|
- [x] T2b: Read `globalThis.viewX/viewY/scale/graphWidth/graphHeight` with `?? defaults`
|
|
- [x] T2c: Call `buildCameraBounds()` and write all four camera bounds
|
|
- [x] T2d: Call `this.camera.updateProjectionMatrix()`
|
|
|
|
- [x] **T3:** Implement private `render()` with ordered dispatch
|
|
- [x] T3a: Guard on `_fallback`, `!this.renderer`, `!this.scene`, `!this.camera`
|
|
- [x] T3b: Call `this.syncTransform()`
|
|
- [x] T3c: Loop `this.layers.values()` dispatching `layer.config.render(group)` for visible layers only
|
|
- [x] T3d: Call `this.renderer.render(this.scene, this.camera)` (via local const captures for TypeScript type safety)
|
|
|
|
- [x] **T4:** Implement `setVisible(id, visible)`
|
|
- [x] T4a: Guard on `_fallback`
|
|
- [x] T4b: Toggle `layer.group.visible`
|
|
- [x] T4c: Check if ANY layer is still visible; update `canvas.style.display`
|
|
- [x] T4d: Call `requestRender()` when `visible === true`
|
|
|
|
- [x] **T5:** Implement `clearLayer(id)`
|
|
- [x] T5a: Guard on `_fallback`
|
|
- [x] T5b: Call `layer.group.clear()` — do NOT call `renderer.dispose()`
|
|
|
|
- [x] **T6:** Implement `unregister(id)`
|
|
- [x] T6a: Guard on `_fallback`
|
|
- [x] T6b: Call `layer.config.dispose(layer.group)`
|
|
- [x] T6c: Call `scene.remove(layer.group)` (via local const capture)
|
|
- [x] T6d: Delete from `this.layers`
|
|
- [x] T6e: Update canvas display if no layers remain visible
|
|
|
|
- [x] **T7:** Remove remaining `biome-ignore lint/correctness/noUnusedPrivateClassMembers` comments from `camera` and `rafId` fields
|
|
|
|
- [x] **T8:** Add Story 1.3 tests to `webgl-layer-framework.test.ts`:
|
|
- [x] T8a: `requestRender()` — RAF coalescing: 3 calls → only 1 `requestAnimationFrame()`
|
|
- [x] T8b: `requestRender()` — `rafId` resets to `null` after frame executes
|
|
- [x] T8c: `syncTransform()` — camera bounds match `buildCameraBounds(0,0,1,960,540)`
|
|
- [x] T8d: `syncTransform()` — uses `?? defaults` when globals absent
|
|
- [x] T8e: `render()` — `syncTransform()` called before layer callbacks, `renderer.render()` called last
|
|
- [x] T8f: `render()` — invisible layer's `config.render()` NOT called
|
|
- [x] T8g: `setVisible(false)` — `group.visible = false`; `dispose` NOT called (NFR-P6)
|
|
- [x] T8h: `setVisible(false)` for ALL layers — canvas `display = "none"`
|
|
- [x] T8i: `setVisible(true)` — `requestRender()` triggered
|
|
- [x] T8j: `clearLayer()` — `group.clear()` called; layer remains in `layers` Map
|
|
- [x] T8k: `clearLayer()` — `renderer.dispose()` NOT called (NFR-P6)
|
|
- [x] T8l: `unregister()` — `dispose()` called; `scene.remove()` called; id removed from Map
|
|
- [x] T8m: `unregister()` last layer — canvas `display = "none"`
|
|
- [x] Also updated existing Story 1.1 test `requestRender() does not throw` to stub RAF globally
|
|
|
|
- [x] **T9:** `npm run lint` — zero errors
|
|
|
|
- [x] **T10:** `npx vitest run src/modules/webgl-layer-framework.test.ts` — all 34 tests pass; statement coverage 85.13% ≥ 80% (NFR-M5 ✓)
|
|
|
|
- [x] **T11:** Set story status to `review`
|
|
|
|
---
|
|
|
|
## Dev Notes
|
|
|
|
### Globals Referenced
|
|
|
|
| Global | Type | Default in tests | Source |
|
|
| ------------- | -------- | ---------------- | ----------------------------- |
|
|
| `viewX` | `number` | `0` | D3 zoom transform X translate |
|
|
| `viewY` | `number` | `0` | D3 zoom transform Y translate |
|
|
| `scale` | `number` | `1` | D3 zoom scale |
|
|
| `graphWidth` | `number` | `960` | Map canvas logical width |
|
|
| `graphHeight` | `number` | `540` | Map canvas logical height |
|
|
|
|
All accessed via `(globalThis as any).NAME ?? default` — never destructure or assume presence (guard for Node test env).
|
|
|
|
### What Story 1.3 Does NOT Cover
|
|
|
|
- `draw-relief-icons.ts` refactor → Story 2.2
|
|
- Performance benchmarking → Story 3.1
|
|
- E2E / browser tests → out of scope for Epic 1
|
|
|
|
### Coverage Target
|
|
|
|
NFR-M5 requires ≥80% statement coverage. After Story 1.3, all public methods and critical private paths are exercised. The remaining uncovered lines should be limited to edge cases in platform-specific paths (ResizeObserver callbacks, WebGL context loss handlers).
|
|
|
|
---
|
|
|
|
## Dev Agent Record
|
|
|
|
### Implementation Notes
|
|
|
|
- **`render()` TypeScript type safety:** Used local const captures (`const renderer = this.renderer; const scene = this.scene; const camera = this.camera;`) immediately after the null-guard, before calling `this.syncTransform()`. This is required because TypeScript re-widens class instance field types after any method call — local consts preserve the narrowed (non-null) types for the final `renderer.render(scene, camera)` call.
|
|
- **`unregister()` local capture:** Same pattern used for `scene` — captured before `layer.config.dispose()` call to preserve TypeScript narrowing.
|
|
- **`syncTransform()` local capture:** `const camera = this.camera;` captured after guard, before variable assignments. No function calls between guard and camera use, so TypeScript narrows correctly; the capture is an additional safety measure.
|
|
- **Existing test update (T8 extra):** The Story 1.1 test `requestRender() does not throw when called multiple times` was updated to add `vi.stubGlobal("requestAnimationFrame", vi.fn().mockReturnValue(0))` since the stub method previously directly called `render()` (a no-op), but the real implementation now calls `requestAnimationFrame` which is absent in the Node.js test environment.
|
|
- **Uncovered lines (15%):** Line 88 (`|| 960` fallback in `init()` clientWidth branch) and lines 256/262-265 (ResizeObserver callback body). Both require real DOM resize events — not testable in Node unit tests. These represent expected coverage gaps acceptable per NFR-M5.
|
|
|
|
### Files Modified
|
|
|
|
- `src/modules/webgl-layer-framework.ts` — implemented `requestRender()`, `syncTransform()`, `render()`, `setVisible()`, `clearLayer()`, `unregister()`; removed 2 `biome-ignore` comments (`camera`, `rafId`)
|
|
- `src/modules/webgl-layer-framework.test.ts` — updated 1 existing test (RAF stub); added new describe block `WebGL2LayerFrameworkClass — lifecycle & render loop (Story 1.3)` with 13 tests
|
|
|
|
### Test Results
|
|
|
|
```
|
|
✓ modules/webgl-layer-framework.test.ts (34 tests) 9ms
|
|
✓ buildCameraBounds (5)
|
|
✓ detectWebGL2 (3)
|
|
✓ getLayerZIndex (1)
|
|
✓ WebGL2LayerFrameworkClass (7)
|
|
✓ WebGL2LayerFrameworkClass — init() (5)
|
|
✓ WebGL2LayerFrameworkClass — lifecycle & render loop (Story 1.3) (13)
|
|
Test Files 1 passed (1) | Tests 34 passed (34)
|
|
|
|
Coverage (v8):
|
|
webgl-layer-framework.ts | 85.13% Stmts | 70.73% Branch | 84.21% Funcs | 91.26% Lines
|
|
NFR-M5 (≥80% statement coverage): ✓ PASS
|
|
```
|
|
|
|
`npm run lint`: Checked 80 files — no fixes applied.
|