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.
fix(io): frame_timestamp() counted Toaster's own sidecar labels as framesβ
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β¦
feat(web): resizable and minimizable floating windowsβ
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β¦
fix(web): default to Orbit camera controls, like every other 3D viewerβ
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 β¦
fix(web): pause decorative CSS animations when the page goes idleβ
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.
fix(engine): rerun-calibrated camera feel near the orbit pivotβ
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.
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β¦
feat(api,web): delta label protocol β labelling is now O(edit), not O(cloud)β
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 β¦
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.
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.