webgpu 1.2.4.dev0__py3-none-any.whl → 1.2.7__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.
- webgpu/__init__.py +2 -0
- webgpu/_version.py +3 -3
- webgpu/camera.py +151 -140
- webgpu/canvas.py +107 -62
- webgpu/clipping.py +2 -14
- webgpu/colormap.py +66 -7
- webgpu/draw.py +0 -5
- webgpu/engine/__init__.py +21 -0
- webgpu/engine/camera.js +310 -0
- webgpu/engine/compute.js +472 -0
- webgpu/engine/engine.js +916 -0
- webgpu/engine/format.js +43 -0
- webgpu/engine/input.js +96 -0
- webgpu/engine/interactions.js +248 -0
- webgpu/export/__init__.py +10 -0
- webgpu/export/capture.py +404 -0
- webgpu/export/format.py +186 -0
- webgpu/export/gui.py +106 -0
- webgpu/export/screenshot.py +152 -0
- webgpu/export/serialize.py +237 -0
- webgpu/gui_param.py +165 -0
- webgpu/jupyter.py +326 -82
- webgpu/jupyter_pyodide.py +7 -17
- webgpu/light.py +3 -1
- webgpu/link/base.py +126 -11
- webgpu/link/link.js +27 -3
- webgpu/link/proxy.py +40 -3
- webgpu/notebook_to_html.py +83 -0
- webgpu/renderer.py +93 -10
- webgpu/scene.py +242 -27
- webgpu/shaders/shapes.wgsl +1 -1
- webgpu/shapes.py +12 -19
- webgpu/testing.py +34 -20
- webgpu/uniforms.py +4 -3
- webgpu/utils.py +18 -8
- webgpu/webgpu_api.py +12 -3
- {webgpu-1.2.4.dev0.dist-info → webgpu-1.2.7.dist-info}/METADATA +2 -3
- webgpu-1.2.7.dist-info/RECORD +61 -0
- webgpu/lilgui.py +0 -78
- webgpu-1.2.4.dev0.dist-info/RECORD +0 -47
- {webgpu-1.2.4.dev0.dist-info → webgpu-1.2.7.dist-info}/WHEEL +0 -0
- {webgpu-1.2.4.dev0.dist-info → webgpu-1.2.7.dist-info}/licenses/LICENSE +0 -0
- {webgpu-1.2.4.dev0.dist-info → webgpu-1.2.7.dist-info}/top_level.txt +0 -0
webgpu/__init__.py
CHANGED
|
@@ -2,6 +2,8 @@ from .clipping import Clipping
|
|
|
2
2
|
from .colormap import Colormap, Colorbar
|
|
3
3
|
from .font import Font
|
|
4
4
|
from .gizmo import CoordinateAxes, NavigationCube
|
|
5
|
+
from .export.format import Interaction
|
|
6
|
+
from .gui_param import GuiParam
|
|
5
7
|
from .renderer import Renderer
|
|
6
8
|
from .scene import Scene
|
|
7
9
|
from .labels import Labels
|
webgpu/_version.py
CHANGED
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 2,
|
|
21
|
+
__version__ = version = '1.2.7'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 2, 7)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g255121a87'
|
webgpu/camera.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import numpy as np
|
|
2
2
|
|
|
3
3
|
from .uniforms import BaseBinding, Binding, UniformBase, ct
|
|
4
|
-
from .utils import read_shader_file
|
|
4
|
+
from .utils import read_shader_file, Lock
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class CameraUniforms(UniformBase):
|
|
@@ -21,6 +21,70 @@ class CameraUniforms(UniformBase):
|
|
|
21
21
|
("padding", ct.c_uint32),
|
|
22
22
|
]
|
|
23
23
|
|
|
24
|
+
def update(self, transform, canvas):
|
|
25
|
+
"""Recompute projection/model-view matrices from transform and canvas dimensions.
|
|
26
|
+
|
|
27
|
+
Returns (model_view_proj, model_view) matrices, or (None, None) if canvas is unavailable.
|
|
28
|
+
"""
|
|
29
|
+
if canvas is None or canvas.height == 0:
|
|
30
|
+
return None, None
|
|
31
|
+
|
|
32
|
+
near = 0.1
|
|
33
|
+
far = 10
|
|
34
|
+
fov = 45
|
|
35
|
+
aspect = canvas.width / canvas.height
|
|
36
|
+
|
|
37
|
+
zoom = 1.0
|
|
38
|
+
top = near * (np.tan(np.radians(fov) / 2)) * zoom
|
|
39
|
+
height = 2 * top
|
|
40
|
+
width = aspect * height
|
|
41
|
+
left = -0.5 * width
|
|
42
|
+
right = left + width
|
|
43
|
+
bottom = top - height
|
|
44
|
+
|
|
45
|
+
x = 2 * near / (right - left)
|
|
46
|
+
y = 2 * near / (top - bottom)
|
|
47
|
+
|
|
48
|
+
a = (right + left) / (right - left)
|
|
49
|
+
b = (top + bottom) / (top - bottom)
|
|
50
|
+
|
|
51
|
+
c = -far / (far - near)
|
|
52
|
+
d = (-far * near) / (far - near)
|
|
53
|
+
|
|
54
|
+
proj_mat = np.array(
|
|
55
|
+
[
|
|
56
|
+
[x, 0, a, 0],
|
|
57
|
+
[0, y, b, 0],
|
|
58
|
+
[0, 0, c, d],
|
|
59
|
+
[0, 0, -1, 0],
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
view_mat = np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, -3], [0, 0, 0, 1]])
|
|
64
|
+
model_view = view_mat @ transform.mat
|
|
65
|
+
model_view_proj = proj_mat @ model_view
|
|
66
|
+
normal_mat = np.linalg.inv(model_view)
|
|
67
|
+
|
|
68
|
+
self.aspect = aspect
|
|
69
|
+
self.view[:] = view_mat.transpose().flatten()
|
|
70
|
+
self.model_view[:] = model_view.transpose().flatten()
|
|
71
|
+
self.model_view_projection[:] = model_view_proj.transpose().flatten()
|
|
72
|
+
self.normal_mat[:] = normal_mat.flatten()
|
|
73
|
+
|
|
74
|
+
# Extract pure rotation from model_view (upper-left 3x3, normalize columns to remove scale)
|
|
75
|
+
mv33 = model_view[:3, :3]
|
|
76
|
+
col_norms = np.linalg.norm(mv33, axis=0)
|
|
77
|
+
col_norms[col_norms == 0] = 1.0
|
|
78
|
+
rot33 = mv33 / col_norms
|
|
79
|
+
rot_mat4 = np.identity(4)
|
|
80
|
+
rot_mat4[:3, :3] = rot33
|
|
81
|
+
self.rot_mat[:] = rot_mat4.transpose().flatten()
|
|
82
|
+
self.width = canvas.width
|
|
83
|
+
self.height = canvas.height
|
|
84
|
+
self.update_buffer()
|
|
85
|
+
|
|
86
|
+
return model_view_proj, model_view
|
|
87
|
+
|
|
24
88
|
|
|
25
89
|
class Transform:
|
|
26
90
|
"""3D transform with translation/rotation/scale around a configurable center."""
|
|
@@ -143,95 +207,121 @@ class Transform:
|
|
|
143
207
|
def _centering(self, center):
|
|
144
208
|
return self._CenteringContext(self, center)
|
|
145
209
|
|
|
210
|
+
|
|
146
211
|
class Camera:
|
|
147
|
-
"""Interactive camera that
|
|
212
|
+
"""Interactive camera that holds a transform and notifies registered observers on change."""
|
|
148
213
|
|
|
149
214
|
def __init__(self):
|
|
150
|
-
self.uniforms = None
|
|
151
|
-
self.canvas = None
|
|
152
215
|
self.transform = Transform()
|
|
153
|
-
self.
|
|
154
|
-
self.
|
|
216
|
+
self._observers = []
|
|
217
|
+
self._observers_lock = Lock()
|
|
155
218
|
self._is_moving = False
|
|
156
219
|
self._is_rotating = False
|
|
220
|
+
self._registered_handlers = {}
|
|
157
221
|
|
|
158
222
|
def __setstate__(self, state):
|
|
159
223
|
"""Restore pickled camera state (only the transform)."""
|
|
160
224
|
self.transform = state["transform"]
|
|
161
|
-
self.
|
|
162
|
-
self.
|
|
163
|
-
self._render_function = None
|
|
164
|
-
self._get_position_function = None
|
|
225
|
+
self._observers = []
|
|
226
|
+
self._observers_lock = Lock()
|
|
165
227
|
self._is_moving = False
|
|
166
228
|
self._is_rotating = False
|
|
229
|
+
self._registered_handlers = {}
|
|
167
230
|
|
|
168
231
|
def __getstate__(self):
|
|
169
232
|
"""Return a minimal picklable representation of the camera."""
|
|
170
233
|
return {"transform": self.transform}
|
|
171
234
|
|
|
235
|
+
def register_observer(self, callback):
|
|
236
|
+
"""Register a callback to be called when the camera transform changes.
|
|
237
|
+
|
|
238
|
+
Idempotent: registering the same callback twice has no additional effect.
|
|
239
|
+
"""
|
|
240
|
+
with self._observers_lock:
|
|
241
|
+
if callback not in self._observers:
|
|
242
|
+
self._observers.append(callback)
|
|
243
|
+
|
|
244
|
+
def unregister_observer(self, callback):
|
|
245
|
+
"""Remove a previously registered observer callback."""
|
|
246
|
+
with self._observers_lock:
|
|
247
|
+
old_len = len(self._observers)
|
|
248
|
+
self._observers = [cb for cb in self._observers if cb is not callback]
|
|
249
|
+
|
|
250
|
+
def _notify_observers(self):
|
|
251
|
+
"""Notify all registered observers that the transform has changed."""
|
|
252
|
+
with self._observers_lock:
|
|
253
|
+
observers = list(self._observers)
|
|
254
|
+
for cb in observers:
|
|
255
|
+
cb()
|
|
256
|
+
|
|
172
257
|
def reset(self, pmin, pmax):
|
|
173
|
-
"""Fit the camera to the axis-aligned box [pmin, pmax] and
|
|
258
|
+
"""Fit the camera to the axis-aligned box [pmin, pmax] and notify observers."""
|
|
174
259
|
self.transform.init(pmin, pmax)
|
|
175
|
-
self.
|
|
260
|
+
self._notify_observers()
|
|
176
261
|
|
|
177
262
|
def reset_xy(self, flip: bool = False):
|
|
178
|
-
"""Reset to a top-down XY view
|
|
263
|
+
"""Reset to a top-down XY view and notify observers."""
|
|
179
264
|
self.transform.reset_xy(flip)
|
|
180
|
-
self.
|
|
265
|
+
self._notify_observers()
|
|
181
266
|
|
|
182
267
|
def reset_xz(self, flip: bool = False):
|
|
183
|
-
"""Reset to an XZ view
|
|
268
|
+
"""Reset to an XZ view and notify observers."""
|
|
184
269
|
self.transform.reset_xz(flip)
|
|
185
|
-
self.
|
|
270
|
+
self._notify_observers()
|
|
186
271
|
|
|
187
272
|
def reset_yz(self, flip: bool = False):
|
|
188
|
-
"""Reset to a YZ view
|
|
273
|
+
"""Reset to a YZ view and notify observers."""
|
|
189
274
|
self.transform.reset_yz(flip)
|
|
190
|
-
self.
|
|
191
|
-
|
|
192
|
-
def set_canvas(self, canvas):
|
|
193
|
-
"""Attach the camera to a canvas, register resize handling, and allocate uniforms."""
|
|
194
|
-
self.canvas = canvas
|
|
195
|
-
canvas.on_resize(self._update_uniforms)
|
|
196
|
-
if self.uniforms is None:
|
|
197
|
-
self.uniforms = CameraUniforms()
|
|
198
|
-
self._update_uniforms()
|
|
199
|
-
|
|
200
|
-
def get_bindings(self) -> list[BaseBinding]:
|
|
201
|
-
return self.uniforms.get_bindings()
|
|
275
|
+
self._notify_observers()
|
|
202
276
|
|
|
203
277
|
def get_shader_code(self):
|
|
204
278
|
return read_shader_file("camera.wgsl")
|
|
205
279
|
|
|
206
|
-
def
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
280
|
+
def register_callbacks(self, input_handler, get_position_fn=None):
|
|
281
|
+
"""Register mouse/wheel handlers on the given input_handler.
|
|
282
|
+
|
|
283
|
+
Idempotent: unregisters existing handlers for this input_handler first.
|
|
284
|
+
get_position_fn is used for dblclick center-on-point (per-canvas).
|
|
285
|
+
"""
|
|
286
|
+
# Unregister first to avoid duplicates
|
|
287
|
+
self.unregister_callbacks(input_handler)
|
|
288
|
+
|
|
289
|
+
def on_dblclick(ev):
|
|
290
|
+
if get_position_fn:
|
|
291
|
+
p = get_position_fn(ev["canvasX"], ev["canvasY"])
|
|
292
|
+
if p is not None:
|
|
293
|
+
self.transform.set_center(p)
|
|
294
|
+
self._notify_observers()
|
|
295
|
+
|
|
296
|
+
handlers = {
|
|
297
|
+
'mousedown': self._on_mousedown,
|
|
298
|
+
'mouseup': self._on_mouseup,
|
|
299
|
+
'mouseout': self._on_mouseup,
|
|
300
|
+
'mousemove': self._on_mousemove,
|
|
301
|
+
'wheel': self._on_wheel,
|
|
302
|
+
'dblclick': on_dblclick,
|
|
303
|
+
}
|
|
304
|
+
self._registered_handlers[id(input_handler)] = handlers
|
|
305
|
+
|
|
306
|
+
input_handler.on_mousedown(handlers['mousedown'], ctrl=False, shift=False, alt=False)
|
|
307
|
+
input_handler.on_mouseup(handlers['mouseup'], ctrl=False, shift=False, alt=False)
|
|
308
|
+
input_handler.on_mouseout(handlers['mouseout'], ctrl=False, shift=False, alt=False)
|
|
309
|
+
input_handler.on_mousemove(handlers['mousemove'], ctrl=False, shift=False, alt=False)
|
|
310
|
+
input_handler.on_dblclick(handlers['dblclick'], ctrl=False, shift=False, alt=False)
|
|
311
|
+
input_handler.on_wheel(handlers['wheel'], ctrl=False, shift=False, alt=False)
|
|
221
312
|
|
|
222
313
|
def unregister_callbacks(self, input_handler):
|
|
223
|
-
input_handler.
|
|
224
|
-
input_handler
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
input_handler.unregister("
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
self._render()
|
|
314
|
+
"""Remove previously registered handlers from the given input_handler."""
|
|
315
|
+
key = id(input_handler)
|
|
316
|
+
handlers = self._registered_handlers.pop(key, None)
|
|
317
|
+
if handlers is None:
|
|
318
|
+
return
|
|
319
|
+
input_handler.unregister("mousedown", handlers['mousedown'])
|
|
320
|
+
input_handler.unregister("mouseup", handlers['mouseup'])
|
|
321
|
+
input_handler.unregister("mouseout", handlers['mouseout'])
|
|
322
|
+
input_handler.unregister("mousemove", handlers['mousemove'])
|
|
323
|
+
input_handler.unregister("dblclick", handlers['dblclick'])
|
|
324
|
+
input_handler.unregister("wheel", handlers['wheel'])
|
|
235
325
|
|
|
236
326
|
def _on_mousedown(self, ev):
|
|
237
327
|
if ev["button"] == 0:
|
|
@@ -242,15 +332,10 @@ class Camera:
|
|
|
242
332
|
def _on_mouseup(self, _):
|
|
243
333
|
self._is_moving = False
|
|
244
334
|
self._is_rotating = False
|
|
245
|
-
self._is_zooming = False
|
|
246
335
|
|
|
247
336
|
def _on_wheel(self, ev):
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
else:
|
|
251
|
-
p = self.transform._center
|
|
252
|
-
self.transform.scale(1 - ev["deltaY"] / 1000, p)
|
|
253
|
-
self._render()
|
|
337
|
+
self.transform.scale(1 - ev["deltaY"] / 1000, self.transform._center)
|
|
338
|
+
self._notify_observers()
|
|
254
339
|
if hasattr(ev, "preventDefault"):
|
|
255
340
|
ev.preventDefault()
|
|
256
341
|
|
|
@@ -258,82 +343,8 @@ class Camera:
|
|
|
258
343
|
if self._is_rotating:
|
|
259
344
|
s = 0.3
|
|
260
345
|
self.transform.rotate(s * ev["movementY"], s * ev["movementX"])
|
|
261
|
-
self.
|
|
262
|
-
|
|
346
|
+
self._notify_observers()
|
|
347
|
+
elif self._is_moving:
|
|
263
348
|
s = 0.01
|
|
264
349
|
self.transform.translate(s * ev["movementX"], -s * ev["movementY"])
|
|
265
|
-
self.
|
|
266
|
-
|
|
267
|
-
def _render(self):
|
|
268
|
-
self._update_uniforms()
|
|
269
|
-
if self._render_function:
|
|
270
|
-
self._render_function()
|
|
271
|
-
|
|
272
|
-
def _get_event_position(self, x, y):
|
|
273
|
-
if self._get_position_function:
|
|
274
|
-
return self._get_position_function(x, y)
|
|
275
|
-
return None
|
|
276
|
-
|
|
277
|
-
def _update_uniforms(self):
|
|
278
|
-
"""Recompute projection/model-view matrices and write them into the GPU uniforms."""
|
|
279
|
-
if self.canvas is None:
|
|
280
|
-
return
|
|
281
|
-
if self.uniforms is None:
|
|
282
|
-
self.uniforms = CameraUniforms()
|
|
283
|
-
near = 0.1
|
|
284
|
-
far = 10
|
|
285
|
-
fov = 45
|
|
286
|
-
if self.canvas.height == 0:
|
|
287
|
-
aspect = 800 / 600
|
|
288
|
-
else:
|
|
289
|
-
aspect = self.canvas.width / self.canvas.height
|
|
290
|
-
|
|
291
|
-
zoom = 1.0
|
|
292
|
-
top = near * (np.tan(np.radians(fov) / 2)) * zoom
|
|
293
|
-
height = 2 * top
|
|
294
|
-
width = aspect * height
|
|
295
|
-
left = -0.5 * width
|
|
296
|
-
right = left + width
|
|
297
|
-
bottom = top - height
|
|
298
|
-
|
|
299
|
-
x = 2 * near / (right - left)
|
|
300
|
-
y = 2 * near / (top - bottom)
|
|
301
|
-
|
|
302
|
-
a = (right + left) / (right - left)
|
|
303
|
-
b = (top + bottom) / (top - bottom)
|
|
304
|
-
|
|
305
|
-
c = -far / (far - near)
|
|
306
|
-
d = (-far * near) / (far - near)
|
|
307
|
-
|
|
308
|
-
proj_mat = np.array(
|
|
309
|
-
[
|
|
310
|
-
[x, 0, a, 0],
|
|
311
|
-
[0, y, b, 0],
|
|
312
|
-
[0, 0, c, d],
|
|
313
|
-
[0, 0, -1, 0],
|
|
314
|
-
]
|
|
315
|
-
)
|
|
316
|
-
|
|
317
|
-
view_mat = np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, -3], [0, 0, 0, 1]])
|
|
318
|
-
model_view = view_mat @ self.transform.mat
|
|
319
|
-
model_view_proj = proj_mat @ model_view
|
|
320
|
-
self.model_view = model_view
|
|
321
|
-
normal_mat = np.linalg.inv(model_view)
|
|
322
|
-
self.model_view_proj = model_view_proj
|
|
323
|
-
|
|
324
|
-
self.uniforms.aspect = aspect
|
|
325
|
-
self.uniforms.view[:] = view_mat.transpose().flatten()
|
|
326
|
-
self.uniforms.model_view[:] = model_view.transpose().flatten()
|
|
327
|
-
self.uniforms.model_view_projection[:] = model_view_proj.transpose().flatten()
|
|
328
|
-
self.uniforms.normal_mat[:] = normal_mat.flatten()
|
|
329
|
-
# Extract pure rotation from model_view (upper-left 3x3, normalize columns to remove scale)
|
|
330
|
-
mv33 = model_view[:3, :3]
|
|
331
|
-
col_norms = np.linalg.norm(mv33, axis=0)
|
|
332
|
-
col_norms[col_norms == 0] = 1.0
|
|
333
|
-
rot33 = mv33 / col_norms
|
|
334
|
-
rot_mat4 = np.identity(4)
|
|
335
|
-
rot_mat4[:3, :3] = rot33
|
|
336
|
-
self.uniforms.rot_mat[:] = rot_mat4.transpose().flatten()
|
|
337
|
-
self.uniforms.width = self.canvas.width
|
|
338
|
-
self.uniforms.height = self.canvas.height
|
|
339
|
-
self.uniforms.update_buffer()
|
|
350
|
+
self._notify_observers()
|
webgpu/canvas.py
CHANGED
|
@@ -22,73 +22,111 @@ _TARGET_FPS = 60
|
|
|
22
22
|
# running: bool = False
|
|
23
23
|
# pending: bool = False
|
|
24
24
|
|
|
25
|
+
class _InstanceDebounce:
|
|
26
|
+
"""Descriptor that creates per-instance debounced methods.
|
|
27
|
+
|
|
28
|
+
When used as a decorator on a method, each instance gets its own
|
|
29
|
+
debounce state (lock, timer, last_call, pending) so that calls on
|
|
30
|
+
different instances never interfere with each other.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, fn, rate_hz=60):
|
|
34
|
+
self._fn = fn
|
|
35
|
+
self._rate_hz = rate_hz
|
|
36
|
+
self._attr = f"_debounce_{fn.__name__}"
|
|
37
|
+
functools.update_wrapper(self, fn)
|
|
38
|
+
|
|
39
|
+
def __set_name__(self, owner, name):
|
|
40
|
+
self._attr = f"_debounce_{name}"
|
|
41
|
+
|
|
42
|
+
def __get__(self, obj, objtype=None):
|
|
43
|
+
if obj is None:
|
|
44
|
+
return self
|
|
45
|
+
# Return a bound debounced callable for this instance
|
|
46
|
+
state = getattr(obj, self._attr, None)
|
|
47
|
+
if state is None:
|
|
48
|
+
state = _DebounceState(self._fn, obj, self._rate_hz)
|
|
49
|
+
object.__setattr__(obj, self._attr, state)
|
|
50
|
+
return state
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _DebounceState:
|
|
54
|
+
"""Per-instance debounce state and callable."""
|
|
55
|
+
|
|
56
|
+
__slots__ = ("_fn", "_obj", "_interval", "_lock", "_last_call", "_timer", "_pending")
|
|
57
|
+
|
|
58
|
+
def __init__(self, fn, obj, rate_hz):
|
|
59
|
+
self._fn = fn
|
|
60
|
+
self._obj = obj
|
|
61
|
+
self._interval = 1.0 / rate_hz
|
|
62
|
+
self._lock = threading.RLock()
|
|
63
|
+
self._last_call = 0.0
|
|
64
|
+
self._timer = None
|
|
65
|
+
self._pending = None
|
|
66
|
+
|
|
67
|
+
def _original(self, *args, **kwargs):
|
|
68
|
+
"""Bypass debounce and call the underlying function immediately."""
|
|
69
|
+
return self._fn(self._obj, *args, **kwargs)
|
|
70
|
+
|
|
71
|
+
def _schedule(self, delay):
|
|
72
|
+
self._timer = threading.Timer(delay, self._run_pending)
|
|
73
|
+
self._timer.daemon = True
|
|
74
|
+
self._timer.start()
|
|
75
|
+
|
|
76
|
+
def _run_pending(self):
|
|
77
|
+
with self._lock:
|
|
78
|
+
if self._pending is None:
|
|
79
|
+
self._timer = None
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
args, kwargs = self._pending
|
|
83
|
+
self._pending = None
|
|
84
|
+
self._last_call = time.monotonic()
|
|
85
|
+
|
|
86
|
+
self._fn(self._obj, *args, **kwargs)
|
|
87
|
+
|
|
88
|
+
with self._lock:
|
|
89
|
+
self._timer = None
|
|
90
|
+
if self._pending is not None:
|
|
91
|
+
delay = max(0.0, self._interval - (time.monotonic() - self._last_call))
|
|
92
|
+
self._schedule(delay)
|
|
93
|
+
|
|
94
|
+
def __call__(self, *args, **kwargs):
|
|
95
|
+
with self._lock:
|
|
96
|
+
now = time.monotonic()
|
|
97
|
+
elapsed = now - self._last_call
|
|
98
|
+
if elapsed >= self._interval and self._timer is None:
|
|
99
|
+
self._last_call = now
|
|
100
|
+
run_now = True
|
|
101
|
+
else:
|
|
102
|
+
self._pending = (args, kwargs)
|
|
103
|
+
run_now = False
|
|
104
|
+
|
|
105
|
+
if self._timer is None:
|
|
106
|
+
self._schedule(max(0.0, self._interval - elapsed))
|
|
107
|
+
|
|
108
|
+
if run_now:
|
|
109
|
+
self._fn(self._obj, *args, **kwargs)
|
|
110
|
+
|
|
111
|
+
|
|
25
112
|
def debounce(arg=None, *, rate_hz=60):
|
|
26
113
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def schedule(delay):
|
|
35
|
-
nonlocal timer
|
|
36
|
-
timer = threading.Timer(delay, run_pending)
|
|
37
|
-
timer.daemon = True
|
|
38
|
-
timer.start()
|
|
39
|
-
|
|
40
|
-
def run_pending():
|
|
41
|
-
nonlocal last_call, timer, pending
|
|
42
|
-
|
|
43
|
-
with lock:
|
|
44
|
-
if pending is None:
|
|
45
|
-
timer = None
|
|
46
|
-
return
|
|
47
|
-
|
|
48
|
-
args, kwargs = pending
|
|
49
|
-
pending = None
|
|
50
|
-
# print("call frequency = ", 1.0 / (time.monotonic() - last_call))
|
|
51
|
-
last_call = time.monotonic()
|
|
52
|
-
|
|
53
|
-
fn(*args, **kwargs)
|
|
54
|
-
|
|
55
|
-
with lock:
|
|
56
|
-
timer = None
|
|
57
|
-
if pending is not None:
|
|
58
|
-
delay = max(0.0, interval - (time.monotonic() - last_call))
|
|
59
|
-
schedule(delay)
|
|
60
|
-
|
|
61
|
-
@wraps(fn)
|
|
62
|
-
def wrapper(*args, **kwargs):
|
|
63
|
-
nonlocal last_call, pending
|
|
64
|
-
|
|
65
|
-
with lock:
|
|
66
|
-
now = time.monotonic()
|
|
67
|
-
elapsed = now - last_call
|
|
68
|
-
if elapsed >= interval and timer is None:
|
|
69
|
-
# print("call frequency = ", 1.0 / elapsed if elapsed > 0 else float('inf'))
|
|
70
|
-
last_call = now
|
|
71
|
-
run_now = True
|
|
72
|
-
else:
|
|
73
|
-
pending = (args, kwargs)
|
|
74
|
-
run_now = False
|
|
75
|
-
|
|
76
|
-
if timer is None:
|
|
77
|
-
schedule(max(0.0, interval - elapsed))
|
|
78
|
-
|
|
79
|
-
if run_now:
|
|
80
|
-
fn(*args, **kwargs)
|
|
81
|
-
|
|
82
|
-
return wrapper
|
|
114
|
+
if platform.is_pyodide:
|
|
115
|
+
if callable(arg):
|
|
116
|
+
return arg
|
|
117
|
+
def _rate_limited(fn, *args, **kwargs):
|
|
118
|
+
return fn
|
|
119
|
+
|
|
120
|
+
return _rate_limited
|
|
83
121
|
|
|
84
122
|
if callable(arg):
|
|
85
|
-
return
|
|
123
|
+
return _InstanceDebounce(arg, rate_hz)
|
|
86
124
|
|
|
87
125
|
if arg is not None:
|
|
88
126
|
rate_hz = arg
|
|
89
127
|
|
|
90
128
|
def decorate(fn):
|
|
91
|
-
return
|
|
129
|
+
return _InstanceDebounce(fn, rate_hz)
|
|
92
130
|
return decorate
|
|
93
131
|
|
|
94
132
|
|
|
@@ -217,6 +255,7 @@ class Canvas:
|
|
|
217
255
|
|
|
218
256
|
self._on_resize_callbacks = []
|
|
219
257
|
self._on_update_html_canvas = []
|
|
258
|
+
self._on_visibility_callbacks = []
|
|
220
259
|
|
|
221
260
|
self._resize_observer = None
|
|
222
261
|
self._intersection_observer = None
|
|
@@ -281,8 +320,7 @@ class Canvas:
|
|
|
281
320
|
"device": self.device.handle,
|
|
282
321
|
"format": self.format,
|
|
283
322
|
"alphaMode": "premultiplied",
|
|
284
|
-
"
|
|
285
|
-
"usage": TextureUsage.RENDER_ATTACHMENT | TextureUsage.COPY_DST,
|
|
323
|
+
"usage": TextureUsage.RENDER_ATTACHMENT | TextureUsage.COPY_DST | TextureUsage.COPY_SRC,
|
|
286
324
|
}
|
|
287
325
|
)
|
|
288
326
|
|
|
@@ -293,6 +331,8 @@ class Canvas:
|
|
|
293
331
|
if observer_entry[0].isIntersecting:
|
|
294
332
|
for func in self._on_resize_callbacks:
|
|
295
333
|
func()
|
|
334
|
+
for func in self._on_visibility_callbacks:
|
|
335
|
+
func(observer_entry[0].isIntersecting)
|
|
296
336
|
|
|
297
337
|
if self._resize_observer is not None:
|
|
298
338
|
self._resize_observer.disconnect()
|
|
@@ -322,6 +362,10 @@ class Canvas:
|
|
|
322
362
|
def on_resize(self, func: Callable):
|
|
323
363
|
self._on_resize_callbacks.append(func)
|
|
324
364
|
|
|
365
|
+
def on_visibility(self, func: Callable):
|
|
366
|
+
"""Register callback for visibility changes. Called with True/False."""
|
|
367
|
+
self._on_visibility_callbacks.append(func)
|
|
368
|
+
|
|
325
369
|
def on_update_html_canvas(self, func: Callable):
|
|
326
370
|
self._on_update_html_canvas.append(func)
|
|
327
371
|
|
|
@@ -359,8 +403,9 @@ class Canvas:
|
|
|
359
403
|
if canvas is None:
|
|
360
404
|
return
|
|
361
405
|
rect = canvas.getBoundingClientRect()
|
|
362
|
-
|
|
363
|
-
|
|
406
|
+
dpr = getattr(platform.js.window, 'devicePixelRatio', 1) or 1
|
|
407
|
+
width = round(rect.width * dpr)
|
|
408
|
+
height = round(rect.height * dpr)
|
|
364
409
|
|
|
365
410
|
if width == self.width and height == self.height:
|
|
366
411
|
for func in self._on_resize_callbacks:
|
webgpu/clipping.py
CHANGED
|
@@ -79,20 +79,8 @@ class Clipping(BaseRenderer):
|
|
|
79
79
|
return None
|
|
80
80
|
|
|
81
81
|
def __del__(self):
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def add_options_to_gui(self, gui):
|
|
86
|
-
if gui is None:
|
|
87
|
-
return
|
|
88
|
-
folder = gui.folder("Clipping", closed=True)
|
|
89
|
-
folder.checkbox("enabled", self.mode != self.Mode.DISABLED, self.enable_clipping)
|
|
90
|
-
folder.value("x", self.center[0], self.set_x_value)
|
|
91
|
-
folder.value("y", self.center[1], self.set_y_value)
|
|
92
|
-
folder.value("z", self.center[2], self.set_z_value)
|
|
93
|
-
folder.value("nx", self.normal[0], self.set_nx_value)
|
|
94
|
-
folder.value("ny", self.normal[1], self.set_ny_value)
|
|
95
|
-
folder.value("nz", self.normal[2], self.set_nz_value)
|
|
82
|
+
# Do NOT auto-destroy: the JS engine may still reference this buffer.
|
|
83
|
+
pass
|
|
96
84
|
|
|
97
85
|
def render(self, options: RenderOptions):
|
|
98
86
|
pass
|