feat(pointcloud): GitHub Pages demo with optional live backend (ADR-094) (#495)
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=<url> — 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.
This commit is contained in:
parent
e11d569a39
commit
21b2b3352f
|
|
@ -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=<url> 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=<url>` — fetch from `<url>/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'
|
||||
|
|
@ -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=<url>`) — fetch from `<url>/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 <url>`. 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=<url>.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.
|
||||
|
|
@ -104,10 +104,139 @@
|
|||
scene.add(skeletonGroup);
|
||||
}
|
||||
|
||||
// ----- Transport configuration -----
|
||||
// ?backend=<url> → fetch splats from <url>/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 =
|
||||
'<span class="demo">● OFFLINE</span><br>Live backend required (?live=1) but unreachable.<br><span class="label">' + (err && err.message ? err.message : err) + '</span>';
|
||||
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
|
||||
? '<span class="live">● LIVE</span>'
|
||||
: '<span class="demo">● DEMO</span>';
|
||||
var html = mode + " Camera + CSI<br>"
|
||||
// Build info panel — badge reflects active transport
|
||||
var mode;
|
||||
if (transportMode === "live") {
|
||||
mode = '<span class="live">● LIVE</span> Local Backend';
|
||||
} else if (transportMode === "remote") {
|
||||
mode = '<span class="live">● REMOTE</span> ' + backendArg;
|
||||
} else {
|
||||
mode = '<span class="demo">● DEMO</span> Synthetic';
|
||||
}
|
||||
var html = mode + "<br>"
|
||||
+ "Splats: " + data.count + "<br>"
|
||||
+ "Frame: " + data.frame;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue