Fantasy-Map-Generator/_bmad-output/planning-artifacts/architecture.md

37 KiB
Raw Blame History

stepsCompleted inputDocuments workflowType project_name user_name date status lastStep completedAt
step-01-init
step-02-context
step-03-starter
step-04-decisions
step-05-patterns
step-06-structure
step-07-validation
step-08-complete
_bmad-output/planning-artifacts/prd.md
_bmad-output/planning-artifacts/research/technical-WebGL-SVG-layered-rendering-research-2026-03-12.md
_bmad-output/project-context.md
architecture Fantasy-Map-Generator Azgaar 2026-03-12 complete 8 2026-03-12

Architecture Decision Document — Fantasy-Map-Generator WebGL Layer Framework

Project: Fantasy-Map-Generator Author: Azgaar (via Winston/Architect) Date: 2026-03-12 Status: Complete — Ready for Implementation


1. Project Context Analysis

1.1 Scope Summary

This architecture addresses a brownfield, isolated subsystem replacement in FMG's rendering pipeline. The surface area is:

  • New: WebGL2LayerFramework TypeScript class (src/modules/webgl-layer-framework.ts)
  • Refactored: src/renderers/draw-relief-icons.ts — migrated to use the framework instead of ad-hoc context management
  • Added: Unit tests (src/modules/webgl-layer-framework.test.ts)
  • Unchanged: All 32 SVG layers, D3 data pipeline, public/modules/ legacy JS, globe renderer, SVG export

1.2 Functional Requirements Coverage

FR Category Count Architectural Component
Framework Core (FR1FR4) 4 WebGL2LayerFramework class
Coordinate Sync (FR5FR7) 3 syncTransform() method + orthographic camera
Layer Lifecycle (FR8FR11) 4 setVisible(), ResizeObserver, dispose()
Relief Rendering (FR12FR17) 6 draw-relief-icons.ts refactored
Compatibility & Fallback (FR18FR19) 2 detectWebGL2() guard in framework init
Interaction (FR20FR21) 2 pointer-events: none on canvas; existing Layers panel unchanged
Developer API (FR22FR24) 3 register(config) public method
Testability (FR25FR27) 3 Pure functions / injectable dependencies

Total: 27 FRs — all addressed.

1.3 Non-Functional Constraints Shaping Architecture

NFR Architectural Impact
NFR-P6: No GPU teardown on hide setVisible(false) must set mesh.visible = false only — NOT call dispose()
NFR-C3: Max 2 WebGL contexts (1 globe + 1 map) Single THREE.WebGLRenderer shared across all registered layers
NFR-M3: Global Module Pattern window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass() at module bottom
NFR-B1/B2: Bundle size ≤ 50KB gzip increase Three.js already present; named imports only (import { WebGLRenderer, ... } from 'three')
NFR-M5: ≥ 80% Vitest coverage on framework core Pure coordinate functions and registration API must be injected/mockable

1.4 Critical Brownfield Constraints Discovered in Codebase

Constraint Detail Architectural Response
Existing WebGL relief renderer draw-relief-icons.ts already has a working Three.js renderer Framework wraps/extends it — not replace from scratch
undrawRelief tears down GPU Current implementation disposes renderer on hide — violates NFR-P6 Framework setVisible(false) replaces teardown pattern
Canvas placement is wrong Currently insertAdjacentElement("afterend", map) — canvas sits above all SVG layers Framework uses positioned wrapper; MVP uses z-index above SVG (see Decision 3)
Globals viewX, viewY, scale Coordinate sync uses raw window globals Framework abstracts to syncTransform() callable function; tests inject mock values
#map { position: absolute } SVG has no positioned parent container Framework wraps SVG in #map-container on init

2. Technology Stack

No new dependencies are introduced. The framework uses only technologies already in the project:

Technology Version Role in This Feature
TypeScript ^5.9.3 Framework implementation language
Three.js ^0.183.2 WebGL renderer, orthographic camera, scene management
Vite ^7.3.1 Bundling (tree-shaking Three.js named imports)
Vitest ^4.0.18 Unit tests for coordinate sync and framework API
D3 ^7.9.0 Source of zoom transform values (viewX, viewY, scale)

