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.
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 β¦