hf-space kept its own web/js/engine copy while main moved to projector's
(48c766c); it had drifted behind the shared engine (no WebGL context-loss
recovery, grid stranded at the origin under a TF-resolved frame, no
setCameraPose), so the HF Space ran the old engine. Mount
projector.web_engine_dir() at /js/engine, as main does, and delete the copy.
projector-engine is not on PyPI yet: CI installs it from GitHub and the Space
image from the v0.1.0 tag archive (the slim image ships no git); locally
[tool.uv.sources] resolves the ../projector sibling, editable.
fix(web): bundle the engine + vendored three in the wheel
augustin-bresset
3a2471b
package-data glob web/js/*.js is non-recursive, so once the engine moved to web/js/engine/ and three was vendored under web/vendor/ (commit 8c9cb02), a wheel install shipped no engine. The HF Space serves assets from the installed package, so the browser 404'd ./engine/viewer.js and the three importmap, the ES module graph died and the 3D view rendered no points. List the engine and vendor globs explicitly.
Orbit-by-default (walls off at the poles, 'blocked at a plane') was the
exact behaviour this codebase used TrackballControls exclusively to
avoid. Back to trackball as default; Orbit stays available as an opt-in
Display-window toggle for gravity-aligned scans that want a level
horizon.
frame_timestamp() globbed the source channel's directory with a bare
'*.npy' to find a frame's position (and from it, its line in
timestamps.txt) — but that glob also matches Toaster's local sidecar
labels saved beside the source (<stem>_toaster.npy, from the plain
'Save' button, unrelated to the apairo channel this function serves).
Every such sidecar sorting alphabetically before a given stem silently
shifted that frame's computed position by one, so write_labels_channel()
stored the WRONG timestamp for any apairo channel frame saved afterward
— the frame's own point-cloud file was untouched, but its label channel
entry drifted onto a neighboring frame's timestamp.
Confirmed against a real dataset: a frame's ground_truth timestamp was
off by 48.8ms from what frame_timestamp() computes today, which lands
exactly on the PREVIOUS frame (nearest-timestamp match, delta 0). A
downstream viewer that resyncs a sparse label channel to its lidar
source by nearest timestamp (rather than by ste…
Every .window (Segmenter, Segments, Display, ...) now gets a resize grip
(bottom-right corner, drags width and height, clamped to a 160x90 floor
and the viewport) and a minimize toggle (collapses to just the titlebar),
wired uniformly in setupWindows() — no per-window HTML changes needed.
Geometry and collapsed state persist per window in localStorage.
The Segmenter and Segments windows were the motivating case: a long
params list or a long segment list had no way to get more room, or to
tuck the window away without losing its state. Base .body is now
flex:1 (previously only the .grow modifier had it, used solely by
win-groups) so a window resized taller actually gives the extra space to
its body instead of leaving it dead — the .grow modifier is now
redundant and removed.
Verified headless: min/resize controls exist on every window, minimize
hides the body and persists, the resize grip changes both dimensions and
clamps at the floor, the body fills a resized-taller window, the
title…
The engine grew setControlStyle('orbit'|'trackball') during the projector
migration (projector defaults to orbit), but toaster's own app.js never
called it — every camera drag free-tumbled with the horizon able to roll,
unlike CloudCompare/MeshLab/rerun/Potree/Blender, all of which lock the
up axis. Users expecting standard orbit behaviour ('turn around the
vision sphere like every other viewer') got trackball instead, with no
way to change it.
app.js now defaults to Orbit and exposes a Display-window toggle
(persisted to localStorage, like the theme picker) back to free-tumble
Trackball — still needed for a scan that isn't gravity-aligned (Toaster's
original rationale for trackball-only). The existing arrow-key roll/
pitch/yaw shortcuts keep working under Orbit: OrbitControls recomputes
its up-alignment quaternion from camera.up on every update() call, so a
manual roll never desyncs it — verified headless, not just read from the
vendored source.
tests/test_web_ui.py locks in: orbit …
Two `animation: ... infinite` rules — the bottom-edge 'ember' glow
(unconditional, body::after) and the active theme's logo (toast bob /
steam rise / invader hop) — ran forever, with no relation to the 3D
canvas's render-on-demand rAF loop. Under QtWebEngine's Vulkan fallback,
continuous compositing leaks Oilpan memory the same way an unparked rAF
loop does: a desktop session left open (not necessarily interacting with
the 3D view — just not touching mouse/keyboard) eventually OOMs, even
showing a tiny static cloud. Reported after ~24 minutes uptime.
app.js's watchPageIdle() adds body.idle after 8s of no pointer/wheel/key
input anywhere on the page (long enough that normal working cadence never
triggers it); style.css pauses (not removes) those two animations off it,
so they resume without a restart-jump on the next interaction.
tests/test_web_ui.py asserts the pause fires after the threshold, targets
the correct theme's animated mark, and un-pauses on the very next input.
Close to the pivot the camera felt wild. Three causes, three fixes,
all matching what rerun's orbital eye does:
- Fly speed now follows the camera-to-pivot distance (was: global scene
radius, so zooming in never slowed the fly keys down). Floor of 2% of
the scene radius so a camera parked on the pivot can still leave.
- The orbit radius is clamped to 2x the near plane (rerun's
MIN_ORBIT_DISTANCE) — zooming can no longer collapse through the pivot,
where every drag whipped the view.
- rotateSpeed 3.0 → 1.8, close to rerun's ~0.004 rad/px feel.
The engine test asserts the distance-scaled fly step and the minDistance
clamp so the calibration can't silently regress.
The last surface projector-class embedders need: a configurable motion-LOD
budget, public requestRender() (hosts move the camera directly in follow
modes) and resize() (panel layouts resize without window events), and a
flyGate predicate so only the hovered viewer of a multi-view workspace
consumes fly keys.
feat(engine): embedding options — size attenuation, control styles, streaming
Extensions the engine needs to host projector-class consumers without
regressing their features:
- setSizeAttenuation(on): point size becomes a world size (metres) that
shrinks with distance; the outline math follows the per-point on-screen
size (vSizePx varying) instead of assuming a uniform pixel size.
- setControlStyle('trackball'|'orbit'): free-tumble (default, unchanged)
or upright OrbitControls — pose and pivot survive the swap; box-mode
button maps are translated per style.
- setCloud(xyz, {octree, frame}) + buildOctree(): streaming consumers load
a frame per tick without worker churn or camera yanks, and rebuild the
octree once playback pauses. The reference grid only rebuilds when the
scene scale moves >25%, not on every streamed frame.
- Vendored OrbitControls addon alongside TrackballControls; removed a
duplicate un-removable window resize listener.
Preparation for reusing the viewer in sibling projects (projector, apairo
tooling):
- toaster/web/js/engine/ now holds the reusable layer (viewer, octree,
octree-worker, overlays) with a README stating the contract: the engine
knows nothing about Toaster's REST API, labels or panels — anything
imported there besides three is a bug. app.js/api.js/colors.js stay one
level up as the application layer.
- three.js r160 is vendored under web/vendor/ (same layout as projector):
the desktop app no longer needs internet for the unpkg importmap.
- Engine hardening for embedders: dispose() (detaches the window listeners,
now held in one named map; frees GPU resources, worker, canvas), and
no-cloud guards on every public entry point.
- Tests: the orbit-crosshair behaviour moves from a scratchpad script into
tests/test_web_ui.py; new coverage for the isolate-mode full-recompute
fallback, dispose(), and a screenshot-level check that delta-ranged GPU
uploads render identically to…
Every label edit (assign, undo, redo, group assigns, apply-suggested) used
to ship the full label array back and recompute + re-upload colours for
every point — ~160 MB of work per click on a 10M-point cloud. Now:
- InteractionController's label-editing methods return the touched indices.
- AnnotationService.state(touched=...) responds with labels_delta
{indices, values} instead of the full labels/grouping arrays.
- The client patches its cached labels, recolours only the touched points
(colors.js: makeLabelColorizer is the single per-point rule shared by the
full recompute and the delta path, so they cannot drift), re-evaluates
visibility only where it can change (touched + both selections), and
uploads just the coalesced dirty ranges (viewer.commitColors).
- Isolate mode falls back to the full recompute — its alpha is a function
of the whole selection, not of the edit.
tests/test_web_ui.py drives the real stack headless (worker octree, motion
cut, pick equivalence) and …
refactor(web): split viewer.js into focused modules
viewer.js was accreting every rendering concern (~950 lines). Pure moves,
no behavior change:
- octree.js: OctreeIndex — the per-frame LOD cut and the conservative
screen-bound picking traversals over the worker-built node table.
- overlays.js: ReferenceGrid (ground grid quad + shaders) and
OrbitIndicator (rerun-style pivot crosshair with its linger/fade state).
viewer.js keeps the renderer, controls, cloud attributes, LOD
orchestration and the pick entry points.
A Potree-style additive octree (every point in exactly one node; each
internal node keeps a uniform random sample of its subtree) is built in a
Web Worker, so the UI never blocks. Its output is a permutation of point
indices where each node's points are one contiguous slice, plus a flat
typed-array node table.
- Motion LOD: per-frame breadth-first cut — frustum-culled, descending
while a node covers > 110 px — replaces the random-subset fallback the
moment the build lands. Near regions stay dense, far ones thin out,
off-screen subtrees drop entirely; running out of budget degrades
resolution, never coverage. Full-res refine at idle is unchanged.
- Picking: pick/pickBox walk the octree with conservative screen-space
bounds instead of projecting every point per click (exact same results,
verified against the brute-force scan on a 3M-point cloud).
- The random-subset LOD remains as the stopgap while the worker builds
(~400 ms for 3M points) and for clouds the octree is skip…
fix(core): labelling after an apairo resume silently did nothing
AnnotationController cached cloud.labels at construction, but resume_apairo
replaces the array wholesale — every later assign wrote into the orphaned
pre-resume array while the UI (and saves) read the new one. The controller
now reads cloud.labels live, and resume clears the undo history so edits
recorded against the old array can't be spliced into the resumed labels.
fix(core): labelling after an apairo resume silently did nothing
AnnotationController cached cloud.labels at construction, but resume_apairo
replaces the array wholesale — every later assign wrote into the orphaned
pre-resume array while the UI (and saves) read the new one. The controller
now reads cloud.labels live, and resume clears the undo history so edits
recorded against the old array can't be spliced into the resumed labels.
Three short world-axis segments at controls.target, shown during camera
motion and fading out 350ms after it stops (100ms smoothstep, sized as a
fraction of camera-to-pivot distance). Depth-tested like rerun's, so the
cloud occludes it and its visibility reads as pivot depth.
Deploy Toaster demo to HF Space — procedural natural-terrain sample
Snapshot of main: the sample generator now builds a procedural natural
terrain (sample.bin still regenerated at image build), demo CLI fixes, web
polish. Space README unchanged; GitHub-only binaries stay out.
Deploy Toaster demo to HF Space (sample.bin regenerated at build)