Three.js imports used (tree-shaken):

import {
  WebGLRenderer,
  Scene,
  OrthographicCamera,
  BufferGeometry,
  BufferAttribute,
  Mesh,
  MeshBasicMaterial,
  TextureLoader,
  SRGBColorSpace,
  LinearMipmapLinearFilter,
  LinearFilter,
  DoubleSide
} from "three";

3. Core Architectural Decisions

Decision 1: Single Shared WebGL Context via WebGL2LayerFramework

Decision: A new WebGL2LayerFrameworkClass TypeScript module manages a single THREE.WebGLRenderer instance shared by all registered WebGL layers. It replaces draw-relief-icons.ts's module-level renderer with a centralized framework.

Rationale:

  • Browser WebGL context limit (816 per page) requires minimizing context creation
  • The globe renderer already holds context #1; the map framework holds context #2 — this is the budget maximum (NFR-C3)
  • A single THREE.Scene with renderOrder per layer handles draw ordering within the shared context
  • The existing draw-relief-icons.ts will be refactored to register with this framework instead of managing its own renderer

Alternatives rejected:

  • One canvas per layer: would exceed context limit at 3+ WebGL layers
  • Raw WebGL2 without Three.js: Three.js already present; adds no bundle cost; handles context loss/restore, shader compilation, VBO management

Decision 2: Layer Registration API

Decision: The framework exposes a register(config: WebGLLayerConfig) method. Callers provide an id, anchorLayerId (SVG element ID for z-position reference), renderOrder (Three.js scene draw order), setup(scene) callback, and dispose() callback. The framework manages canvas lifecycle; layer-specific GPU resource creation happens in setup.

interface WebGLLayerConfig {
  id: string;
  anchorLayerId: string; // e.g. "terrain" — SVG <g> whose DOM position sets z-index
  renderOrder: number; // Three.js renderOrder for objects in this layer
  setup: (scene: THREE.Scene) => void; // called once after WebGL2 context confirmed
  dispose: (scene: THREE.Scene) => void; // called on framework.unregister(id)
}

What the framework manages:

  • Canvas element creation, sizing, positioning
  • THREE.WebGLRenderer + THREE.Scene initialization
  • Z-index derivation from anchor SVG layer DOM position
  • Visibility toggle (visible = false/true on registered THREE.Object3D groups)
  • Canvas resize via ResizeObserver
  • D3 zoom/pan → orthographic camera sync

What each layer module manages:

  • Creating THREE.Mesh / BufferGeometry / textures in setup(scene)
  • Clearing and rebuilding geometry when data changes (called by drawRelief equivalent)
  • Cleaning up GPU objects in dispose(scene)

Decision 3: Canvas Z-Index Positioning — MVP vs. Phase 2

MVP Decision: The canvas is inserted as a sibling to #map in #map-container (a new position: relative wrapper div). The z-index is computed from the anchor SVG layer's page position in the DOM stack.

Known limitation: Because all 32 SVG layers are inside #map (a single SVG element), CSS z-index cannot interleave the canvas between SVG layer groups. In MVP, the canvas renders above the entire SVG (higher z-index than #map). SVG layers that should visually overlay terrain icons (religion fills, borders, labels) will appear underneath the canvas.

