SmaugC137 commited on
Commit
28ee467
·
1 Parent(s): 8c2f4c9

feat(api,web): delta label protocol — labelling is now O(edit), not O(cloud)

Browse files

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 asserts the delta-patched buffers are
byte-identical to a from-scratch full-state re-render, with and without
the hide-labelled visibility mask, including across undo.

tests/test_api.py CHANGED
@@ -62,9 +62,13 @@ def test_segment_then_assign_group(client):
62
  sel = client.post("/api/group/select", json={"group_id": gid}).json()
63
  assert decode_array(sel["selection"]).size == snap["segments"][0]["count"]
64
 
65
- # Label that segment with class 4.
66
  after = client.post("/api/group/assign", json={"group_id": gid, "class_id": 4}).json()
67
- labels = decode_array(after["labels"])
 
 
 
 
68
  assert int((labels == 4).sum()) == snap["segments"][0]["count"]
69
 
70
 
@@ -88,8 +92,11 @@ def test_assign_visible_groups_endpoint(client):
88
  # Uncheck one segment, then assign class 3 to the visible (checked) ones.
89
  client.post("/api/group/visibility", json={"group_id": hide["id"], "visible": False})
90
  after = client.post("/api/groups/assign_visible", json={"class_id": 3}).json()
91
- labels = decode_array(after["labels"])
92
  # Exactly the visible segment's points became class 3; the hidden one did not.
 
 
 
93
  assert int((labels == 3).sum()) == sum(s["count"] for s in segs if s["id"] != hide["id"])
94
 
95
 
@@ -126,9 +133,12 @@ def test_pick_assign_undo(client):
126
  client.post("/api/active_class", json={"class_id": 2})
127
  client.post("/api/pick", json={"index": 5})
128
  after = client.post("/api/assign", json={}).json()
129
- assert decode_array(after["labels"])[5] == 2
 
130
  back = client.post("/api/undo").json()
131
- assert decode_array(back["labels"])[5] == 0
 
 
132
 
133
 
134
  def test_browse_lists_dir_and_flags_openable(client, tmp_path):
 
62
  sel = client.post("/api/group/select", json={"group_id": gid}).json()
63
  assert decode_array(sel["selection"]).size == snap["segments"][0]["count"]
64
 
65
+ # Label that segment with class 4 — the response is a delta, not full labels.
66
  after = client.post("/api/group/assign", json={"group_id": gid, "class_id": 4}).json()
67
+ delta = after["labels_delta"]
68
+ assert decode_array(delta["indices"]).size == snap["segments"][0]["count"]
69
+ assert (decode_array(delta["values"]) == 4).all()
70
+ # The full state still reflects the write.
71
+ labels = decode_array(client.get("/api/state").json()["labels"])
72
  assert int((labels == 4).sum()) == snap["segments"][0]["count"]
73
 
74
 
 
92
  # Uncheck one segment, then assign class 3 to the visible (checked) ones.
93
  client.post("/api/group/visibility", json={"group_id": hide["id"], "visible": False})
94
  after = client.post("/api/groups/assign_visible", json={"class_id": 3}).json()
95
+ delta = after["labels_delta"]
96
  # Exactly the visible segment's points became class 3; the hidden one did not.
97
+ assert decode_array(delta["indices"]).size == sum(s["count"] for s in segs if s["id"] != hide["id"])
98
+ assert (decode_array(delta["values"]) == 3).all()
99
+ labels = decode_array(client.get("/api/state").json()["labels"])
100
  assert int((labels == 3).sum()) == sum(s["count"] for s in segs if s["id"] != hide["id"])
101
 
102
 
 
133
  client.post("/api/active_class", json={"class_id": 2})
134
  client.post("/api/pick", json={"index": 5})
135
  after = client.post("/api/assign", json={}).json()
136
+ assert decode_array(after["labels_delta"]["indices"]).tolist() == [5]
137
+ assert decode_array(after["labels_delta"]["values"]).tolist() == [2]
138
  back = client.post("/api/undo").json()
139
+ assert decode_array(back["labels_delta"]["indices"]).tolist() == [5]
140
+ assert decode_array(back["labels_delta"]["values"]).tolist() == [0]
141
+ assert decode_array(client.get("/api/state").json()["labels"])[5] == 0
142
 
143
 
144
  def test_browse_lists_dir_and_flags_openable(client, tmp_path):
tests/test_controller.py CHANGED
@@ -127,8 +127,8 @@ def test_assign_group_labels_the_segment(two_clusters, schema):
127
  viewer = FakeViewer()
128
  ctl = InteractionController(session, viewer)
129
  ctl.set_active_class(1)
130
- n = ctl.assign_group(0)
131
- assert n == 50
132
  assert (session.cloud.labels[:50] == 1).all()
133
  assert (session.cloud.labels[50:] == 0).all()
134
  assert viewer.recolored # labelled points are repainted in any view
@@ -138,23 +138,23 @@ def test_apply_suggested_single_and_all(two_clusters, schema):
138
  session = _grouped_session(two_clusters, schema)
139
  ctl = InteractionController(session, FakeViewer())
140
  # Only group 1 carries a suggestion (-> class 2).
141
- n = ctl.apply_suggested(1)
142
- assert n == 50
143
  assert (session.cloud.labels[50:] == 2).all()
144
  # Group 0 has no suggestion -> no-op.
145
- assert ctl.apply_suggested(0) == 0
146
  # "All suggested" applies every group that has one.
147
  session2 = _grouped_session(two_clusters, schema)
148
  ctl2 = InteractionController(session2, FakeViewer())
149
- assert ctl2.apply_suggested(None) == 50
150
 
151
 
152
  def test_group_ops_noop_without_active_grouping(two_clusters, schema):
153
  two_clusters.ensure_labels(schema.unlabeled_id)
154
  session = Session(two_clusters, schema) # no grouping
155
  ctl = InteractionController(session, FakeViewer())
156
- assert ctl.assign_group(0) == 0
157
- assert ctl.apply_suggested() == 0
158
 
159
 
160
  def test_group_visibility_commands(two_clusters, schema):
@@ -187,9 +187,9 @@ def test_assign_visible_groups_labels_only_checked(two_clusters, schema):
187
  # Uncheck (hide) group 0, then assign -> only the visible group 1 is labelled.
188
  ctl.set_group_visibility(0, False)
189
  ctl.set_active_class(2)
