# PLAN.md — hmwebviewer task breakdown

> **Agents: read this + PROGRESS.md on start.** Pick the next `todo` task whose `depends_on` are all `done`. Set it to `in_progress` with your name before working. Move to `done` only when acceptance criteria pass and you updated PROGRESS.md.

## Status legend
`todo` · `in_progress(@agent)` · `blocked(reason)` · `done` · `skipped(reason)`

---

## Phase 0 — Bootstrap
| ID | Task | Agent | Status | Depends | Acceptance |
|----|------|-------|--------|---------|------------|
| P0-1 | Scaffold Vite + TS project, install three + types | setup | done (build-verify pending) | — | `npm run dev` serves blank page; `npm run build` exits 0 |
| P0-2 | Copy Draco + KTX2 decoder WASM to `public/`, wire CDN fallback | setup | done (auto-copies via postinstall) | P0-1 | `DRACOLoader.setDecoderPath('/draco/')` resolves; console clean |
| P0-3 | Basic WebGLRenderer scene (camera, lights, resize loop) | viewer-core | done (build-verify pending) | P0-1 | canvas renders a test cube; resizes on window resize |

## Phase 1 — Core viewer (parallelizable)
| ID | Task | Agent | Status | Depends | Acceptance |
|----|------|-------|--------|---------|------------|
| P1-1 | Singleton loaders (GLTFLoader + DRACOLoader + KTX2Loader, ONE each) | viewer-core | done (build-verify pending) | P0-2 | exported factory; reused across loads; no double-instance |
| P1-2 | `loadServerAsset(url)` with progress bar wiring | viewer-core | done (build-verify pending) | P1-1 | loads sample Draco GLB, progress % updates, scene populated |
| P1-3 | `loadLocalFile(blob)` — createObjectURL + revoke after load | viewer-core | done (build-verify pending) | P1-1 | drop GLB loads; URL revoked post-load; memory stable across N drops |
| P1-4 | OrbitControls + camera framing (fit model to view) | viewer-core | done (build-verify pending) | P1-2 | model auto-framed; rotate/zoom works |

## Phase 2 — Drag & Drop (parallelizable with Phase 1)
| ID | Task | Agent | Status | Depends | Acceptance |
|----|------|-------|--------|---------|------------|
| P2-1 | Dropzone UI + HTML5 DnD event capture, file type/size validation | dnd-handler | done (build-verify pending) | P0-1 | invalid files rejected with message; valid files accepted |
| P2-2 | Wire dropzone → `loadLocalFile` | dnd-handler | done (build-verify pending) | P1-3, P2-1 | dropped file appears in scene |

## Phase 3 — SSR hydration (serial, depends on core)
| ID | Task | Agent | Status | Depends | Acceptance |
|----|------|-------|--------|---------|------------|
| P3-1 | WebP placeholder element + CSS opacity transition scaffolding | hydration | done | P1-2 | placeholder shows, fades on command |
| P3-2 | `executeHydration()` — coordinate WebGL-ready AND model-loaded → fade | hydration | done | P3-1 | no empty-canvas flash; race handled; transition 0.5s |
| P3-3 | Toggle UI states (progress bar show/hide, loading→ready) | hydration | done | P3-2 | UI reflects each load phase |

## Phase 4 — Asset optimization pipeline (offline, parallelizable)
| ID | Task | Agent | Status | Depends | Acceptance |
|----|------|-------|--------|---------|------------|
| P4-1 | `tools/preprocess.mjs` — gltf-transform Draco+KTX2 wrapper | asset-pipeline | done | — | input GLB → output compressed GLB, size reduced, loads in viewer |
| P4-2 | Sample asset set (small/medium/large GLB) for testing | asset-pipeline | done | — | Box(1.6K)/Duck(118K)/Avocado(7.9M) + Duck.optimized(Draco) under `samples/` + `public/samples/` |
| P4-3 | Pre-render pipeline (Blender CLI OR Puppeteer) → animated WebP | asset-pipeline | done | P4-1 | Duck → 24-frame animated WebP (54KB) at `public/previews/Duck.webp`, wired into `#preview` hydration |