Why this is acceptable for MVP:

  • The visual impact is limited: relief icons appear in terrain cells (mountains, forests), while labels/burg icons appear in civilized cells — overlap is uncommon in practice
  • The current codebase ALREADY exhibits this same behavior (draw-relief-icons.ts places canvas after #map in DOM order with no z-index)
  • pointer-events: none preserves all interaction; the UX regression is purely visual

Phase 2 fix — DOM-Split Architecture:

#map-container (position: relative)
  ├── svg#map-back  (layers 111, z-index: 1)
  ├── canvas#terrainCanvas  (z-index: 2, pointer-events: none)
  └── svg#map-front  (layers 1332 + interaction, z-index: 3)

This requires moving layer <g> elements between two SVG elements and syncing D3 transforms to both — deferred to Phase 2.

Z-index computation formula (MVP):

function getLayerZIndex(anchorLayerId: string): number {
  const anchor = document.getElementById(anchorLayerId);
  if (!anchor) return 100;
  // Use the element's index in its parent's children as the z-index base
  const siblings = Array.from(anchor.parentElement?.children ?? []);
  const idx = siblings.indexOf(anchor);
  return idx > 0 ? idx : 100;
}

Decision 4: D3 Zoom → WebGL Orthographic Camera Sync

Decision: The sync formula from the existing draw-relief-icons.ts is extracted into a pure, testable function buildCameraBounds(viewX, viewY, scale, graphWidth, graphHeight) that returns {left, right, top, bottom} for the orthographic camera.

Derivation (documented in code):

D3 applies transform: translate(viewX, viewY) scale(scale) to #viewbox
This means: screen_point = map_point * scale + (viewX, viewY)
Inverting: map_point = (screen_point - (viewX, viewY)) / scale

Orthographic camera bounds (what map rectangle is visible on screen):
  left   = -viewX / scale          ← left edge of visible map
  right  = (graphWidth - viewX) / scale  ← right edge
  top    = -viewY / scale          ← top edge
  bottom = (graphHeight - viewY) / scale ← bottom edge

Camera is configured with Y-down convention (top < bottom) to match SVG:
  new OrthographicCamera(left, right, top, bottom, -1, 1)
  where top < bottom (Y increases downward, SVG convention)

Why this is testable: buildCameraBounds takes only numbers and returns numbers. Tests inject mock viewX/viewY/scale values and assert exact output — no DOM or WebGL required.

Decision 5: Visibility Toggle — GPU State Preservation

Decision: framework.setVisible(id, visible) toggles the Three.js Object3D.visible property of the layer's registered group. The canvas element's display style is changed only when ALL registered layers are hidden.

setVisible(id: string, visible: boolean): void {
  const layer = this.layers.get(id);
  if (!layer) return;
  layer.group.visible = visible;
  // Only hide canvas if ALL layers are invisible (avoids GPU context loss)
  const anyVisible = [...this.layers.values()].some(l => l.group.visible);
  this.canvas.style.display = anyVisible ? "block" : "none";
  if (visible) this.render();
}

This replaces undrawRelief's current behavior which calls renderer.dispose(), scene = null, etc. — destroying GPU buffers on every hide. The framework never destroys buffers except on framework.unregister(id).

Decision 6: WebGL2 Detection and SVG Fallback

Decision: Framework initialization calls detectWebGL2() which attempts canvas.getContext('webgl2'). On failure, the framework sets a hasFallback = true flag and the relief renderer falls back to drawSvg(). All framework methods become no-ops when in fallback mode.

function detectWebGL2(): boolean {
  const probe = document.createElement("canvas");
  const ctx = probe.getContext("webgl2");
  if (!ctx) return false;
  const ext = ctx.getExtension("WEBGL_lose_context");
  ext?.loseContext();
  return true;
}

Testable: The detection function is exported and can be called with a mock canvas in Vitest.

Decision 7: Frame Rendering — On-Demand, RAF-Coalesced

Decision: The framework exposes a requestRender() method that coalesces calls within a single animation frame, preventing redundant GPU draws during rapid pan/zoom events.

private rafId: number | null = null;

requestRender(): void {
  if (this.rafId !== null) return;
  this.rafId = requestAnimationFrame(() => {
    this.rafId = null;
    this.render();
  });
}

private render(): void {
  this.syncTransform();
  this.renderer.render(this.scene, this.camera);
}

This replaces window.rerenderReliefIcons which currently does the same RAF coalescing at the module level.

Decision 8: ResizeObserver for Canvas Sizing

Decision: The framework attaches a ResizeObserver to the #map-container element. On resize, it updates canvas dimensions and the orthographic camera aspect ratio, then re-renders.

private observeResize(): void {
  this.resizeObserver = new ResizeObserver(entries => {
    const { width, height } = entries[0].contentRect;
    this.renderer.setSize(width, height);
    this.requestRender();
  });
  this.resizeObserver.observe(this.container);
}

4. Implementation Patterns

4.1 Global Module Pattern (Mandatory)

All TypeScript modules in src/modules/ and src/renderers/ follow the project's Global Module Pattern. The framework module must follow it exactly:

// src/modules/webgl-layer-framework.ts

// 1. Global type declaration
declare global {
  var WebGL2LayerFramework: WebGL2LayerFrameworkClass;
}

// 2. Class implementation
class WebGL2LayerFrameworkClass {
  // ...
}

// 3. Window registration (LAST LINE)
window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass();

4.2 Module Import Pattern

Add to src/modules/index.ts as a side-effect import:

import "./webgl-layer-framework";

And draw-relief-icons.ts remains in src/renderers/index.ts — no change to import structure.

4.3 Function Naming Conventions

Pattern Convention Example
Framework class PascalCase + Class suffix WebGL2LayerFrameworkClass
Window global PascalCase window.WebGL2LayerFramework
Pure utility functions camelCase buildCameraBounds, detectWebGL2, getLayerZIndex
Internal methods camelCase syncTransform, observeResize, requestRender

4.4 TypeScript Type Declarations

New types go in src/types/global.ts:

declare global {
  var WebGL2LayerFramework: import("../modules/webgl-layer-framework").WebGL2LayerFrameworkClass;
  var drawRelief: (type?: "svg" | "webGL", parentEl?: HTMLElement) => void;
  var undrawRelief: () => void;
  var rerenderReliefIcons: () => void;
}

4.5 Error Handling Philosophy

  • Framework init failures (WebGL2 unavailable): set hasFallback = true, log with WARN global, no throw
  • Missing DOM elements (e.g., #map not found on init): early return + WARN log
  • WebGL context loss mid-session: renderer.forceContextRestore() then renderer.dispose() + re-init on next draw call (preserves existing pattern from draw-relief-icons.ts)
  • Unit tests: pure functions throw Errors; framework class methods log and return for resilience

4.6 Test Patterns

Unit tests co-located with source in src/modules/:

// src/modules/webgl-layer-framework.test.ts
import {describe, it, expect, vi} from "vitest";
import {buildCameraBounds, detectWebGL2, getLayerZIndex} from "./webgl-layer-framework";

describe("buildCameraBounds", () => {
  it("returns correct bounds for identity transform", () => {
    // viewX=0, viewY=0, scale=1, 960x540
    const b = buildCameraBounds(0, 0, 1, 960, 540);
    expect(b.left).toBe(0);
    expect(b.right).toBe(960);
    expect(b.top).toBe(0);
    expect(b.bottom).toBe(540);
  });

  it("returns correct bounds at 2× zoom centered on origin", () => {
    const b = buildCameraBounds(0, 0, 2, 960, 540);
    expect(b.left).toBe(0);
    expect(b.right).toBe(480);
    expect(b.top).toBe(0);
    expect(b.bottom).toBe(270);
  });

  it("returns correct bounds with pan offset", () => {
    const b = buildCameraBounds(-100, -50, 1, 960, 540);
    expect(b.left).toBe(100);
    expect(b.right).toBe(1060);
  });
});

describe("detectWebGL2", () => {
  it("returns false when getContext returns null", () => {
    const canvas = {getContext: () => null} as unknown as HTMLCanvasElement;
    expect(detectWebGL2(canvas)).toBe(false);
  });
});

Key testability rule: Pure functions (buildCameraBounds, detectWebGL2, getLayerZIndex) are exported as named exports and tested without DOM/WebGL. The class itself is tested with stub canvases where needed.


5. Project Structure

5.1 Files Created

src/
  modules/
    webgl-layer-framework.ts          ← NEW: Framework class (core deliverable)
    webgl-layer-framework.test.ts     ← NEW: Unit tests (≥80% coverage target)
  renderers/
    draw-relief-icons.ts              ← MODIFIED: Refactored to use framework

5.2 Files Modified

src/
  modules/
    index.ts                          ← ADD: import "./webgl-layer-framework"
  types/
    global.ts                         ← ADD: WebGL2LayerFramework global declaration

5.3 webgl-layer-framework.ts Internal Structure

// ─── Exports (for testability) ───────────────────────────────────────────────
export function detectWebGL2(probe?: HTMLCanvasElement): boolean;
export function buildCameraBounds(
  viewX: number,
  viewY: number,
  scale: number,
  graphWidth: number,
  graphHeight: number
): {left: number; right: number; top: number; bottom: number};
export function getLayerZIndex(anchorLayerId: string): number;

// ─── Types ───────────────────────────────────────────────────────────────────
export interface WebGLLayerConfig {
  id: string;
  anchorLayerId: string;
  renderOrder: number;
  setup: (scene: THREE.Scene) => void;
  dispose: (scene: THREE.Scene) => void;
}

interface RegisteredLayer {
  config: WebGLLayerConfig;
  group: THREE.Group;
}

// ─── Class ───────────────────────────────────────────────────────────────────
export class WebGL2LayerFrameworkClass {
  private canvas: HTMLCanvasElement | null = null;
  private renderer: THREE.WebGLRenderer | null = null;
  private camera: THREE.OrthographicCamera | null = null;
  private scene: THREE.Scene | null = null;
  private layers: Map<string, RegisteredLayer> = new Map();
  private resizeObserver: ResizeObserver | null = null;
  private rafId: number | null = null;
  private container: HTMLElement | null = null;
  readonly hasFallback: boolean = false;

  // Public API
  init(containerId?: string): boolean;
  register(config: WebGLLayerConfig): boolean;
  unregister(id: string): void;
  setVisible(id: string, visible: boolean): void;
  requestRender(): void;
  syncTransform(): void;

  // Private
  private render(): void;
  private observeResize(): void;
  private ensureContainer(): HTMLElement | null;
}

// ─── Global Registration (MUST be last line) ─────────────────────────────────
declare global {
  var WebGL2LayerFramework: WebGL2LayerFrameworkClass;
}
window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass();

5.4 draw-relief-icons.ts Refactored Structure

The module registers itself with the framework on load. Existing window globals (drawRelief, undrawRelief, rerenderReliefIcons) are preserved for backward compatibility with legacy public/modules/ code that calls them.

// Internal: called by framework's setup callback
function setupReliefLayer(scene: THREE.Scene): void;
// Internal: rebuild geometry from pack.relief data
function buildReliefScene(icons: ReliefIcon[]): void;
// Internal: SVG fallback renderer
function drawSvgRelief(icons: ReliefIcon[], parentEl: HTMLElement): void;

// Public window globals (backward-compatible)
window.drawRelief = (type = "webGL", parentEl = byId("terrain")) => {
  if (WebGL2LayerFramework.hasFallback || type === "svg") {
    drawSvgRelief(icons, parentEl);
  } else {
    buildReliefScene(icons);
    WebGL2LayerFramework.requestRender();
  }
};
window.undrawRelief = () => {
  // Clears geometry but does NOT dispose GPU resources
  disposeScene(); // removes meshes from scene, keeps renderer alive
  if (terrainEl) terrainEl.innerHTML = "";
};
window.rerenderReliefIcons = () => {
  WebGL2LayerFramework.requestRender();
};

5.5 DOM Structure After Framework Init

body
  div#map-container  (NEW; position: relative; width: svgWidth; height: svgHeight)
    svg#map  (MOVED inside container; position: absolute; inset: 0; z-index: 1)
    canvas#terrainCanvas  (NEW; position: absolute; inset: 0; z-index: getLayerZIndex("terrain")+1; pointer-events: none; aria-hidden: true)

5.6 Framework Initialization Sequence

1. Framework module loaded (via src/modules/index.ts import)
2. window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass()
   → sets hasFallback = !detectWebGL2()
3. draw-relief-icons.ts loaded (via src/renderers/index.ts import)
   → calls WebGL2LayerFramework.register({ id: "terrain", anchorLayerId: "terrain", ... })
   → if hasFallback: register is a no-op, drawRelief uses SVG path
4. Main map generation completes → window.drawRelief() called by legacy JS
   → if WebGL: builds scene, requestRender() → next RAF → syncTransform + renderer.render
   → if SVG fallback: drawSvgRelief()
5. D3 zoom/pan events → window.rerenderReliefIcons() → framework.requestRender()
6. Layer visibility toggle (legacy JS) → window.undrawRelief() or window.drawRelief()
   → framework.setVisible("terrain", false/true) — NO GPU teardown

6. Architecture Validation

6.1 FR Coverage Matrix

Requirement Addressed By Status
FR1: Single WebGL2 context WebGL2LayerFrameworkClass owns one THREE.WebGLRenderer
FR2: Canvas at correct z-index getLayerZIndex(anchorLayerId) → canvas z-index (MVP: above SVG)
FR3: Register layer by anchor + callback framework.register(config)
FR4: Layer registry layers: Map<string, RegisteredLayer>
FR5: Sync to D3 zoom transform syncTransform() reads viewX, viewY, scale globals
FR6: Update on D3 change requestRender() called from rerenderReliefIcons
FR7: Map-space → WebGL clip coordinates buildCameraBounds() formula
FR8: Toggle without GPU teardown setVisible()group.visible only
FR9: Resize canvas on viewport change ResizeObserver on container
FR10: Recalculate z-index on layer reorder getLayerZIndex() reads live DOM position
FR11: Dispose layer + GPU resources unregister(id) → disposes GeometryBuffers, removes from scene
FR12: All relief icons in one draw call Per-set Mesh with merged BufferGeometry (existing batched approach)
FR13: Icons at SVG-space coordinates Camera in SVG pixel-space; icon positions in pack.relief unchanged
FR14: Scale with zoom and user setting Camera bounds change with zoom; icon size uses r.s from relief data
FR15: Per-icon rotation Rotation encoded in quad vertex positions during buildSetMesh ⚠️ Verify rotation support in existing buildSetMesh
FR16: Configurable opacity MeshBasicMaterial.opacity + transparent: true
FR17: Re-render on terrain data change drawRelief() calls buildReliefScene() + requestRender()
FR18: WebGL2 detection + fallback detectWebGL2()hasFallback flag
FR19: SVG fallback visually identical Existing drawSvg() preserved unchanged
FR20: No pointer-event capture canvas.style.pointerEvents = "none"
FR21: Existing Layers panel unchanged drawRelief/undrawRelief window globals preserved
FR22: Register without z-index knowledge framework.register derives z-index internally
FR23: Render callback receives D3 transform syncTransform() reads globals; transforms available in RAF
FR24: Same visibility API for all layers framework.setVisible(id, bool) uniform for all registered layers
FR25: Coordinate sync testable in isolation buildCameraBounds is a pure exported function
FR26: Fallback detection testable detectWebGL2(probeCanvas) accepts injectable canvas
FR27: Registration testable without real WebGL hasFallback = true path is a no-op; stub renderers in tests

FR15 Note: The existing buildSetMesh in draw-relief-icons.ts constructs static quads; rotation may not be applied. This must be verified and implemented (per-icon rotation via vertex transformation in buildSetMesh) before MVP ships.

6.2 NFR Compliance

NFR Status Implementation
NFR-P1: <16ms @ 1k icons Enabled by Single-batch BufferGeometry per atlas set
NFR-P2: <100ms @ 10k icons Enabled by Same batch approach; measure at implementation
NFR-P3: Toggle <4ms group.visible = false is O(1)
NFR-P4: Pan/zoom latency <8ms RAF-coalesced; syncTransform() is one matrix update
NFR-P5: Init <200ms Enabled by Three.js renderer init is ~50100ms
NFR-P6: No GPU teardown on hide setVisible never calls dispose()
NFR-C1: WebGL2 as sole gate detectWebGL2() uses canvas.getContext('webgl2')
NFR-C2: Cross-browser visual parity Enabled by Three.js normalizes WebGL2 across browsers
NFR-C3: Max 2 contexts Framework creates 1 context; globe creates 1
NFR-C4: Fallback when HW accel disabled detectWebGL2() returns false → SVG path
NFR-M1: Framework unknown of layer content setup/dispose callbacks encapsulate all content
NFR-M2: New layer = 1 register() call Confirmed by API design
NFR-M3: Global Module Pattern window.WebGL2LayerFramework = new ... at bottom
NFR-M4: Sync formula documented buildCameraBounds has full derivation in JSDoc
NFR-M5: ≥80% test coverage Target Tests for buildCameraBounds, detectWebGL2, getLayerZIndex, register, setVisible
NFR-B1: Tree-shaking Three.js Named imports only
NFR-B2: ≤50KB bundle increase No new dependencies; framework code ~5KB

6.3 Architecture Risks and Mitigations

Risk Likelihood Impact Architecture Mitigation
D3 + WebGL coordinate offset at extreme zoom Medium High buildCameraBounds is unit-tested at zoom 0.150; exact formula documented
FR15: Rotation not in existing buildSetMesh High Medium Flag as pre-MVP verification item; add rotation attribute if missing
MVP z-ordering: canvas above SVG High Medium Accepted tradeoff; documented; Phase 2 DOM-split design provided
undrawRelief callers expect full cleanup Low Low Preserve undrawRelief signature; change internals only (no GPU teardown)
Context loss mid-session Low High Framework inherits existing forceContextRestore pattern from draw-relief-icons.ts
will-change: transform memory overhead Low Low Apply only during active zoom/pan; remove after with timing debounce

6.4 Decision Coherence Check

Decision Pair Compatible? Note
Single context (D1) + Layer registry (D2) renderOrder on THREE.Group within shared scene
MVP z-index above SVG (D3) + pointer-events:none (D3) Interaction preserved regardless of z-stack
Camera sync using globals (D4) + testability (FR25) buildCameraBounds is pure; globals are injected in tests
No GPU teardown (D5) + undrawRelief backward compat (section 5.4) undrawRelief calls disposeScene() (geometry only) not renderer.dispose()
On-demand RAF render (D7) + ResizeObserver (D8) Both call requestRender() which coalesces to one RAF

7. Implementation Guidance for AI Agents

When implementing this architecture, follow these rules precisely:

MUST DO

  1. Framework module registers firstsrc/modules/index.ts import must appear before renderer imports
  2. window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass() is the last line of the framework module
  3. Export buildCameraBounds, detectWebGL2, getLayerZIndex as named exports — tests depend on them
  4. setVisible(id, false) NEVER calls renderer.dispose() — only sets group.visible = false
  5. Canvas element gets: pointer-events: none; aria-hidden: true; position: absolute; inset: 0
  6. Fallback path: when hasFallback === true, all framework methods return silently; drawRelief calls drawSvgRelief
  7. window.drawRelief, window.undrawRelief, window.rerenderReliefIcons must remain as window globals (legacy JS calls them)
  8. Verify FR15 (per-icon rotation) in buildSetMesh before MVP — add rotation support if missing

MUST NOT DO

  1. Do NOT create a second THREE.WebGLRenderer — framework owns the only map renderer
  2. Do NOT move layer <g> elements between SVG elements — DOM-split is Phase 2
  3. Do NOT add any new entries to public/modules/ — all new code is in src/
  4. Do NOT break the window.drawRelief(type, parentEl) signature — legacy callers
  5. Do NOT use isNaN() — use Number.isNaN(); or parseInt() without radix
  6. Do NOT import Three.js as import * as THREE from "three" — use named imports only

Verification Checklist

  • npm run lint passes with zero errors
  • npx vitest run passes all tests
  • buildCameraBounds tests pass at zoom 0.1, 1, 2, 10, 50
  • detectWebGL2 test passes with null-returning mock canvas
  • Layer registration test passes with stub scene
  • setVisible(false) test confirms GPU buffers remain allocated
  • Visual: relief icons render at correct coordinate positions
  • Visual: toggling terrain layer on/off preserves icon positions
  • Visual: pan/zoom redraws canvas correctly in sync with SVG

8. Next Steps

With this architecture complete, the recommended implementation sequence is:

Story 1: Create webgl-layer-framework.ts with exported pure functions and stub class methods; write all unit tests first (TDD).

Story 2: Implement WebGL2LayerFrameworkClass core: init(), detectWebGL2(), canvas creation, #map-container wrapper, ResizeObserver.

Story 3: Implement register(), unregister(), setVisible(), requestRender(), syncTransform().

Story 4: Refactor draw-relief-icons.ts to use WebGL2LayerFramework.register() and remove the module-level renderer state. Verify FR15 rotation support.

Story 5: Integration testing — generate map, toggle terrain layer, pan/zoom, verify visual output matches SVG baseline.

Story 6: Bundle size audit — verify Three.js tree-shaking, confirm ≤50KB gzipped delta.