190
- n = ctl.assign_visible_groups()
191
  labels = session.cloud.labels
192
- assert n == 50
193
  assert (labels[:50] == schema.unlabeled_id).all() # hidden group untouched
194
  assert (labels[50:] == 2).all() # checked group labelled
195
 
@@ -204,7 +204,7 @@ def test_clear_grouping_discards_segmentation_keeps_labels(two_clusters, schema)
204
  ctl.set_display_mode("grouping")
205
  # Label a segment first — its labels must survive the grouping being dropped.
206
  labelled = ctl.assign_group(0, 1)
207
- assert labelled == 50
208
 
209
  ctl.clear_grouping()
210
  assert session.active_grouping is None
 
127
  viewer = FakeViewer()
128
  ctl = InteractionController(session, viewer)
129
  ctl.set_active_class(1)
130
+ touched = ctl.assign_group(0)
131
+ assert touched.size == 50
132
  assert (session.cloud.labels[:50] == 1).all()
133
  assert (session.cloud.labels[50:] == 0).all()
134
  assert viewer.recolored # labelled points are repainted in any view
 
138
  session = _grouped_session(two_clusters, schema)
139
  ctl = InteractionController(session, FakeViewer())
140
  # Only group 1 carries a suggestion (-> class 2).
141
+ touched = ctl.apply_suggested(1)
142
+ assert touched.size == 50
143
  assert (session.cloud.labels[50:] == 2).all()
144
  # Group 0 has no suggestion -> no-op.
145
+ assert ctl.apply_suggested(0).size == 0
146
  # "All suggested" applies every group that has one.
147
  session2 = _grouped_session(two_clusters, schema)
148
  ctl2 = InteractionController(session2, FakeViewer())
149
+ assert ctl2.apply_suggested(None).size == 50
150
 
151
 
152
  def test_group_ops_noop_without_active_grouping(two_clusters, schema):
153
  two_clusters.ensure_labels(schema.unlabeled_id)
154
  session = Session(two_clusters, schema) # no grouping
155
  ctl = InteractionController(session, FakeViewer())
156
+ assert ctl.assign_group(0).size == 0
157
+ assert ctl.apply_suggested().size == 0
158
 
159
 
160
  def test_group_visibility_commands(two_clusters, schema):
 
187
  # Uncheck (hide) group 0, then assign -> only the visible group 1 is labelled.
188
  ctl.set_group_visibility(0, False)
189
  ctl.set_active_class(2)
190
+ touched = ctl.assign_visible_groups()
191
  labels = session.cloud.labels
192
+ assert touched.size == 50
193
  assert (labels[:50] == schema.unlabeled_id).all() # hidden group untouched
194
  assert (labels[50:] == 2).all() # checked group labelled
195
 
 
204
  ctl.set_display_mode("grouping")
205
  # Label a segment first — its labels must survive the grouping being dropped.
206
  labelled = ctl.assign_group(0, 1)
207
+ assert labelled.size == 50
208
 
209
  ctl.clear_grouping()
210
  assert session.active_grouping is None
