scigraphs-engine 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. scigraphs_engine/__init__.py +50 -0
  2. scigraphs_engine/_camera_math.py +85 -0
  3. scigraphs_engine/adaptive.py +468 -0
  4. scigraphs_engine/api.py +1284 -0
  5. scigraphs_engine/backends/__init__.py +4 -0
  6. scigraphs_engine/backends/wgpu/__init__.py +31 -0
  7. scigraphs_engine/backends/wgpu/buffers.py +232 -0
  8. scigraphs_engine/backends/wgpu/camera.py +148 -0
  9. scigraphs_engine/backends/wgpu/device.py +108 -0
  10. scigraphs_engine/backends/wgpu/pipelines.py +180 -0
  11. scigraphs_engine/backends/wgpu/render.py +250 -0
  12. scigraphs_engine/backends/wgpu/renderer.py +257 -0
  13. scigraphs_engine/backends/wgpu/shaders.py +381 -0
  14. scigraphs_engine/backends/wgpu/target.py +115 -0
  15. scigraphs_engine/backends/wgpu/wgsl/arrow.json +78 -0
  16. scigraphs_engine/backends/wgpu/wgsl/arrow.wgsl +76 -0
  17. scigraphs_engine/backends/wgpu/wgsl/arrow_flat.json +48 -0
  18. scigraphs_engine/backends/wgpu/wgsl/arrow_flat.wgsl +82 -0
  19. scigraphs_engine/backends/wgpu/wgsl/heb_line.json +81 -0
  20. scigraphs_engine/backends/wgpu/wgsl/heb_line.wgsl +137 -0
  21. scigraphs_engine/backends/wgpu/wgsl/line_f.json +108 -0
  22. scigraphs_engine/backends/wgpu/wgsl/line_f.wgsl +115 -0
  23. scigraphs_engine/backends/wgpu/wgsl/ribbon.json +93 -0
  24. scigraphs_engine/backends/wgpu/wgsl/ribbon.wgsl +131 -0
  25. scigraphs_engine/backends/wgpu/wgsl/ribbon_f.json +162 -0
  26. scigraphs_engine/backends/wgpu/wgsl/ribbon_f.wgsl +204 -0
  27. scigraphs_engine/backends/wgpu/wgsl/ribbon_id.json +54 -0
  28. scigraphs_engine/backends/wgpu/wgsl/ribbon_id.wgsl +103 -0
  29. scigraphs_engine/backends/wgpu/wgsl/round_point.json +22 -0
  30. scigraphs_engine/backends/wgpu/wgsl/round_point.wgsl +68 -0
  31. scigraphs_engine/backends/wgpu/wgsl/round_point_f.json +118 -0
  32. scigraphs_engine/backends/wgpu/wgsl/round_point_f.wgsl +141 -0
  33. scigraphs_engine/backends/wgpu/wgsl/smooth_color.json +32 -0
  34. scigraphs_engine/backends/wgpu/wgsl/smooth_color.wgsl +40 -0
  35. scigraphs_engine/backends/wgpu/wgsl/sphere.json +83 -0
  36. scigraphs_engine/backends/wgpu/wgsl/sphere.wgsl +115 -0
  37. scigraphs_engine/backends/wgpu/wgsl/sphere_f.json +152 -0
  38. scigraphs_engine/backends/wgpu/wgsl/sphere_f.wgsl +185 -0
  39. scigraphs_engine/backends/wgpu/wgsl/sphere_id.json +44 -0
  40. scigraphs_engine/backends/wgpu/wgsl/sphere_id.wgsl +80 -0
  41. scigraphs_engine/backends/wgpu/wgsl/uniform_color.json +37 -0
  42. scigraphs_engine/backends/wgpu/wgsl/uniform_color.wgsl +42 -0
  43. scigraphs_engine/blocks.py +76 -0
  44. scigraphs_engine/bundling/__init__.py +11 -0
  45. scigraphs_engine/bundling/fdeb.py +310 -0
  46. scigraphs_engine/bundling/mingle.py +480 -0
  47. scigraphs_engine/bundling/routed.py +420 -0
  48. scigraphs_engine/bundling/sbeb.py +292 -0
  49. scigraphs_engine/channels.py +91 -0
  50. scigraphs_engine/communities.py +125 -0
  51. scigraphs_engine/edge_styles.py +566 -0
  52. scigraphs_engine/filters.py +754 -0
  53. scigraphs_engine/gpu_compute.py +17 -0
  54. scigraphs_engine/lod.py +89 -0
  55. scigraphs_engine/mesh.py +480 -0
  56. scigraphs_engine/palette.py +68 -0
  57. scigraphs_engine/settings.py +151 -0
  58. scigraphs_engine/simplify.py +352 -0
  59. scigraphs_engine/source.py +143 -0
  60. scigraphs_engine-0.1.1.dist-info/METADATA +103 -0
  61. scigraphs_engine-0.1.1.dist-info/RECORD +64 -0
  62. scigraphs_engine-0.1.1.dist-info/WHEEL +5 -0
  63. scigraphs_engine-0.1.1.dist-info/licenses/LICENSE +21 -0
  64. scigraphs_engine-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,50 @@
