From 21b2b3352f25a238f4b64bf5f2f968f5ef42622b Mon Sep 17 00:00:00 2001 From: rUv Date: Wed, 29 Apr 2026 19:35:41 -0400 Subject: [PATCH] feat(pointcloud): GitHub Pages demo with optional live backend (ADR-094) (#495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishes the live 3D point cloud viewer to gh-pages/pointcloud/ so it can be linked from the README alongside the Observatory and Dual-Modal Pose Fusion demos. The viewer auto-selects its transport from URL parameters: - default / ?backend=auto — try /api/splats, fall back to synthetic demo - ?backend=demo — synthetic in-browser only, no network - ?backend= — fetch from a CORS-permitting host running ruview-pointcloud serve - ?live=1 — strict mode, show offline panel instead of demo fallback The synthetic frame matches the live API JSON shape (splats, count, frame, live, pipeline.{skeleton,vitals}) so a single render path drives both modes. New workflow uses keep_files: true to preserve the existing observatory/, pose-fusion/, and nvsim/ deployments on gh-pages. See docs/adr/ADR-094-pointcloud-github-pages-deployment.md for the full decision record and 6 acceptance gates. --- .github/workflows/pointcloud-pages.yml | 67 +++++++ ...-094-pointcloud-github-pages-deployment.md | 164 ++++++++++++++++++ .../wifi-densepose-pointcloud/src/viewer.html | 146 +++++++++++++++- 3 files changed, 371 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/pointcloud-pages.yml create mode 100644 docs/adr/ADR-094-pointcloud-github-pages-deployment.md diff --git a/.github/workflows/pointcloud-pages.yml b/.github/workflows/pointcloud-pages.yml new file mode 100644 index 00000000..74f33deb --- /dev/null +++ b/.github/workflows/pointcloud-pages.yml @@ -0,0 +1,67 @@ +name: Point Cloud Viewer → GitHub Pages + +# Publishes the live 3D point cloud viewer to gh-pages/pointcloud/. +# The viewer defaults to a synthetic in-browser demo; users can append +# ?backend= or ?backend=auto to point it at a real ruview-pointcloud +# server (CORS-permitting host required). See ADR-094. +# +# Uses keep_files: true to preserve the existing observatory/, pose-fusion/, +# nvsim/, and root index.html demos already on gh-pages. + +on: + push: + branches: [main] + paths: + - 'v2/crates/wifi-densepose-pointcloud/src/viewer.html' + - '.github/workflows/pointcloud-pages.yml' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: pointcloud-pages + cancel-in-progress: true + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v4 + + - name: Stage viewer for Pages + run: | + mkdir -p _site/pointcloud + cp v2/crates/wifi-densepose-pointcloud/src/viewer.html _site/pointcloud/index.html + # Drop a tiny README so direct browsers of the directory get context. + cat > _site/pointcloud/README.md <<'EOF' + # RuView — Live 3D Point Cloud Viewer + + Hosted at: https://ruvnet.github.io/RuView/pointcloud/ + + ## Modes + + - Default — synthetic in-browser demo (no backend, no network calls). + - `?backend=auto` — fetch from `/api/splats` on the same origin + (only works when the viewer is served by `ruview-pointcloud serve`). + - `?backend=` — fetch from `/api/splats` on a CORS-permitting + host (e.g. `?backend=https://my-ruview.example.com`). + - `?live=1` — require a live backend; show an offline message instead + of falling back to the synthetic demo. + + See ADR-094 for the deployment design. + EOF + + - name: Deploy to gh-pages/pointcloud/ + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./_site/pointcloud + destination_dir: pointcloud + # CRITICAL: preserves observatory/, pose-fusion/, nvsim/, and root + # index.html already on gh-pages. + keep_files: true + commit_message: 'deploy(pointcloud): ${{ github.sha }}' + user_name: 'github-actions[bot]' + user_email: 'github-actions[bot]@users.noreply.github.com' diff --git a/docs/adr/ADR-094-pointcloud-github-pages-deployment.md b/docs/adr/ADR-094-pointcloud-github-pages-deployment.md new file mode 100644 index 00000000..f9214825 --- /dev/null +++ b/docs/adr/ADR-094-pointcloud-github-pages-deployment.md @@ -0,0 +1,164 @@ +# ADR-094: Live 3D Point Cloud Viewer — GitHub Pages Deployment with Optional Real-Data Stream + +| Field | Value | +|---|---| +| **Status** | Proposed (2026-04-29) | +| **Date** | 2026-04-29 | +| **Authors** | ruv | +| **Related** | ADR-092 (nvsim dashboard Pages deployment), ADR-059 (live ESP32 CSI pipeline), ADR-079 (camera ground-truth training) | +| **Branch** | `feat/pointcloud-pages-demo` | + +--- + +## 1. Context + +The `wifi-densepose-pointcloud` crate ships a Three.js-based viewer +(`v2/crates/wifi-densepose-pointcloud/src/viewer.html`) that renders the +fused camera-depth + WiFi CSI + mmWave point cloud produced by the +`ruview-pointcloud serve` binary. Today the viewer is local-only: + +- It is served by the Axum binary on `127.0.0.1:9880`. +- It polls `/api/splats` every 500 ms expecting a backend on the same + origin. +- There is no GitHub Pages deployment, so the README's + "▶ Live 3D Point Cloud" link points at the moved-content section in + `docs/readme-details.md`, not at a hosted demo. The two sibling demos + (Live Observatory, Dual-Modal Pose Fusion) are already hosted at + `https://ruvnet.github.io/RuView/` and `…/pose-fusion.html`. + +This is an asymmetry: a first-time visitor can preview the WiFi pose +demo and the Observatory in one click, but cannot preview the point +cloud without cloning the repo, building Rust, plugging in an ESP32, +and pointing a webcam at themselves. That gap suppresses the most +visually compelling demonstration of the v0.7+ sensor-fusion work. + +A naive fix — drop the static HTML at `gh-pages/pointcloud/` — does +not work because the viewer's `fetch("/api/splats")` will 404 on Pages +and the canvas will hang at "Loading…". A second naive fix — bake in a +fixed sample dataset — solves the loading state but loses the live-data +story entirely, and forks the viewer into a "demo build" and a "real +build" that drift apart. + +## 2. Decision + +Ship **one** viewer that auto-selects its transport from URL parameters, +and publish it to `gh-pages/pointcloud/` alongside the other demos: + +1. **Default mode** — when the viewer is opened with no query parameters + on `https://ruvnet.github.io/RuView/pointcloud/`, render a synthetic + in-browser scene (floor grid, walls, breathing/swaying figure, animated + 17-keypoint skeleton) and label the badge `● DEMO Synthetic`. No + network calls are made. Renders forever, deterministic, ~200 splats. +2. **Auto mode** (`?backend=auto`) — fetch from `/api/splats` on the same + origin. This is the local-development case (`ruview-pointcloud serve` + serves the viewer and the API together). On any failure (404, network + error, CORS), fall back silently to synthetic-demo rendering so the + tab never dies. +3. **Remote mode** (`?backend=`) — fetch from `/api/splats`. The + user supplies a CORS-permitting host running their own + `ruview-pointcloud serve` (e.g. a Tailscale-exposed home node). Badge + reads `● REMOTE `. Same silent demo fallback on failure. +4. **Strict-live mode** (`?live=1`) — disable the demo fallback. If the + chosen transport fails, replace the info panel with an explicit offline + message (`● OFFLINE — Live backend required but unreachable`). Useful + for embedding the viewer in a status page or kiosk. + +The synthetic frame returned by the in-browser generator matches the +JSON shape of the live `/api/splats` payload exactly (`splats`, `count`, +`frame`, `live`, `pipeline.{skeleton,vitals,…}`), so a single render path +drives both modes. There is no demo build vs real build — only one HTML +file, one render path, and one set of bugs. + +A new GitHub Actions workflow (`.github/workflows/pointcloud-pages.yml`) +copies the viewer to `gh-pages/pointcloud/index.html` on every push to +`main` that touches the viewer, using `peaceiris/actions-gh-pages@v4` +with `keep_files: true` to preserve the existing observatory, pose-fusion, +and nvsim deployments. + +## 3. Consequences + +### Positive + +- **First-click demo.** Visitors clicking the README's + "▶ Live 3D Point Cloud" link land on a working Three.js scene in <1 s, + no toolchain required. Matches the parity of the other two demos. +- **Real-data on demand.** Users with their own `ruview-pointcloud serve` + host can use the same hosted viewer URL with + `?backend=https://their-host.example.com` — no clone, no rebuild. The + hosted demo doubles as a thin client for self-hosted backends. +- **Single render path.** Synthetic frames flow through the same + `handleData → updateSplats → drawSkeleton` pipeline as live frames, so + visual regressions surface in the demo and the live build at the same + time. This is the same dual-transport pattern ADR-092 chose for nvsim. +- **No backend deploy required.** Pages serves static HTML; the demo + works without standing up an Axum host on the public internet, and + there is no per-visitor CSI/camera plumbing to provision. +- **Preserves existing deployments.** `keep_files: true` plus the + `pointcloud/` destination means observatory/, pose-fusion/, nvsim/, + and the root index.html on gh-pages are untouched. + +### Negative / tradeoffs + +- **Synthetic ≠ real.** The demo figure is procedural, not recorded from + hardware, so visitors cannot see *real* CSI-derived poses without + supplying `?backend=`. We accept this — the alternatives (pre-recorded + JSON, on-page WASM inference) add maintenance cost and diverge the + render path. +- **CORS burden on remote mode.** Users who want to share their backend + must add `Access-Control-Allow-Origin: https://ruvnet.github.io` (or + `*`) to their `ruview-pointcloud serve` config. We document this in the + workflow's generated README; we do **not** add a public proxy. +- **Synthetic generator lives in the viewer.** ~80 LOC of procedural JS + is now part of `viewer.html`. Acceptable: the file is already the + client-side render bundle, and the generator is bounded and inert + (deterministic, no I/O, no eval). +- **No replay-from-recording in this ADR.** A future ADR may add a + `?recording=.jsonl` mode that replays captured frames at native + rate; that is out of scope here. + +### Neutral + +- The local-dev experience is unchanged. `ruview-pointcloud serve` still + serves `viewer.html` from the bundled asset and the viewer still hits + `/api/splats` because `?backend` defaults to `auto`. Nothing in the + Rust crate changes — this is HTML + workflow only. + +## 4. Implementation + +| File | Change | +|---|---| +| `v2/crates/wifi-densepose-pointcloud/src/viewer.html` | Add URL-param transport selector (`backend`, `live`), synthetic frame generator, demo-fallback path, transport-aware mode badge. ~120 LOC added, no removed behavior. | +| `.github/workflows/pointcloud-pages.yml` | New workflow: stage viewer to `_site/pointcloud/index.html`, deploy to `gh-pages/pointcloud/` with `keep_files: true`. Triggers on viewer changes and on manual dispatch. | +| `README.md` | Already updated — `▶ Live 3D Point Cloud` link will be retargeted to `https://ruvnet.github.io/RuView/pointcloud/` once the first deploy succeeds. (Tracked separately, not blocking this ADR.) | +| `docs/adr/README.md` | ADR index — add ADR-094 row. | + +## 5. Acceptance Gates + +This ADR is **Implemented** when all of the following hold: + +1. Pushing to `main` with a viewer change triggers + `pointcloud-pages.yml`, which deploys to `gh-pages/pointcloud/` in + under 60 seconds. +2. `https://ruvnet.github.io/RuView/pointcloud/` loads, renders the + synthetic scene, displays `● DEMO Synthetic` badge, and shows + non-zero splat + frame counts. +3. Existing demos at `https://ruvnet.github.io/RuView/` and + `…/pose-fusion.html` and `…/nvsim/` are still reachable after the + first deploy (smoke-tested manually). +4. `https://ruvnet.github.io/RuView/pointcloud/?live=1` shows the + `● OFFLINE` panel (because no same-origin backend exists on Pages). +5. `https://ruvnet.github.io/RuView/pointcloud/?backend=https://example.invalid` + falls back to demo within one poll interval (~500 ms) without + throwing in the console. +6. Running `./target/release/ruview-pointcloud serve` locally and + opening `http://127.0.0.1:9880/` (which serves the same HTML) still + shows live-mode rendering with the `● LIVE Local Backend` badge. + +## 6. Out of Scope + +- Replaying recorded JSONL frames in the browser (future ADR). +- WASM-side execution of the fusion pipeline in the browser (would + require porting the camera + mmWave path; deferred). +- Authentication / signed splats payloads — backend-side concern, + unaffected by this client-side change. +- Hosting a public CORS proxy for users without their own backend. diff --git a/v2/crates/wifi-densepose-pointcloud/src/viewer.html b/v2/crates/wifi-densepose-pointcloud/src/viewer.html index 342735d7..c7895308 100644 --- a/v2/crates/wifi-densepose-pointcloud/src/viewer.html +++ b/v2/crates/wifi-densepose-pointcloud/src/viewer.html @@ -104,10 +104,139 @@ scene.add(skeletonGroup); } + // ----- Transport configuration ----- + // ?backend= → fetch splats from /api/splats (CORS-permitting host) + // ?backend=auto → try /api/splats, fall back to synthetic demo on failure (default) + // ?backend=demo → always render synthetic demo (no network) + // ?live=1 → require live; show error instead of demo fallback + var urlParams = new URLSearchParams(window.location.search); + var backendArg = urlParams.get("backend") || "auto"; + var requireLive = urlParams.get("live") === "1"; + var transportMode = "demo"; // resolved at first fetch: "live" | "remote" | "demo" + var demoStartMs = Date.now(); + var demoFrameNum = 0; + + function buildSplatsUrl() { + if (backendArg === "demo") return null; + if (backendArg === "auto") return "/api/splats"; + // User-supplied URL — strip trailing slash and append /api/splats. + var base = backendArg.replace(/\/+$/, ""); + return base + "/api/splats"; + } + + function syntheticFrame() { + // Deterministic synthetic point cloud: floor grid, two walls, and + // a standing figure that breathes/sways. Resembles the live API + // payload so the same render path drives both modes. + var t = (Date.now() - demoStartMs) / 1000.0; + var sway = Math.sin(t * 0.8) * 0.05; + var breath = Math.sin(t * 1.2) * 0.015; + var splats = []; + + // Floor — 12x12 grid at y=-1 + var gx, gz; + for (gx = -6; gx <= 6; gx++) { + for (gz = 0; gz <= 12; gz++) { + splats.push({ + center: [gx * 0.4, -1.0, gz * 0.4], + color: [0.15, 0.18, 0.22], + opacity: 1.0, + scale: [0.05, 0.05, 0.05] + }); + } + } + // Back wall + side walls — sparse vertical strips + var wy, wx; + for (wy = -1; wy <= 2; wy++) { + for (wx = -6; wx <= 6; wx += 2) { + splats.push({ + center: [wx * 0.4, wy * 0.5, 4.8], + color: [0.12, 0.20, 0.28], + opacity: 1.0, + scale: [0.05, 0.05, 0.05] + }); + } + splats.push({ center: [-2.4, wy * 0.5, 0.5 + (wy + 1) * 0.8], color: [0.12, 0.20, 0.28], opacity: 1.0, scale: [0.05, 0.05, 0.05] }); + splats.push({ center: [ 2.4, wy * 0.5, 0.5 + (wy + 1) * 0.8], color: [0.12, 0.20, 0.28], opacity: 1.0, scale: [0.05, 0.05, 0.05] }); + } + // Standing figure — 60 points in a vertical cylinder + var i, theta, r, py; + for (i = 0; i < 60; i++) { + theta = (i / 60) * Math.PI * 2; + py = -0.6 + (i / 60) * 1.6; + r = 0.18 + breath * (py > 0 ? 1 : 0); + splats.push({ + center: [sway + Math.cos(theta) * r, py, 2.3 + Math.sin(theta) * r], + color: [0.91, 0.65, 0.20], + opacity: 1.0, + scale: [0.04, 0.04, 0.04] + }); + } + + // 17 COCO keypoints in normalized [0,1] image coords (matches live shape) + var headY = 0.18; + var keypoints = [ + [0.50 + sway * 0.05, headY, 0.95], // 0 nose + [0.52 + sway * 0.05, headY - 0.01, 0.92], // 1 leftEye + [0.48 + sway * 0.05, headY - 0.01, 0.92], // 2 rightEye + [0.54 + sway * 0.05, headY, 0.85], // 3 leftEar + [0.46 + sway * 0.05, headY, 0.85], // 4 rightEar + [0.60 + sway * 0.04, 0.32, 0.93], // 5 leftShoulder + [0.40 + sway * 0.04, 0.32, 0.93], // 6 rightShoulder + [0.65 + sway * 0.03, 0.46, 0.90], // 7 leftElbow + [0.35 + sway * 0.03, 0.46, 0.90], // 8 rightElbow + [0.68, 0.60 + Math.sin(t * 1.4) * 0.02, 0.86], // 9 leftWrist + [0.32, 0.60 - Math.sin(t * 1.4) * 0.02, 0.86], // 10 rightWrist + [0.57, 0.58, 0.94], // 11 leftHip + [0.43, 0.58, 0.94], // 12 rightHip + [0.58, 0.74, 0.90], // 13 leftKnee + [0.42, 0.74, 0.90], // 14 rightKnee + [0.59, 0.92, 0.88], // 15 leftAnkle + [0.41, 0.92, 0.88] // 16 rightAnkle + ]; + + demoFrameNum += 1; + return { + splats: splats, + count: splats.length, + frame: demoFrameNum, + live: false, + pipeline: { + skeleton: { keypoints: keypoints, confidence: 0.86 }, + vitals: { + breathing_rate: 14 + Math.round(Math.sin(t * 0.05) * 2), + motion_score: 0.18 + Math.abs(sway) * 2 + } + } + }; + } + async function fetchCloud() { + // Demo-only mode: never hit the network. + if (backendArg === "demo") { + transportMode = "demo"; + handleData(syntheticFrame()); + return; + } try { - var resp = await fetch("/api/splats"); + var resp = await fetch(buildSplatsUrl(), { cache: "no-store" }); + if (!resp.ok) throw new Error("HTTP " + resp.status); var data = await resp.json(); + transportMode = (backendArg === "auto") ? "live" : "remote"; + handleData(data); + } catch (err) { + if (requireLive) { + document.getElementById("stats").innerHTML = + '● OFFLINE
Live backend required (?live=1) but unreachable.
' + (err && err.message ? err.message : err) + ''; + return; + } + transportMode = "demo"; + handleData(syntheticFrame()); + } + } + + function handleData(data) { + try { if (data.splats && data.frame !== lastFrame) { // Compute CSI frame rate var now = Date.now(); @@ -127,11 +256,16 @@ clearSkeleton(); } - // Build info panel - var mode = data.live - ? '● LIVE' - : '● DEMO'; - var html = mode + " Camera + CSI
" + // Build info panel — badge reflects active transport + var mode; + if (transportMode === "live") { + mode = '● LIVE Local Backend'; + } else if (transportMode === "remote") { + mode = '● REMOTE ' + backendArg; + } else { + mode = '● DEMO Synthetic'; + } + var html = mode + "
" + "Splats: " + data.count + "
" + "Frame: " + data.frame;