tests/test_web_ui.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end checks of the web viewer's octree LOD/picking and delta recolour.
2
+
3
+ Drives the real stack — ``toaster-web`` served from THIS checkout, headless
4
+ Chromium via Playwright — against a synthetic cloud big enough to build the
5
+ octree and trip the motion LOD (> 1M points). Skips cleanly when Playwright
6
+ or its browser is unavailable.
7
+
8
+ Invariants covered:
9
+ - the worker-built octree is a permutation of the cloud with well-formed,
10
+ disjoint node slices;
11
+ - camera motion renders the octree cut (a strict subset), idle refines back
12
+ to the full cloud, and the rAF loop parks afterwards (the QtWebEngine
13
+ leak guard);
14
+ - octree pick/pickBox return exactly what the brute-force scans return;
15
+ - after delta-applied label edits (assign, undo), the client's colour/alpha
16
+ buffers are indistinguishable from a from-scratch full-state re-render.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import socket
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+ import pytest
30
+
31
+ pytest.importorskip("playwright.sync_api")
32
+ from playwright.sync_api import sync_playwright # noqa: E402
33
+
34
+ REPO = Path(__file__).resolve().parent.parent
35
+ N_POINTS = 1_200_000 # > LOD_BUDGET (1M) so the motion cut engages
36
+
37
+
38
+ def _free_port() -> int:
39
+ with socket.socket() as s:
40
+ s.bind(("127.0.0.1", 0))
41
+ return s.getsockname()[1]
42
+
43
+
44
+ @pytest.fixture(scope="module")
45
+ def server(tmp_path_factory):
46
+ """toaster-web on a random port, serving a 1.2M-point synthetic terrain."""
47
+ cloud = tmp_path_factory.mktemp("cloud") / "big.bin"
48
+ rng = np.random.default_rng(7)
49
+ xy = rng.uniform(-50, 50, (N_POINTS, 2)).astype(np.float32)
50
+ z = (np.sin(xy[:, 0] * 0.2) * np.cos(xy[:, 1] * 0.15) + rng.normal(0, 0.1, N_POINTS)).astype(
51
+ np.float32
52
+ )
53
+ i = rng.uniform(0, 1, N_POINTS).astype(np.float32)
54
+ np.column_stack([xy, z, i]).astype(np.float32).tofile(cloud)
55
+
56
+ port = _free_port()
57
+ env = os.environ | {"PYTHONPATH": str(REPO)} # serve THIS checkout, not the installed copy
58
+ proc = subprocess.Popen(
59
+ [sys.executable, "-m", "toaster.api.server", str(cloud), "--port", str(port)],
60
+ env=env,
61
+ cwd=REPO,
62
+ stdout=subprocess.DEVNULL,
63
+ stderr=subprocess.PIPE,
64
+ )
65
+ import urllib.request
66
+
67
+ deadline = time.time() + 30
68
+ while True:
69
+ try:
70
+ urllib.request.urlopen(f"http://127.0.0.1:{port}/api/meta", timeout=1)
71
+ break
72
+ except Exception:
73
+ if proc.poll() is not None:
74
+ pytest.skip(f"server died: {proc.stderr.read().decode()[-500:]}")
75
+ if time.time() > deadline:
76
+ proc.kill()
77
+ pytest.skip("server did not come up in 30s")
78
+ time.sleep(0.3)
79
+ yield f"http://127.0.0.1:{port}"
80
+ proc.terminate()
81
+ proc.wait(timeout=10)
82
+
83
+
84
+ @pytest.fixture(scope="module")
85
+ def page(server):
86
+ """One loaded page (cloud + octree ready) shared by the module's tests."""
87
+ with sync_playwright() as p:
88
+ try:
89
+ browser = p.chromium.launch()
90
+ except Exception as exc: # no browser installed
91
+ pytest.skip(f"chromium unavailable: {exc}")
92
+ page = browser.new_page(viewport={"width": 1280, "height": 800})
93
+ errors: list[str] = []
94
+ page.on("pageerror", lambda e: errors.append(f"pageerror: {e}"))
95
+ page.on(
96
+ "console",
97
+ lambda m: errors.append(f"console: {m.text}") if m.type == "error" else None,
98
+ )
99
+ page.goto(server)
100
+ page.wait_for_function(
101
+ f"window.__toaster && window.__toaster.viewer.geom && "
102
+ f"window.__toaster.viewer.geom.getAttribute('position').count === {N_POINTS}",
103
+ timeout=90000,
104
+ )
105
+ page.wait_for_function("window.__toaster.viewer._octree !== null", timeout=90000)
106
+ page._errors = errors
107
+ yield page
108
+ assert not errors, f"console/page errors: {errors}"
109
+ browser.close()
110
+
111
+
112
+ def test_octree_is_a_permutation_with_wellformed_slices(page):
113
+ ok = page.evaluate(
114
+ f"""(() => {{
115
+ const o = window.__toaster.viewer._octree.data;
116
+ if (o.order.length !== {N_POINTS}) return "bad order length";
117
+ const seen = new Uint8Array({N_POINTS});
118
+ for (let k = 0; k < o.order.length; k++) {{
119
+ if (seen[o.order[k]]) return "duplicate index";
120
+ seen[o.order[k]] = 1;
121
+ }}
122
+ for (let i = 0; i < seen.length; i++) if (!seen[i]) return "missing index";
123
+ for (let ni = 0; ni < o.start.length; ni++) {{
124
+ if (o.start[ni] + o.count[ni] > o.order.length) return "slice out of bounds";
125
+ for (let c = 0; c < 8; c++) {{
126
+ const ch = o.children[ni * 8 + c];
127
+ if (ch >= 0 && o.start[ch] < o.start[ni] + o.count[ni])
128
+ return "child overlaps parent sample";
129
+ }}
130
+ }}
131
+ return "ok";
132
+ }})()"""
133
+ )
134
+ assert ok == "ok"
135
+
136
+
137
+ def test_motion_draws_octree_cut_then_idle_refines_full(page):
138
+ # A held fly key keeps the view "in motion" for as long as we need to
139
+ # observe it (a drag's 150 ms settle window is shorter than one headless
140
+ # software-GL frame, so the refine would race the assertion).
141
+ page.click("canvas", position={"x": 10, "y": 10})
142
+ page.keyboard.down("w")
143
+ time.sleep(0.4)
144
+ during = page.evaluate(
145
+ """(() => {
146
+ const v = window.__toaster.viewer;
147
+ return { cut: v.geom.index === v._drawAttr, n: v.geom.drawRange.count };
148
+ })()"""
149
+ )
150
+ page.keyboard.up("w")
151
+ assert during["cut"], "motion frames must render the octree cut"
152
+ assert 0 < during["n"] < N_POINTS, "the cut must be a strict subset"
153
+
154
+ time.sleep(1.5) # settle + slow software-GL frames
155
+ after = page.evaluate(
156
+ "(() => { const v = window.__toaster.viewer;"
157
+ " return v.geom.index === null && !v._lodOn; })()"
158
+ )
159
+ assert after, "idle must refine back to the full cloud"
160
+
161
+ # The rAF loop must park at idle (the QtWebEngine leak guard).
162
+ for _ in range(4):
163
+ assert page.evaluate("window.__toaster.viewer._rafPending") is not True
164
+ time.sleep(0.4)
165
+
166
+
167
+ def test_octree_picking_matches_brute_force(page):
168
+ out = page.evaluate(
169
+ """(() => {
170
+ const v = window.__toaster.viewer;
171
+ const pick1 = v.pick(640, 400);
172
+ const box1 = v.pickBox(610, 370, 670, 430);
173
+ const oct = v._octree;
174
+ v._octree = null; // brute-force path
175
+ const pick2 = v.pick(640, 400);
176
+ const box2 = v.pickBox(610, 370, 670, 430);
177
+ v._octree = oct;
178
+ box1.sort((a, b) => a - b);
179
+ box2.sort((a, b) => a - b);
180
+ return {
181
+ pickSame: pick1 === pick2,
182
+ picked: pick1,
183
+ boxSame: box1.length === box2.length && box1.every((x, i) => x === box2[i]),
184
+ boxN: box1.length,
185
+ };
186
+ })()"""
187
+ )
188
+ assert out["pickSame"], "octree pick must match the exact scan"
189
+ assert out["picked"] >= 0
190
+ assert out["boxSame"], "octree pickBox must match the exact scan"
191
+ assert out["boxN"] > 0
192
+
193
+
194
+ def test_delta_recolor_matches_full_rerender(page):
195
+ # Box-select a screen region, label it, and compare the delta-patched
196
+ # colour/alpha buffers against a from-scratch full-state re-render —
197
+ # byte-identical or the delta path has drifted from the reference rules.
198
+ # Drive the real UI: box mode, rubber-band drag, double-click to label.
199
+ page.click("button[data-mode-pick=box]")
200
+ page.mouse.move(560, 320)
201
+ page.mouse.down()
202
+ page.mouse.move(720, 480, steps=5)
203
+ page.mouse.up()
204
+ page.wait_for_function(
205
+ "window.__toaster.debug.getState().selection.length > 0", timeout=15000
206
+ )
207
+ page.mouse.dblclick(640, 440) # viewport coords, inside the drawn box — delta path
208
+ page.wait_for_function(
209
+ "window.__toaster.debug.getState().selection.length === 0", timeout=15000
210
+ )
211
+
212
+ def buffers_equal_after_full_refresh() -> dict:
213
+ return page.evaluate(
214
+ """(async () => {
215
+ const { viewer, debug } = window.__toaster;
216
+ const live = viewer.colorArrays();
217
+ const colors = live.colors.slice();
218
+ const alpha = live.alpha.slice();
219
+ await debug.refresh(); // from-scratch reference render
220
+ const ref = viewer.colorArrays();
221
+ let colorDiff = 0, alphaDiff = 0;
222
+ for (let i = 0; i < colors.length; i++) if (colors[i] !== ref.colors[i]) colorDiff++;
223
+ for (let i = 0; i < alpha.length; i++) if (alpha[i] !== ref.alpha[i]) alphaDiff++;
224
+ const labeled = debug.getState().labels.reduce((a, v) => a + (v !== 0 ? 1 : 0), 0);
225
+ return { colorDiff, alphaDiff, labeled };
226
+ })()"""
227
+ )
228
+
229
+ out = buffers_equal_after_full_refresh()
230
+ assert out["labeled"] > 0, "the double-click must have labelled the box selection"
231
+ assert out["colorDiff"] == 0, f"{out['colorDiff']} colour slots diverged from the reference"
232
+ assert out["alphaDiff"] == 0, f"{out['alphaDiff']} alpha slots diverged from the reference"
233
+
234
+ # Same equivalence with the visibility mask active (hide-labelled points):
235
+ # the delta must now patch alphas too, and keep the hidden-count pill exact.
236
+ page.check("#hide-labeled")
237
+ page.mouse.move(400, 250)
238
+ page.mouse.down()
239
+ page.mouse.move(560, 400, steps=5)
240
+ page.mouse.up()
241
+ page.wait_for_function(
242
+ "window.__toaster.debug.getState().selection.length > 0", timeout=15000
243
+ )
244
+ page.mouse.dblclick(480, 330) # viewport coords, inside the second box
245
+ page.wait_for_function(
246
+ "window.__toaster.debug.getState().selection.length === 0", timeout=15000
247
+ )
248
+ out = buffers_equal_after_full_refresh()
249
+ assert out["colorDiff"] == 0, "colours diverged with the visibility mask on"
250
+ assert out["alphaDiff"] == 0, "alphas diverged with the visibility mask on"
251
+
252
+ # Undo (a delta of the same edit, reversed) must also match the reference.
253
+ page.keyboard.press("Control+z")
254
+ time.sleep(0.5)
255
+ out = buffers_equal_after_full_refresh()
256
+ assert out["colorDiff"] == 0, "colours diverged after an undo delta"
257
+ assert out["alphaDiff"] == 0, "alphas diverged after an undo delta"
258
+ page.uncheck("#hide-labeled")
toaster/api/service.py CHANGED
@@ -165,17 +165,33 @@ class AnnotationService:
165
  features = {k: encode_array(v) for k, v in ctl.session.cloud.features.items()}