## Phase 5 — Hardening (serial)
| ID | Task | Agent | Status | Depends | Acceptance |
|----|------|-------|--------|---------|------------|
| P5-1 | Dispose pattern: geometry/material/texture + revoke on teardown | reviewer | done | P1, P2 | no leaks after 10 load/unload cycles (DevTools heap) |
| P5-2 | Error handling: bad file, decode fail, WebGL unsupported | reviewer | done | P1-3, P2-1 | graceful message, no uncaught exception |
| P5-3 | Perf smoke: load each sample, record time, assert < 3s perceived | reviewer | done | P4-2 | timings logged in PROGRESS.md |
| P5-4 | Full review pass (correctness + simplification) | reviewer | done | all | `/review` clean; no high-severity findings |

## Phase 6 — Multi-format loaders (OBJ/FBX/DAE/IFC), SSR+CSR — via dynamic workflow `multiformat-viewer`
| ID | Task | Agent | Status | Depends | Acceptance |
|----|------|-------|--------|---------|------------|
| P6-1 | Unified `src/viewer/modelLoader.ts` — `loadModel(url)` dispatch by ext, normalize each loader to `Object3D` | viewer-core | done | P1-1 | glb/gltf/obj/fbx/dae/ifc all resolve to scene-addable Object3D; GLB path unchanged |
| P6-2 | OBJ/FBX/DAE via three example loaders, lazy dynamic-import (separate Vite chunks) | viewer-core | done | P6-1 | OBJLoader/FBXLoader/ColladaLoader code-split; load samples |
| P6-3 | IFC via `web-ifc` IfcAPI (single-thread wasm `/web-ifc/`, no COOP/COEP) — StreamAllMeshes → BufferGeometry | viewer-core | done | P6-1 | Cube.ifc loads; geom.delete + CloseModel (no wasm leak) |
| P6-4 | CSR: dropzone accepts all 6 exts via `extOf`; SSR: `loadServerAsset` + main.ts preview-base route any ext | viewer-core+dnd | done | P6-1, P2-1 | drop any of 6 loads; `?model=` + `/previews/<base>.webp` works per format |
| P6-5 | Samples + 360° WebP previews per format (Cube.obj/dae/fbx/ifc) | asset-pipeline | done | P6-1, P4-3 | 4 samples in samples/ + public/samples/; 4 animated WebP (24 frames) in public/previews/ |

## Phase 7 — On-screen FPS + adaptive quality (<60fps → optimize)
| ID | Task | Agent | Status | Depends | Acceptance |
|----|------|-------|--------|---------|------------|
| P7-1 | `src/ui/fps.ts` — on-screen FPS overlay (EMA, throttled DOM, color-coded) | viewer-core | done | P0-3 | FPS readout visible; green≥60/orange/red |
| P7-2 | `src/viewer/adaptiveQuality.ts` — pixelRatio tier ladder + hysteresis; step down when sustained <60, recover with headroom | viewer-core | done | P7-1 | tier steps on sustained <60fps; no oscillation; logs tier change |
| P7-3 | Wire fps.sample + adaptive.update into `ThreeDViewer.animate` | viewer-core | done | P7-1, P7-2 | both fed each frame; rotateTo (prerender) unaffected |

---

## Parallelization map
- **Wave 1 (after P0):** P1-1 (core) then forks → P1-2/P1-3/P1-4 + P2-1 + P4-1/P4-2 can proceed in parallel.
- **Wave 2:** P3-* depends on core; P2-2 depends on P1-3+P2-1.
- **Wave 3:** P5-* after functional work lands.

Independent agents that may run concurrently: `viewer-core`, `dnd-handler`, `asset-pipeline`. `hydration` waits on `viewer-core`. `reviewer` runs last + on-demand.

## Validated extras (post-PLAN)
- **KTX2 runtime decode** ✅ — KTX2Loader + DRACOLoader together decode the Khronos ABeautifulGame KTX2+Draco GLB (11.5MB) in 626ms perceived (perf-smoke). Runtime path confirmed.
- **KTX2 production encoding** ⏸ — needs KTX-Software (`toktx`) installed; then `gltf-transform etc1s|uastc`. Not on this box. Documented in `tools/preprocess.mjs` header + user memory.