1
+ # Nothing in this package may import bpy, gpu, bmesh or mathutils; tests in the
2
+ # SciGraphs repository parse this directory for those names. Everything below
3
+ # numpy is lazy, so a MeshSpec caller never pays for igraph and scipy.
4
+
5
+ import importlib as _importlib
6
+
7
+ __version__ = "0.1.1"
8
+
9
+ from .mesh import ( # noqa: F401
10
+ DrawState, MeshGroup, MeshSpec, ShaderRef, LINES, POINTS, TRIS,
11
+ )
12
+ from .settings import Clause, Settings, PREFIX # noqa: F401
13
+ from .source import ArraySource, GraphSource, EDGE, POINT # noqa: F401
14
+
15
+ _LAZY = (
16
+ "adaptive", "api", "blocks", "channels", "communities", "edge_styles",
17
+ "filters", "gpu_compute", "lod", "mesh", "palette", "settings", "simplify",
18
+ "source",
19
+ )
20
+
21
+ # Lets `from scigraphs_engine import Graph` resolve without importing api.
22
+ _API_NAMES = (
23
+ "BackendUnavailable", "Camera", "ChannelResult", "ChannelSpec",
24
+ "ChannelUnavailable", "Condition", "EngineError", "Geometry", "Graph",
25
+ "Image", "Report", "UnknownChannel", "channel", "channel_names",
26
+ "channel_specs", "colormap_names", "gpu_available",
27
+ )
28
+
29
+ __all__ = [
30
+ "ArraySource", "Clause", "DrawState", "EDGE", "GraphSource", "LINES",
31
+ "MeshGroup", "MeshSpec", "POINT", "POINTS", "PREFIX", "Settings",
32
+ "ShaderRef", "TRIS", "__version__",
33
+ ] + list(_API_NAMES) + list(_LAZY)
34
+
35
+
36
+ def __getattr__(name):
37
+ """Import a submodule or a public API name on first access (PEP 562)."""
38
+ if name in _LAZY:
39
+ module = _importlib.import_module("." + name, __name__)
40
+ globals()[name] = module
41
+ return module
42
+ if name in _API_NAMES:
43
+ value = getattr(_importlib.import_module(".api", __name__), name)
44
+ globals()[name] = value
45
+ return value
46
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
47
+
48
+
49
+ def __dir__():
50
+ return sorted(set(globals()) | set(_LAZY) | set(_API_NAMES))
@@ -0,0 +1,85 @@
1
+ # Transcribed from backends/wgpu/camera.py, held float32-exact against it by
2
+ # tests/test_camera.py. Depth is z in [0, 1] (WebGPU), not OpenGL's.
3
+
4
+ import numpy as np
5
+
6
+
7
+ def _normalize(v):
8
+ v = np.asarray(v, dtype=np.float64)
9
+ n = np.linalg.norm(v)
10
+ return v / n if n > 1e-12 else v
11
+
12
+
13
+ def look_at(eye, target, up=(0.0, 0.0, 1.0)):
14
+ """World -> view, right-handed, looking down -z. +Z up like Blender."""
15
+ eye = np.asarray(eye, dtype=np.float64)
16
+ f = _normalize(np.asarray(target, dtype=np.float64) - eye)
17
+ up = _normalize(up)
18
+ if abs(float(np.dot(f, up))) > 0.9999:
19
+ up = np.array([0.0, 1.0, 0.0]) if abs(f[1]) < 0.9 else \
20
+ np.array([1.0, 0.0, 0.0])
21
+ s = _normalize(np.cross(f, up))
22
+ u = np.cross(s, f)
23
+ m = np.eye(4, dtype=np.float64)
24
+ m[0, :3], m[1, :3], m[2, :3] = s, u, -f
25
+ m[0, 3] = -float(np.dot(s, eye))
26
+ m[1, 3] = -float(np.dot(u, eye))
27
+ m[2, 3] = float(np.dot(f, eye))
28
+ return m.astype(np.float32)
29
+
30
+
31
+ def perspective(fov_y_deg, aspect, near, far):
32
+ """View -> clip with z in [0, 1]."""
33
+ f = 1.0 / np.tan(np.radians(float(fov_y_deg)) * 0.5)
34
+ m = np.zeros((4, 4), dtype=np.float64)
35
+ m[0, 0] = f / float(aspect)
36
+ m[1, 1] = f
37
+ m[2, 2] = far / (near - far)
38
+ m[2, 3] = (far * near) / (near - far)
39
+ m[3, 2] = -1.0
40
+ return m.astype(np.float32)
41
+
42
+
43
+ def bounding_sphere(coords):
44
+ """(center, radius) of the box's circumsphere. Over-frames a flat layout."""
45
+ coords = np.asarray(coords, dtype=np.float32).reshape(-1, 3)
46
+ if coords.size == 0:
47
+ return np.zeros(3, dtype=np.float32), 1.0
48
+ lo = coords.min(axis=0)
49
+ hi = coords.max(axis=0)
50
+ center = (lo + hi) * 0.5
51
+ radius = float(np.linalg.norm(hi - lo)) * 0.5
52
+ return center, (radius if radius > 1e-6 else 1.0)
53
+
54
+
55
+ def fit_view(coords, size, fov_y_deg=45.0, margin=1.15,
56
+ direction=(0.0, -1.0, 0.35), up=(0.0, 0.0, 1.0)):
57
+ """A resolved api.Camera framing all of ``coords`` at ``size``."""
58
+ from .api import Camera
59
+
60
+ width, height = size
61
+ center, radius = bounding_sphere(coords)
62
+ aspect = float(width) / float(height)
63
+ # Fit the tighter axis: vertical-only fitting crops a portrait render.
64
+ half_v = np.radians(float(fov_y_deg)) * 0.5
65
+ half_h = np.arctan(np.tan(half_v) * aspect)
66
+ dist = radius * float(margin) / np.sin(min(half_v, half_h))
67
+
68
+ eye = center - _normalize(direction).astype(np.float32) * dist
69
+ near = max(dist - radius * 2.0, dist * 1e-3)
70
+ far = dist + radius * 4.0
71
+ return Camera(look_at(eye, center, up),
72
+ perspective(fov_y_deg, aspect, near, far), near, far)
73
+
74
+
75
+ def look_at_camera(eye, target, up, fov_y_deg, size, near=None, far=None):
76
+ from .api import Camera
77
+
78
+ width, height = size
79
+ eye = np.asarray(eye, dtype=np.float64)
80
+ span = float(np.linalg.norm(np.asarray(target, dtype=np.float64) - eye))
81
+ near = float(near) if near is not None else max(span * 1e-3, 1e-4)
82
+ far = float(far) if far is not None else max(span * 10.0, near * 100.0)
83
+ return Camera(look_at(eye, target, up),
84
+ perspective(fov_y_deg, float(width) / float(height),
85
+ near, far), near, far)
@@ -0,0 +1,468 @@
1
+ # Level of detail over simplify.build_hierarchy's community tree: one ancestor
2
+ # per node. Crowding opens a community, the caller's ratios merge it back.
3
+
4
+ import collections
5
+ import math
6
+
7
+ import numpy as np
8
+
9
+ Camera = collections.namedtuple(
10
+ "Camera", "persp focal_y half_w half_h forward eye")
11
+
12
+ CROWDING_REFRESH_COS = 0.9994 # ~2 deg; crowding varies only with direction
13
+
14
+ # Expand over this, merge under COLLAPSE_MAX_MEASURED. The gap damps thrash.
15
+ EXPAND_MIN_PREDICTED = 0.55
16
+
17
+ EXPAND_MIN_PX = 2.5
18
+
19
+ COLLAPSE_MAX_MEASURED = 0.25
20
+
21
+ COLLAPSE_HIDDEN_FRACTION = 0.5
22
+
23
+ DIRECTION_BINS = 192 # ~15 degree bins over the sphere
24
+
25
+ BLAME_SHARE_DEG = 0.0 # also file a merge under directions within this angle
26
+
27
+ EXPAND_HYSTERESIS = 0.0
28
+
29
+ MAX_REGROUP_FRACTION = 1.0
30
+
31
+ MAX_MERGE_LOSS = 0.3 # 0 draws every node, unbounded merges them all
32
+
33
+ SIGMA_K = 2.0
34
+
35
+
36
+ class CutState:
37
+ """Per-object persistent state for the adaptive cut."""
38
+
39
+ def __init__(self, levels):
40
+ self.sizes = [lvl["centers"].shape[0] for lvl in levels]
41
+ self.hidden = [{} for _ in levels]
42
+ self.futile = [{} for _ in levels]
43
+ self.tried = [{} for _ in levels]
44
+ self.expanded = [np.zeros(n, dtype=bool) for n in self.sizes]
45
+ self.mean_child_r = [None] * len(levels)
46
+ self.crowd = [None] * len(levels)
47
+ self.crowd_dir = np.zeros(3)
48
+ self.changes = 0
49
+ self.frames = 0
50
+
51
+ def _from(self, caches, level_index, bin_index, create=False):
52
+ mask = caches[level_index].get(bin_index)
53
+ if mask is None:
54
+ mask = np.zeros(self.sizes[level_index], dtype=bool)
55
+ if create:
56
+ caches[level_index][bin_index] = mask
57
+ return mask
58
+
59
+ def hidden_from(self, level_index, bin_index, create=False):
60
+ """Which entries of this level scored poorly from this direction."""
61
+ return self._from(self.hidden, level_index, bin_index, create)
62
+
63
+ def futile_from(self, level_index, bin_index, create=False):
64
+ """Where merging was tried from this direction and did not pay."""
65
+ return self._from(self.futile, level_index, bin_index, create)
66
+
67
+ def tried_from(self, level_index, bin_index, create=False):
68
+ """Whose merge has already been tried from this direction."""
69
+ return self._from(self.tried, level_index, bin_index, create)
70
+
71
+ def reset(self):
72
+ for caches in (self.hidden, self.futile, self.tried):
73
+ for cache in caches:
74
+ cache.clear()
75
+ for arr in self.expanded:
76
+ arr[:] = False
77
+ self.crowd = [None] * len(self.crowd)
78
+ self.changes = 0
79
+
80
+
81
+ def camera_terms(persp_mat, window_mat, height):
82
+ """Camera terms for the predictor. The focal term must come from
83
+ ``window_mat``; taken off ``persp_mat`` it picks up orientation."""
84
+ window = np.asarray(window_mat, dtype=np.float64)
85
+ persp = np.asarray(persp_mat, dtype=np.float64)
86
+ focal_y = float(window[1][1])
87
+ aspect = focal_y / float(window[0][0]) if window[0][0] else 1.0
88
+ # w = -z_view, so the bottom row is the viewing direction in source space.
89
+ forward = persp[3, :3]
90
+ forward = forward / (np.linalg.norm(forward) + 1e-12)
91
+ # Dividing the projection out of persp leaves the modelview.
92
+ modelview = np.linalg.solve(window, persp)
93
+ eye = -modelview[:3, :3].T @ modelview[:3, 3]
94
+ return Camera(persp, focal_y, 0.5 * height * aspect, 0.5 * height,
95
+ forward, eye)
96
+
97
+
98
+ def depth_of(points, camera):
99
+ """Perspective divide (distance along the view axis) for each point."""
100
+ return np.abs(points @ camera.persp[3, :3] + camera.persp[3, 3]) + 1e-9
101
+
102
+
103
+ def project_px(points, camera):
104
+ """Project points to pixel coordinates, plus pixels per unit at each depth."""
105
+ homog = np.empty((points.shape[0], 4), dtype=np.float64)
106
+ homog[:, :3] = points
107
+ homog[:, 3] = 1.0
108
+ clip = homog @ camera.persp.T
109
+ w = np.abs(clip[:, 3]) + 1e-9
110
+ px = np.empty((points.shape[0], 2), dtype=np.float64)
111
+ px[:, 0] = clip[:, 0] / w * camera.half_w
112
+ px[:, 1] = clip[:, 1] / w * camera.half_h
113
+ return px, camera.focal_y / w * camera.half_h
114
+
115
+
116
+ def children_of(levels, level_index, leaf):
117
+ """Centers, radii and parent map one step below ``level_index``. Below
118
+ level 0 that is the original nodes, taken from ``leaf``."""
119
+ if level_index > 0:
120
+ child = levels[level_index - 1]
121
+ return (child["centers"].astype(np.float64),
122
+ child["radii"].astype(np.float64),
123
+ child["parent"])
124
+ radii = np.asarray(leaf["radii"], dtype=np.float64)
125
+ coords = np.asarray(leaf["coords"], dtype=np.float64)
126
+ if radii.ndim == 0:
127
+ radii = np.full(coords.shape[0], float(radii))
128
+ return coords, radii, levels[0]["member_of"]
129
+
130
+
131
+ def crowding(levels, leaf, level_index, camera):
132
+ """Screen room the children have, in [0, 1]: footprint over the area their
133
+ centers cover. The ellipse is dilated or it collapses on two children."""
134
+ n_parent = levels[level_index]["centers"].shape[0]
135
+ centers, radii, parent = children_of(levels, level_index, leaf)
136
+
137
+ px, scale = project_px(centers, camera)
138
+ child_px = radii * scale
139
+
140
+ count = np.bincount(parent, minlength=n_parent).astype(np.float64)
141
+ safe = np.maximum(count, 1.0)
142
+ mean_x = np.bincount(parent, weights=px[:, 0], minlength=n_parent) / safe
143
+ mean_y = np.bincount(parent, weights=px[:, 1], minlength=n_parent) / safe
144
+ dx = px[:, 0] - mean_x[parent]
145
+ dy = px[:, 1] - mean_y[parent]
146
+ s_xx = np.bincount(parent, weights=dx * dx, minlength=n_parent) / safe
147
+ s_yy = np.bincount(parent, weights=dy * dy, minlength=n_parent) / safe
148
+ s_xy = np.bincount(parent, weights=dx * dy, minlength=n_parent) / safe
149
+
150
+ trace = s_xx + s_yy
151
+ gap = np.sqrt(np.maximum(trace * trace - 4.0 * (s_xx * s_yy - s_xy * s_xy),
152
+ 0.0))
153
+ mean_r = np.bincount(parent, weights=child_px, minlength=n_parent) / safe
154
+ semi_major = SIGMA_K * np.sqrt(np.maximum(0.5 * (trace + gap), 0.0)) + mean_r
155
+ semi_minor = SIGMA_K * np.sqrt(np.maximum(0.5 * (trace - gap), 0.0)) + mean_r
156
+ available = np.pi * semi_major * semi_minor
157
+
158
+ demand = np.bincount(parent, weights=np.pi * child_px * child_px,
159
+ minlength=n_parent)
160
+ ratio = demand / np.maximum(available, 1e-12)
161
+ return np.clip(1.0 / np.maximum(ratio, 1.0), 0.0, 1.0), count
162
+
163
+
164
+ def legibility(levels, leaf, level_index, camera, state=None):
165
+ """Mean on-screen radius the children of each community would have."""
166
+ level = levels[level_index]
167
+ mean_r = _mean_child_radius(levels, leaf, level_index, state)
168
+ return mean_r * camera.focal_y / depth_of(
169
+ level["centers"], camera) * camera.half_h
170
+
171
+
172
+ def _mean_child_radius(levels, leaf, level_index, state=None):
173
+ """Mean world radius of each community's children. Camera independent."""
174
+ if state is not None and state.mean_child_r[level_index] is not None:
175
+ return state.mean_child_r[level_index]
176
+ n_parent = levels[level_index]["centers"].shape[0]
177
+ _centers, radii, parent = children_of(levels, level_index, leaf)
178
+ count = np.maximum(np.bincount(parent, minlength=n_parent), 1)
179
+ mean_r = np.bincount(parent, weights=radii, minlength=n_parent) / count
180
+ if state is not None:
181
+ state.mean_child_r[level_index] = mean_r
182
+ return mean_r
183
+
184
+
185
+ def predict_expand(levels, leaf, level_index, camera, state=None,
186
+ min_predicted=EXPAND_MIN_PREDICTED, min_px=EXPAND_MIN_PX,
187
+ hysteresis=0.0):
188
+ """``(ok, predicted, child_px)``: whether communities here may be opened.
189
+ ``hysteresis`` keeps a drifting camera from flipping one open and shut."""
190
+ predicted, count = None, None
191
+ if state is not None:
192
+ cached = state.crowd[level_index]
193
+ if cached is not None and \
194
+ float(state.crowd_dir @ camera.forward) >= CROWDING_REFRESH_COS:
195
+ predicted, count = cached
196
+ if predicted is None:
197
+ predicted, count = crowding(levels, leaf, level_index, camera)
198
+ if state is not None:
199
+ state.crowd[level_index] = (predicted, count)
200
+
201
+ child_px = legibility(levels, leaf, level_index, camera, state)
202
+ open_now = (state.expanded[level_index] if state is not None
203
+ else np.zeros(count.shape, dtype=bool))
204
+ slack = np.where(open_now, 1.0 - hysteresis, 1.0 + hysteresis)
205
+ ok = ((count > 0) & (predicted >= min_predicted * slack)
206
+ & (child_px >= min_px * slack))
207
+ return ok, predicted, child_px
208
+
209
+
210
+ def _bin_directions(count=DIRECTION_BINS):
211
+ """Fibonacci spiral, not a lat-long grid: at the poles a lat-long grid bins
212
+ two identical directions apart on the sign of a zero."""
213
+ i = np.arange(count, dtype=np.float64) + 0.5
214
+ z = 1.0 - 2.0 * i / count
215
+ r = np.sqrt(np.maximum(0.0, 1.0 - z * z))
216
+ theta = math.pi * (1.0 + math.sqrt(5.0)) * i
217
+ return np.column_stack((r * np.cos(theta), r * np.sin(theta), z))
218
+
219
+
220
+ BIN_DIRECTIONS = _bin_directions()
221
+
222
+
223
+ def _bin_neighbors(degrees=BLAME_SHARE_DEG):
224
+ if degrees <= 0.0:
225
+ return [np.array([i]) for i in range(DIRECTION_BINS)]
226
+ close = BIN_DIRECTIONS @ BIN_DIRECTIONS.T >= math.cos(math.radians(degrees))
227
+ return [np.flatnonzero(row) for row in close]
228
+
229
+
230
+ BIN_NEIGHBORS = _bin_neighbors()
231
+
232
+
233
+ def direction_bin(view_dir):
234
+ d = np.asarray(view_dir, dtype=np.float64)
235
+ return int(np.argmax(BIN_DIRECTIONS @ (d / (np.linalg.norm(d) + 1e-12))))
236
+
237
+
238
+ def apply_measurement(levels, state, drawn, measured, view_dir,
239
+ max_measured=COLLAPSE_MAX_MEASURED,
240
+ min_hidden_fraction=COLLAPSE_HIDDEN_FRACTION,
241
+ max_merge_loss=MAX_MERGE_LOSS, share=True):
242
+ """Record a collapse wherever the caller's ratios came back below threshold.
243
+ ``drawn`` is what :func:`select_cut` returns; ``measured`` holds ratios in
244
+ [0, 1]. A parent is penalized when over ``min_hidden_fraction`` of its shown
245
+ children fall below ``max_measured``. Returns the entries changed for
246
+ ``view_dir``; callers loop until it is zero, so count every change."""
247
+ bin_index = direction_bin(view_dir)
248
+ bins = BIN_NEIGHBORS[bin_index] if share else (bin_index,)
249
+ added = 0
250
+ for child_level, (indices, ratios) in enumerate(zip(drawn, measured)):
251
+ if indices is None or len(indices) == 0:
252
+ continue
253
+ indices = np.asarray(indices, dtype=np.int64)
254
+ ratios = np.nan_to_num(np.asarray(ratios, dtype=np.float64), nan=1.0)
255
+
256
+ # A supernode that measured poorly is opened once, to see if it helps.
257
+ if child_level < len(levels):
258
+ poor = indices[ratios < max_measured]
259
+ for blame_bin in bins:
260
+ tried = state.tried_from(child_level, int(blame_bin),
261
+ create=True)
262
+ fresh = poor[~tried[poor]]
263
+ if not fresh.size:
264
+ continue
265
+ if blame_bin == bin_index:
266
+ added += int(fresh.size)
267
+ tried[fresh] = True
268
+ state.futile_from(child_level, int(blame_bin),
269
+ create=True)[fresh] = True
270
+
271
+ # Ratios for level L-1 penalize level L; the top has no parent.
272
+ if child_level == len(levels):
273
+ parent_level, parent_of = 0, levels[0]["member_of"]
274
+ elif child_level + 1 < len(levels):
275
+ parent_level, parent_of = child_level + 1, levels[child_level]["parent"]
276
+ else:
277
+ continue
278
+ parent = parent_of[indices]
279
+
280
+ n_parent = state.sizes[parent_level]
281
+ shown = np.bincount(parent, minlength=n_parent)
282
+ poorly = (ratios < max_measured).astype(np.float64)
283
+ hidden = np.bincount(parent, weights=poorly, minlength=n_parent)
284
+ blame = (shown > 0) & (hidden >= min_hidden_fraction * shown)
285
+
286
+ added += int((blame & ~state.hidden_from(
287
+ parent_level, bin_index)).sum())
288
+ for blame_bin in bins:
289
+ state.hidden_from(parent_level, int(blame_bin),
290
+ create=True)[:] |= blame
291
+
292
+ # Cost is counted in members, not drawn elements, and charged to every
293
+ # ancestor, because the cut can drop several levels at once.
294
+ weight = (np.ones(indices.size) if child_level == len(levels)
295
+ else levels[child_level]["counts"][indices].astype(np.float64))
296
+ ancestor, level_index = parent, parent_level
297
+ while True:
298
+ size = state.sizes[level_index]
299
+ members = np.bincount(ancestor, weights=weight, minlength=size)
300
+ lost = np.bincount(ancestor, weights=weight * poorly,
301
+ minlength=size)
302
+ seen = members > 0
303
+ cost = np.divide(lost, members, out=np.ones(size), where=seen)
304
+ for blame_bin in bins:
305
+ judged = seen & state.tried_from(level_index, int(blame_bin))
306
+ if not judged.any():
307
+ continue
308
+ verdict = (1.0 - cost[judged]) > max_merge_loss
309
+ settled = state.futile_from(level_index, int(blame_bin),
310
+ create=True)
311
+ if blame_bin == bin_index:
312
+ added += int((settled[judged] != verdict).sum())
313
+ settled[judged] = verdict
314
+ if level_index + 1 >= len(levels):
315
+ break
316
+ ancestor = levels[level_index]["parent"][ancestor]
317
+ level_index += 1
318
+ return added
319
+
320
+
321
+ def select_cut(levels, leaf, state, camera, max_regroup=MAX_REGROUP_FRACTION,
322
+ hysteresis=EXPAND_HYSTERESIS, **predict_kwargs):
323
+ """Which communities to draw, top level down. ``drawn[i]`` masks level ``i``
324
+ (finest first) plus a final mask over the original nodes; a community is
325
+ drawn when every ancestor was expanded and it was not."""
326
+ top = len(levels) - 1
327
+ want = [None] * len(levels)
328
+ reached = np.ones(levels[top]["centers"].shape[0], dtype=bool)
329
+ stale = float(state.crowd_dir @ camera.forward) < CROWDING_REFRESH_COS
330
+
331
+ bin_index = direction_bin(camera.forward)
332
+
333
+ for level_index in range(top, -1, -1):
334
+ may_open, _predicted, _px = predict_expand(
335
+ levels, leaf, level_index, camera, state,
336
+ hysteresis=hysteresis, **predict_kwargs)
337
+ # Recorded poor from here stays whole, unless the merge did not pay.
338
+ may_open &= ~(state.hidden_from(level_index, bin_index)
339
+ & ~state.futile_from(level_index, bin_index))
340
+ may_open &= reached
341
+ want[level_index] = may_open
342
+ if level_index > 0:
343
+ reached = may_open[levels[level_index - 1]["parent"]]
344
+ if stale:
345
+ state.crowd_dir = np.asarray(camera.forward, dtype=np.float64).copy()
346
+
347
+ # Budget in original nodes: one collapse near the root rewrites half the
348
+ # screen. Collapses go first, since they fix a misleading fragment.
349
+ reach = reach_masks(levels, state.expanded)
350
+ n_nodes = levels[0]["member_of"].shape[0]
351
+ budget = (None if max_regroup >= 1.0
352
+ else max(1, int(round(max_regroup * n_nodes))))
353
+ changes = 0
354
+ for collapsing in (True, False):
355
+ for level_index in range(top, -1, -1):
356
+ # Reachability follows the level above, recomputed in the descent.
357
+ if level_index < top:
358
+ reach[level_index] = (
359
+ state.expanded[level_index + 1] & reach[level_index + 1]
360
+ )[levels[level_index]["parent"]]
361
+ if budget is not None and budget <= 0:
362
+ continue
363
+ current = state.expanded[level_index]
364
+ # Flipping an entry buried under a collapsed ancestor is free.
365
+ differing = np.flatnonzero((current != want[level_index])
366
+ & reach[level_index]
367
+ & (current if collapsing else ~current))
368
+ if differing.size == 0:
369
+ continue
370
+ if budget is not None:
371
+ # Cheapest first; one change is always allowed, per frame.
372
+ cost = levels[level_index]["counts"][differing]
373
+ differing = differing[np.argsort(cost, kind='stable')]
374
+ affordable = int(np.searchsorted(
375
+ np.cumsum(np.sort(cost)), budget, 'right'))
376
+ differing = differing[:max(affordable,
377
+ 1 if changes == 0 else 0)]
378
+ if differing.size == 0:
379
+ continue
380
+ budget -= int(levels[level_index]["counts"][differing].sum())
381
+ current[differing] = want[level_index][differing]
382
+ changes += int(differing.size)
383
+
384
+ # Derive the cut from the applied flags: a rate-limited frame stays valid.
385
+ drawn = [r & ~(e & r) for r, e in zip(reach, state.expanded)]
386
+ expanded_leaf = state.expanded[0] & reach[0]
387
+ drawn.append(expanded_leaf[levels[0]["member_of"]])
388
+
389
+ state.changes = changes
390
+ state.frames += 1
391
+ return drawn, changes
392
+
393
+
394
+ def regrouped(before, after):
395
+ """Which nodes are drawn in another group; compares partitions, not labels."""
396
+ before = np.asarray(before, dtype=np.int64)
397
+ after = np.asarray(after, dtype=np.int64)
398
+ joint = before * (after.max() + 1) + after
399
+ return ((np.bincount(joint)[joint] != np.bincount(before)[before])
400
+ | (np.bincount(joint)[joint] != np.bincount(after)[after]))
401
+
402
+
403
+ def reach_masks(levels, expanded):
404
+ """Which entries of each level are on screen: every ancestor was expanded."""
405
+ top = len(levels) - 1
406
+ reach = [None] * len(levels)
407
+ mask = np.ones(levels[top]["centers"].shape[0], dtype=bool)
408
+ for level_index in range(top, -1, -1):
409
+ reach[level_index] = mask
410
+ if level_index > 0:
411
+ mask = (expanded[level_index] & mask)[
412
+ levels[level_index - 1]["parent"]]
413
+ return reach
414
+
415
+
416
+ def cut_labels(levels, drawn, n_nodes):
417
+ """One label per node, coarsest first: what ``build_coarse_level`` consumes."""
418
+ labels = np.full(n_nodes, -1, dtype=np.int64)
419
+ offset = 0
420
+ for level_index in range(len(levels) - 1, -1, -1):
421
+ mask = drawn[level_index]
422
+ taken = int(mask.sum())
423
+ if taken == 0:
424
+ continue
425
+ gid = np.full(mask.size, -1, dtype=np.int64)
426
+ gid[mask] = np.arange(taken, dtype=np.int64) + offset
427
+ offset += taken
428
+ mine = gid[levels[level_index]["member_of"]]
429
+ labels = np.where(mine >= 0, mine, labels)
430
+
431
+ leaf = drawn[len(levels)]
432
+ taken = int(leaf.sum())
433
+ if taken:
434
+ labels[leaf] = np.arange(taken, dtype=np.int64) + offset
435
+ return labels
436
+
437
+
438
+ def split_by_level(levels, drawn, values):
439
+ """Undo :func:`cut_labels`: split per-element values back onto the levels."""
440
+ values = np.asarray(values)
441
+ indices, out = [None] * (len(levels) + 1), [None] * (len(levels) + 1)
442
+ offset = 0
443
+ order = list(range(len(levels) - 1, -1, -1)) + [len(levels)]
444
+ for slot in order:
445
+ idx = np.flatnonzero(drawn[slot])
446
+ indices[slot] = idx
447
+ out[slot] = values[offset:offset + idx.size]
448
+ offset += idx.size
449
+ return indices, out
450
+
451
+
452
+ def cut_summary(levels, drawn):
453
+ """One line describing what a cut draws."""
454
+ parts = []
455
+ total_nodes = 0
456
+ for level_index in range(len(levels) - 1, -1, -1):
457
+ count = int(drawn[level_index].sum())
458
+ if count:
459
+ members = int(levels[level_index]["counts"][drawn[level_index]].sum())
460
+ total_nodes += members
461
+ parts.append(f"L{level_index + 1}: {count} blobs ({members} nodes)")
462
+ leaf = int(drawn[len(levels)].sum())
463
+ if leaf:
464
+ total_nodes += leaf
465
+ parts.append(f"L0: {leaf} nodes")
466
+ drawn_count = sum(int(d.sum()) for d in drawn)
467
+ return (f"{drawn_count} drawn elements covering {total_nodes} nodes | "
468
+ + ", ".join(parts))