166
  return {"xyz": encode_array(ctl.cloud_xyz()), "features": features}
167
 
168
- def state(self) -> dict[str, Any]:
169
- """Everything the client needs to (re)colour: snapshot + bulk arrays."""
 
 
 
 
 
 
170
  ctl = self._ctl()
171
- grouping = ctl.grouping_array()
172
- return {
173
  "snapshot": asdict(ctl.snapshot()),
174
- "labels": encode_array(ctl.label_array()),
175
- "grouping": encode_array(grouping) if grouping is not None else None,
176
  # int32 so it maps to a JS Int32Array (int64 has no plain TypedArray).
177
  "selection": encode_array(ctl.selection_indices().astype(np.int32)),
178
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
  # -- commands ---------------------------------------------------------
181
 
@@ -188,8 +204,7 @@ class AnnotationService:
188
  return self.state()
189
 
190
  def assign(self, class_id: int | None = None) -> dict[str, Any]:
191
- self._ctl().assign(class_id)
192
- return self.state()
193
 
194
  def set_active_class(self, class_id: int) -> dict[str, Any]:
195
  self._ctl().set_active_class(class_id)
@@ -200,12 +215,10 @@ class AnnotationService:
200
  return self.state()
201
 
202
  def undo(self) -> dict[str, Any]:
203
- self._ctl().undo()
204
- return self.state()
205
 
206
  def redo(self) -> dict[str, Any]:
207
- self._ctl().redo()
208
- return self.state()
209
 
210
  def clear_selection(self) -> dict[str, Any]:
211
  self._ctl().clear_selection()
@@ -242,16 +255,13 @@ class AnnotationService:
242
  return self.state()
243
 
244
  def assign_group(self, group_id: int, class_id: int | None = None) -> dict[str, Any]:
245
- self._ctl().assign_group(group_id, class_id)
246
- return self.state()
247
 
248
  def apply_suggested(self, group_id: int | None = None) -> dict[str, Any]:
249
- self._ctl().apply_suggested(group_id)
250
- return self.state()
251
 
252
  def assign_visible_groups(self, class_id: int | None = None) -> dict[str, Any]:
253
- self._ctl().assign_visible_groups(class_id)
254
- return self.state()
255
 
256
  def set_group_visibility(self, group_id: int, visible: bool) -> dict[str, Any]:
257
  self._ctl().set_group_visibility(group_id, visible)
 
165
  features = {k: encode_array(v) for k, v in ctl.session.cloud.features.items()}
166
  return {"xyz": encode_array(ctl.cloud_xyz()), "features": features}
167
 
168
+ def state(self, touched: np.ndarray | None = None) -> dict[str, Any]:
169
+ """Everything the client needs to (re)colour: snapshot + bulk arrays.
170
+
171
+ With ``touched`` (the indices a label edit wrote), the full label and
172
+ grouping arrays are replaced by ``labels_delta`` — just those indices
173
+ and their new values — so labelling stays O(edit), not O(cloud), on
174
+ the wire and in the client's recolour.
175
+ """
176
  ctl = self._ctl()
177
+ base = {
 
178
  "snapshot": asdict(ctl.snapshot()),
 
 
179
  # int32 so it maps to a JS Int32Array (int64 has no plain TypedArray).
180
  "selection": encode_array(ctl.selection_indices().astype(np.int32)),
181
  }
182
+ if touched is not None:
183
+ labels = ctl.label_array()
184
+ return base | {
185
+ "labels_delta": {
186
+ "indices": encode_array(touched.astype(np.int32, copy=False)),
187
+ "values": encode_array(labels[touched].astype(np.int32, copy=False)),
188
+ },
189
+ }
190
+ grouping = ctl.grouping_array()
191
+ return base | {
192
+ "labels": encode_array(ctl.label_array()),
193
+ "grouping": encode_array(grouping) if grouping is not None else None,
194
+ }
195
 
196
  # -- commands ---------------------------------------------------------
197
 
 
204
  return self.state()
205
 
206
  def assign(self, class_id: int | None = None) -> dict[str, Any]:
207
+ return self.state(touched=self._ctl().assign(class_id))
 
208
 
209
  def set_active_class(self, class_id: int) -> dict[str, Any]:
210
  self._ctl().set_active_class(class_id)
 
215
  return self.state()
216
 
217
  def undo(self) -> dict[str, Any]:
218
+ return self.state(touched=self._ctl().undo())
 
219
 
220
  def redo(self) -> dict[str, Any]:
221
+ return self.state(touched=self._ctl().redo())
 
222
 
223
  def clear_selection(self) -> dict[str, Any]:
224
  self._ctl().clear_selection()
 
255
  return self.state()
256
 
257
  def assign_group(self, group_id: int, class_id: int | None = None) -> dict[str, Any]:
258
+ return self.state(touched=self._ctl().assign_group(group_id, class_id))
 
259
 
260
  def apply_suggested(self, group_id: int | None = None) -> dict[str, Any]:
261
+ return self.state(touched=self._ctl().apply_suggested(group_id))
 
262
 
263
  def assign_visible_groups(self, class_id: int | None = None) -> dict[str, Any]:
264
+ return self.state(touched=self._ctl().assign_visible_groups(class_id))
 
265
 
266
  def set_group_visibility(self, group_id: int, visible: bool) -> dict[str, Any]:
267
  self._ctl().set_group_visibility(group_id, visible)
toaster/interaction/controller.py CHANGED
@@ -128,25 +128,34 @@ class InteractionController:
128
  self.session.active_class = class_id
129
  self._changed()
130
 
131
- def assign(self, class_id: int | None = None) -> None:
132
- """Write the active (or given) class to the current selection."""
 
 
 
 
133
  cid = self.session.active_class if class_id is None else class_id
134
  touched = self.session.annotation.assign(self.session.selection, cid)
135
  if touched.size:
136
  self._recolor_labels(touched)
137
  self.clear_selection()
 
138
 
139
- def undo(self) -> None:
 
140
  touched = self.session.annotation.undo()
141
  if touched is not None:
142
  self._recolor_labels(touched)
143
  self._changed()
 
144
 
145
- def redo(self) -> None:
 
146
  touched = self.session.annotation.redo()
147
  if touched is not None:
148
  self._recolor_labels(touched)
149
  self._changed()
 
150
 
151
  def _recolor_labels(self, indices: np.ndarray) -> None:
152
  # Only meaningful while showing labels; other modes recolour on switch.
@@ -190,27 +199,27 @@ class InteractionController:
190
  return
191
  self._apply_selection(Selection.from_group(grouping, group_id), modifiers)
192
 
193
- def assign_group(self, group_id: int, class_id: int | None = None) -> int:
194
- """Label a whole segment with the active (or given) class. Returns points labelled."""
195
  grouping = self.session.active_grouping
196
  if grouping is None:
197
- return 0
198
  cid = self.session.active_class if class_id is None else class_id
199
  touched = self.session.annotation.assign(Selection.from_group(grouping, group_id), cid)
200
  if touched.size:
201
  self._paint_labels(touched)
202
  self._changed()
203
- return int(touched.size)
204
 
205
- def assign_visible_groups(self, class_id: int | None = None) -> int:
206
  """Label every currently-visible (checked) segment with the active/given class.
207
 
208
  Hidden (unchecked, greyed) segments are skipped. The whole batch is one
209
- undoable edit. Returns the number of points labelled.
210
  """
211
  grouping = self.session.active_grouping
212
  if grouping is None:
213
- return 0
214
  cid = self.session.active_class if class_id is None else class_id
215
  sel = Selection.empty(self.session.cloud.n)
216
  for g in grouping.group_ids():
@@ -220,30 +229,30 @@ class InteractionController:
220
  if touched.size:
221
  self._paint_labels(touched)
222
  self._changed()
223
- return int(touched.size)
224
 
225
- def apply_suggested(self, group_id: int | None = None) -> int:
226
  """Accept model predictions: label group(s) with their ``suggested_labels``.
227
 
228
  With ``group_id`` set, only that segment; otherwise every segment that
229
- carries a suggestion. Returns the number of points labelled.
230
  """
231
  grouping = self.session.active_grouping
232
  if grouping is None or not grouping.suggested_labels:
233
- return 0
234
  if group_id is not None:
235
  suggestion = grouping.suggested_labels.get(group_id)
236
  items = [(group_id, suggestion)] if suggestion is not None else []
237
  else:
238
  items = list(grouping.suggested_labels.items())
239
- total = 0
240
  for gid, cid in items:
241
  touched = self.session.annotation.assign(Selection.from_group(grouping, gid), cid)
242
  if touched.size:
243
  self._paint_labels(touched)
244
- total += int(touched.size)
245
  self._changed()
246
- return total
247
 
248
  def _paint_labels(self, indices: np.ndarray) -> None:
249
  # Recolour assigned points to their class colour in *any* view, so a
 
128
  self.session.active_class = class_id
129
  self._changed()
130
 
131
+ def assign(self, class_id: int | None = None) -> np.ndarray:
132
+ """Write the active (or given) class to the current selection.
133
+
134
+ Returns the touched indices, so a remote front-end can patch labels and
135
+ colours in place instead of re-reading the full arrays.
136
+ """
137
  cid = self.session.active_class if class_id is None else class_id
138
  touched = self.session.annotation.assign(self.session.selection, cid)
139
  if touched.size:
140
  self._recolor_labels(touched)
141
  self.clear_selection()
142
+ return touched
143
 
144
+ def undo(self) -> np.ndarray:
145
+ """Revert the last edit; returns the touched indices (empty if nothing)."""
146
  touched = self.session.annotation.undo()
147
  if touched is not None:
148
  self._recolor_labels(touched)
149
  self._changed()
150
+ return touched if touched is not None else np.empty(0, dtype=np.int64)
151
 
152
+ def redo(self) -> np.ndarray:
153
+ """Re-apply the last undone edit; returns the touched indices (empty if nothing)."""
154
  touched = self.session.annotation.redo()
155
  if touched is not None:
156
  self._recolor_labels(touched)
157
  self._changed()
158
+ return touched if touched is not None else np.empty(0, dtype=np.int64)
159
 
160
  def _recolor_labels(self, indices: np.ndarray) -> None:
161
  # Only meaningful while showing labels; other modes recolour on switch.
 
199
  return
200
  self._apply_selection(Selection.from_group(grouping, group_id), modifiers)
201
 
202
+ def assign_group(self, group_id: int, class_id: int | None = None) -> np.ndarray:
203
+ """Label a whole segment with the active (or given) class. Returns touched indices."""
204
  grouping = self.session.active_grouping
205
  if grouping is None:
206
+ return np.empty(0, dtype=np.int64)
207
  cid = self.session.active_class if class_id is None else class_id
208
  touched = self.session.annotation.assign(Selection.from_group(grouping, group_id), cid)
209
  if touched.size:
210
  self._paint_labels(touched)
211
  self._changed()
212
+ return touched
213
 
214
+ def assign_visible_groups(self, class_id: int | None = None) -> np.ndarray:
215
  """Label every currently-visible (checked) segment with the active/given class.
216
 
217
  Hidden (unchecked, greyed) segments are skipped. The whole batch is one
218
+ undoable edit. Returns the touched indices.
219
  """
220
  grouping = self.session.active_grouping
221
  if grouping is None:
222
+ return np.empty(0, dtype=np.int64)
223
  cid = self.session.active_class if class_id is None else class_id
224
  sel = Selection.empty(self.session.cloud.n)
225
  for g in grouping.group_ids():
 
229
  if touched.size:
230
  self._paint_labels(touched)
231
  self._changed()
232
+ return touched
233
 
234
+ def apply_suggested(self, group_id: int | None = None) -> np.ndarray:
235
  """Accept model predictions: label group(s) with their ``suggested_labels``.
236
 
237
  With ``group_id`` set, only that segment; otherwise every segment that
238
+ carries a suggestion. Returns the touched indices across all of them.
239
  """
240
  grouping = self.session.active_grouping
241
  if grouping is None or not grouping.suggested_labels:
242
+ return np.empty(0, dtype=np.int64)
243
  if group_id is not None:
244
  suggestion = grouping.suggested_labels.get(group_id)
245
  items = [(group_id, suggestion)] if suggestion is not None else []
246
  else:
247
  items = list(grouping.suggested_labels.items())
248
+ all_touched: list[np.ndarray] = []
249
  for gid, cid in items:
250
  touched = self.session.annotation.assign(Selection.from_group(grouping, gid), cid)
251
  if touched.size:
252
  self._paint_labels(touched)
253
+ all_touched.append(touched)
254
  self._changed()
255
+ return np.concatenate(all_touched) if all_touched else np.empty(0, dtype=np.int64)
256
 
257
  def _paint_labels(self, indices: np.ndarray) -> None:
258
  # Recolour assigned points to their class colour in *any* view, so a
toaster/web/js/app.js CHANGED
@@ -2,12 +2,24 @@
2
  // wire interactions to the API, and re-render from each returned state.
3
 
4
  import { api, decodeArray } from "./api.js";
5
- import { computeColors } from "./colors.js";
6
  import { Viewer } from "./viewer.js";
7
 
8
  const el = (id) => document.getElementById(id);
9
  const viewer = new Viewer(el("viewport"));
10
- window.__toaster = { viewer }; // debug / headless-test handle (module scope is unreachable from outside)
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  let cloud = { xyz: null, features: {} };
13
  let state = null; // decoded { snapshot, labels, grouping, selection }
@@ -29,7 +41,7 @@ let voxel = { size: 0.5, showEmpty: false, map: null, centers: new Float32Array(
29
  const VOXEL_GRID_CAP = 30000; // max cubes to draw / cells to enumerate
30
  // The one point-visibility mask (class eyes ∧ hide-labelled ∧ isolate) — purely
31
  // client-side, applied on top of the colours the semantic state produces.
32
- const vis = { hideLabeled: false, hiddenClasses: new Set(), isolate: false };
33
 
34
  // -- themes ------------------------------------------------------------------
35
 
@@ -563,6 +575,10 @@ async function doSave() {
563
  }
564
 
565
  function applyState(raw) {
 
 
 
 
566
  state = {
567
  snapshot: raw.snapshot,
568
  labels: decodeArray(raw.labels),
@@ -571,6 +587,29 @@ function applyState(raw) {
571
  };
572
  if (state.selection.length === 0) vis.isolate = false; // isolate is armed per-selection
573
  refreshColors();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
  viewer.setHighlight(state.selection, cloud.xyz);
575
  renderClasses();
576
  renderModes();
@@ -581,6 +620,44 @@ function applyState(raw) {
581
  `Sel: ${state.selection.length.toLocaleString()} · Class: ${className(s.active_class)} · View: ${s.display_mode}`;
582
  }
583
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  // -- point visibility ---------------------------------------------------------
585
 
586
  // Recompute colours from the semantic state, apply the visibility mask on top,
@@ -617,6 +694,7 @@ function applyVisibility(alpha) {
617
  }
618
  }
619
  }
 
620
  renderVisPill(hidden);
621
  }
622
 
 
2
  // wire interactions to the API, and re-render from each returned state.
3
 
4
  import { api, decodeArray } from "./api.js";
5
+ import { computeColors, makeLabelColorizer } from "./colors.js";
6
  import { Viewer } from "./viewer.js";
7
 
8
  const el = (id) => document.getElementById(id);
9
  const viewer = new Viewer(el("viewport"));
10
+ // Debug / headless-test handle (module scope is unreachable from outside).
11
+ // `refresh` re-pulls the full state and re-renders from scratch — the tests
12
+ // use it as the reference the delta path must be indistinguishable from.
13
+ window.__toaster = {
14
+ viewer,
15
+ debug: {
16
+ getState: () => state,
17
+ getCloud: () => cloud,
18
+ computeColors,
19
+ api,
20
+ refresh: async () => applyState(await api.state()),
21
+ },
22
+ };
23
 
24
  let cloud = { xyz: null, features: {} };
25
  let state = null; // decoded { snapshot, labels, grouping, selection }
 
41
  const VOXEL_GRID_CAP = 30000; // max cubes to draw / cells to enumerate
42
  // The one point-visibility mask (class eyes ∧ hide-labelled ∧ isolate) — purely
43
  // client-side, applied on top of the colours the semantic state produces.
44
+ const vis = { hideLabeled: false, hiddenClasses: new Set(), isolate: false, hiddenCount: 0 };
45
 
46
  // -- themes ------------------------------------------------------------------
47
 
 
575
  }
576
 
577
  function applyState(raw) {
578
+ // Label edits (assign / undo / redo / group assigns) come back as a delta —
579
+ // just the touched indices and values — so applying them stays O(edit)
580
+ // whatever the cloud size. Everything else carries the full arrays.
581
+ if (raw.labels_delta && state && cloud.xyz) return applyDelta(raw);
582
  state = {
583
  snapshot: raw.snapshot,
584
  labels: decodeArray(raw.labels),
 
587
  };
588
  if (state.selection.length === 0) vis.isolate = false; // isolate is armed per-selection
589
  refreshColors();
590
+ finishStateRender();
591
+ }
592
+
593
+ function applyDelta(raw) {
594
+ const indices = decodeArray(raw.labels_delta.indices);
595
+ const values = decodeArray(raw.labels_delta.values);
596
+ const oldSelection = state.selection;
597
+ const wasIsolate = vis.isolate && oldSelection.length > 0;
598
+ state.snapshot = raw.snapshot;
599
+ state.selection = decodeArray(raw.selection);
600
+ for (let k = 0; k < indices.length; k++) state.labels[indices[k]] = values[k];
601
+ if (state.selection.length === 0) vis.isolate = false;
602
+
603
+ // Isolate couples every point's alpha to the selection as a whole — a
604
+ // selection change under isolate is a wholesale visibility change, not a
605
+ // delta. Rare enough to just recompute fully.
606
+ if (wasIsolate || (vis.isolate && state.selection.length > 0)) refreshColors();
607
+ else patchColors(indices, oldSelection);
608
+ finishStateRender();
609
+ }
610
+
611
+ // The panel/status re-render both state paths share.
612
+ function finishStateRender() {
613
  viewer.setHighlight(state.selection, cloud.xyz);
614
  renderClasses();
615
  renderModes();
 
620
  `Sel: ${state.selection.length.toLocaleString()} · Class: ${className(s.active_class)} · View: ${s.display_mode}`;
621
  }
622
 
623
+ // Delta recolour: rewrite only the touched points' colours in the live GPU
624
+ // arrays, re-evaluate visibility for the points it can have changed on (the
625
+ // touched points, plus both selections — selected points are force-visible,
626
+ // so selection churn shows/hides them), and upload just those ranges.
627
+ function patchColors(touched, oldSelection) {
628
+ const { colors, alpha } = viewer.colorArrays();
629
+ const colorize = makeLabelColorizer(state, cloud);
630
+ if (colorize) for (let k = 0; k < touched.length; k++) colorize(colors, touched[k]);
631
+
632
+ if (vis.hideLabeled || vis.hiddenClasses.size > 0) {
633
+ const labels = state.labels;
634
+ const unlabeled = state.snapshot.unlabeled_id;
635
+ const selected = new Set(state.selection);
636
+ // Idempotent per point, so overlap between the three sets double-counts
637
+ // nothing: the second visit sees before === after.
638
+ const reapply = (i) => {
639
+ const before = alpha[i];
640
+ const masked =
641
+ (vis.hideLabeled && labels[i] !== unlabeled) || vis.hiddenClasses.has(labels[i]);
642
+ const after = masked && !selected.has(i) ? 0 : 1;
643
+ alpha[i] = after;
644
+ vis.hiddenCount += (after === 0 ? 1 : 0) - (before === 0 ? 1 : 0);
645
+ };
646
+ for (let k = 0; k < touched.length; k++) reapply(touched[k]);
647
+ for (let k = 0; k < oldSelection.length; k++) reapply(oldSelection[k]);
648
+ for (let k = 0; k < state.selection.length; k++) reapply(state.selection[k]);
649
+ renderVisPill(vis.hiddenCount);
650
+ }
651
+
652
+ // Selection points' alpha may have changed even when nothing is "touched"
653
+ // colour-wise — commit the union.
654
+ const union = new Uint32Array(touched.length + oldSelection.length + state.selection.length);
655
+ union.set(touched, 0);
656
+ union.set(oldSelection, touched.length);
657
+ union.set(state.selection, touched.length + oldSelection.length);
658
+ viewer.commitColors(union);
659
+ }
660
+
661
  // -- point visibility ---------------------------------------------------------
662
 
663
  // Recompute colours from the semantic state, apply the visibility mask on top,
 
694
  }
695
  }
696
  }
697
+ vis.hiddenCount = hidden; // the delta path adjusts this incrementally
698
  renderVisPill(hidden);
699
  }
700
 
toaster/web/js/colors.js CHANGED
@@ -37,15 +37,16 @@ function turbo(t) {
37
  }
38
 
39
  // decoded: { snapshot, labels:Int32Array, grouping:Int32Array|null }
40
- // cloud: { xyz:Float32Array, features:{ intensity?:Float32Array } }
41
- export function computeColors(decoded, cloud) {
 
 
 
 
 
42
  const snap = decoded.snapshot;
43
  const labels = decoded.labels;
44
  const grouping = decoded.grouping;
45
- const n = labels.length;
46
- const colors = new Float32Array(n * 3);
47
- const alpha = new Float32Array(n).fill(1); // every point stays visible & pickable
48
-
49
  const mode = snap.display_mode;
50
  if (mode === "grouping" && grouping) {
51
  // Segments toggled off (Hide all / Solo / a row's checkbox) are not removed —
@@ -56,22 +57,36 @@ export function computeColors(decoded, cloud) {
56
  const lut = {};
57
  for (const c of snap.classes) lut[c.id] = c.color;
58
  const unlabeled = snap.unlabeled_id;
59
- for (let i = 0; i < n; i++) {
60
  const g = grouping[i];
61
  if (!hidden.has(g)) setRGB(colors, i, g < 0 ? NOISE : GROUP_PALETTE[g % GROUP_PALETTE.length]);
62
  else if (labels[i] !== unlabeled) setRGB(colors, i, lut[labels[i]] || DIM);
63
  else setRGB(colors, i, DIM);
64
- }
65
- } else if (mode === "intensity" && cloud.features.intensity) {
66
- rampInto(colors, cloud.features.intensity);
67
- } else if (mode === "height") {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  const z = new Float32Array(n);
69
  for (let i = 0; i < n; i++) z[i] = cloud.xyz[i * 3 + 2];
70
  rampInto(colors, z);
71
  } else {
72
- const lut = {};
73
- for (const c of snap.classes) lut[c.id] = c.color;
74
- for (let i = 0; i < n; i++) setRGB(colors, i, lut[labels[i]] || UNKNOWN);
75
  }
76
  return { colors, alpha };
77
  }
 
37
  }
38
 
39
  // decoded: { snapshot, labels:Int32Array, grouping:Int32Array|null }
40
+ // Per-point colour writer for the label-dependent display modes, or null when
41
+ // the current mode's colours can't be changed by a label edit (intensity /
42
+ // height ramps). This is the single source of the per-point rules: the full
43
+ // recompute below and the delta recolour in app.js both call it, so they can
44
+ // never drift apart.
45
+ // decoded: { snapshot, labels:Int32Array, grouping:Int32Array|null }
46
+ export function makeLabelColorizer(decoded, cloud) {
47
  const snap = decoded.snapshot;
48
  const labels = decoded.labels;
49
  const grouping = decoded.grouping;
 
 
 
 
50
  const mode = snap.display_mode;
51
  if (mode === "grouping" && grouping) {
52
  // Segments toggled off (Hide all / Solo / a row's checkbox) are not removed —
 
57
  const lut = {};
58
  for (const c of snap.classes) lut[c.id] = c.color;
59
  const unlabeled = snap.unlabeled_id;
60
+ return (colors, i) => {
61
  const g = grouping[i];
62
  if (!hidden.has(g)) setRGB(colors, i, g < 0 ? NOISE : GROUP_PALETTE[g % GROUP_PALETTE.length]);
63
  else if (labels[i] !== unlabeled) setRGB(colors, i, lut[labels[i]] || DIM);
64
  else setRGB(colors, i, DIM);
65
+ };
66
+ }
67
+ // Same fall-through as the full recompute: "intensity" without the feature
68
+ // renders as labels, so it *does* depend on them.
69
+ if ((mode === "intensity" && cloud.features.intensity) || mode === "height") return null;
70
+ const lut = {};
71
+ for (const c of snap.classes) lut[c.id] = c.color;
72
+ return (colors, i) => setRGB(colors, i, lut[labels[i]] || UNKNOWN);
73
+ }
74
+
75
+ // cloud: { xyz:Float32Array, features:{ intensity?:Float32Array } }
76
+ export function computeColors(decoded, cloud) {
77
+ const n = decoded.labels.length;
78
+ const colors = new Float32Array(n * 3);
79
+ const alpha = new Float32Array(n).fill(1); // every point stays visible & pickable
80
+
81
+ const colorize = makeLabelColorizer(decoded, cloud);
82
+ if (colorize) {
83
+ for (let i = 0; i < n; i++) colorize(colors, i);
84
+ } else if (decoded.snapshot.display_mode === "height") {
85
  const z = new Float32Array(n);
86
  for (let i = 0; i < n; i++) z[i] = cloud.xyz[i * 3 + 2];
87
  rampInto(colors, z);
88
  } else {
89
+ rampInto(colors, cloud.features.intensity);
 
 
90
  }
91
  return { colors, alpha };
92
  }
toaster/web/js/viewer.js CHANGED
@@ -25,6 +25,27 @@ const OCTREE_MIN_POINTS = 200_000;
25
  // pixels — below it, the node's own sample is already ~1 point per pixel.
26
  const OCTREE_SPLIT_PX = 110;
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  const VERT = `
29
  attribute vec3 acolor;
30
  attribute float aalpha;
@@ -251,6 +272,36 @@ export class Viewer {
251
  this._requestRender();
252
  }
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  setHighlight(indices, xyz) {
255
  if (this.highlight) {
256
  this.scene.remove(this.highlight);
 
25
  // pixels — below it, the node's own sample is already ~1 point per pixel.
26
  const OCTREE_SPLIT_PX = 110;
27
 
28
+ // Sort a delta's indices and merge them into [start, count] runs, bridging
29
+ // gaps below 512 slots (uploading a few unchanged points is cheaper than
30
+ // another bufferSubData call). Returns null when the result is still too
31
+ // fragmented — the caller should full-upload instead.
32
+ function coalesceRuns(indices) {
33
+ if (indices.length === 0) return [];
34
+ const sorted = Uint32Array.from(indices).sort();
35
+ const runs = [];
36
+ let start = sorted[0], prev = sorted[0];
37
+ for (let k = 1; k < sorted.length; k++) {
38
+ const v = sorted[k];
39
+ if (v - prev > 512) {
40
+ runs.push([start, prev - start + 1]);
41
+ start = v;
42
+ }
43
+ prev = v;
44
+ }
45
+ runs.push([start, prev - start + 1]);
46
+ return runs.length > 64 ? null : runs;
47
+ }
48
+
49
  const VERT = `
50
  attribute vec3 acolor;
51
  attribute float aalpha;
 
272
  this._requestRender();
273
  }
274
 
275
+ // The live GPU-side arrays, for in-place patching by the delta recolour.
276
+ // Write into them, then call commitColors with the touched indices.
277
+ colorArrays() {
278
+ return {
279
+ colors: this.geom.getAttribute("acolor").array,
280
+ alpha: this.geom.getAttribute("aalpha").array,
281
+ };
282
+ }
283
+
284
+ // Upload only what a label delta touched: the indices are coalesced into a
285
+ // few contiguous ranges (lidar points arrive in scan order, so a labelled
286
+ // cluster is usually index-local too). Falls back to a full upload when the
287
+ // edit is too scattered for ranged updates to win.
288
+ commitColors(indices) {
289
+ const ca = this.geom.getAttribute("acolor");
290
+ const aa = this.geom.getAttribute("aalpha");
291
+ const runs = coalesceRuns(indices);
292
+ if (runs && ca.addUpdateRange) {
293
+ ca.clearUpdateRanges();
294
+ aa.clearUpdateRanges();
295
+ for (const [start, count] of runs) {
296
+ ca.addUpdateRange(start * 3, count * 3);
297
+ aa.addUpdateRange(start, count);
298
+ }
299
+ }
300
+ ca.needsUpdate = true;
301
+ aa.needsUpdate = true;
302
+ this._requestRender();
303
+ }
304
+
305
  setHighlight(indices, xyz) {
306
  if (this.highlight) {
307
  this.scene.remove(this.highlight);