Skip to content

Commit dbc3da2

Browse files
committed
use js engine also from ngapp
1 parent 46c6f3a commit dbc3da2

6 files changed

Lines changed: 398 additions & 34 deletions

File tree

webgpu/camera.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ class CameraUniforms(UniformBase):
2121
("dpr", ct.c_float),
2222
]
2323

24-
def update(self, transform, canvas):
24+
def update(self, transform, canvas, write_buffer=True):
2525
"""Recompute projection/model-view matrices from transform and canvas dimensions.
2626
2727
Returns (model_view_proj, model_view) matrices, or (None, None) if canvas is unavailable.
28+
29+
With ``write_buffer`` False the matrices are computed but the GPU buffer
30+
is not uploaded (JS-engine mode, where the browser owns the camera).
2831
"""
2932
if canvas is None or canvas.height == 0:
3033
return None, None
@@ -82,7 +85,8 @@ def update(self, transform, canvas):
8285
self.width = canvas.width
8386
self.height = canvas.height
8487
self.dpr = float(getattr(canvas, 'dpr', 1.0))
85-
self.update_buffer()
88+
if write_buffer:
89+
self.update_buffer()
8690

8791
return model_view_proj, model_view
8892

@@ -219,6 +223,7 @@ def __init__(self):
219223
self._is_moving = False
220224
self._is_rotating = False
221225
self._registered_handlers = {}
226+
self._dblclick_handlers = {}
222227

223228
def __setstate__(self, state):
224229
"""Restore pickled camera state (only the transform)."""
@@ -228,6 +233,7 @@ def __setstate__(self, state):
228233
self._is_moving = False
229234
self._is_rotating = False
230235
self._registered_handlers = {}
236+
self._dblclick_handlers = {}
231237

232238
def __getstate__(self):
233239
"""Return a minimal picklable representation of the camera."""
@@ -311,8 +317,30 @@ def on_dblclick(ev):
311317
input_handler.on_dblclick(handlers['dblclick'], ctrl=False, shift=False, alt=False)
312318
input_handler.on_wheel(handlers['wheel'], ctrl=False, shift=False, alt=False)
313319

320+
def register_dblclick_center(self, input_handler, get_position_fn):
321+
"""Register only the double-click-to-center handler (used in JS-engine
322+
mode, where rotate/pan/zoom are handled in the browser)."""
323+
self.unregister_dblclick_center(input_handler)
324+
325+
def on_dblclick(ev):
326+
if get_position_fn:
327+
p = get_position_fn(ev["canvasX"], ev["canvasY"])
328+
if p is not None:
329+
self.transform.set_center(p)
330+
self._notify_observers()
331+
332+
self._dblclick_handlers[id(input_handler)] = on_dblclick
333+
input_handler.on_dblclick(on_dblclick, ctrl=False, shift=False, alt=False)
334+
335+
def unregister_dblclick_center(self, input_handler):
336+
"""Remove a previously registered double-click-to-center handler."""
337+
handler = self._dblclick_handlers.pop(id(input_handler), None)
338+
if handler is not None:
339+
input_handler.unregister("dblclick", handler)
340+
314341
def unregister_callbacks(self, input_handler):
315342
"""Remove previously registered handlers from the given input_handler."""
343+
self.unregister_dblclick_center(input_handler)
316344
key = id(input_handler)
317345
handlers = self._registered_handlers.pop(key, None)
318346
if handlers is None:

webgpu/engine/engine.js

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ class RenderEngine {
181181
theme: descriptor.theme || {},
182182
};
183183

184+
// Live-mode host callbacks (optional).
185+
this._onEvent = descriptor.on_event || null;
186+
this._onCameraChanged = descriptor.on_camera_changed || null;
187+
// Host clear color [r,g,b,a] overriding the built-in light/dark color.
188+
this._clearColorOverride = descriptor.clear_color || null;
189+
184190
this.buffers = _toMap(descriptor.buffers);
185191
this.textures = _toMap(descriptor.textures);
186192
this.samplers = new Map();
@@ -260,22 +266,27 @@ class RenderEngine {
260266
this.renderPassObjects = [];
261267
await this._createRenderPipelines();
262268

263-
// --- Input ---
264-
// Live mode: the host (Python) owns input handling and writes the camera
265-
// uniform and calls notifyDirty() explicitly when buffers change.
266-
if (this.mode !== 'live') {
267-
this.camera.registerObserver(() => {
268-
this._updateCameraBuffer();
269-
// Only mark the camera buffer dirty — compute passes that actually
270-
// depend on the camera (list it in their triggers) will re-run.
271-
// Passes triggered by other buffers (e.g. clipping plane) are
272-
// unaffected by camera movement.
273-
if (this._cameraBufferId) {
274-
this.computeDAG.markDirty(this._cameraBufferId);
275-
}
276-
this.render();
269+
// --- Input + camera ---
270+
// The JS engine owns the camera and input loop in both modes. In live mode
271+
// it also forwards non-camera events to the host and syncs settled camera
272+
// transforms back.
273+
this.camera.registerObserver(() => {
274+
this._updateCameraBuffer();
275+
if (this._cameraBufferId) {
276+
this.computeDAG.markDirty(this._cameraBufferId);
277+
}
278+
this.render();
279+
this._notifyCameraChanged();
280+
});
281+
this.input = new InputHandler(canvas, this.camera, () => this.render());
282+
if (this._onEvent) {
283+
this.input.setEventSink((ev) => {
284+
try { this._onEvent(ev); }
285+
catch (e) { console.warn('[engine] on_event failed:', e && (e.message || e)); }
277286
});
278-
this.input = new InputHandler(canvas, this.camera, () => this.render());
287+
}
288+
if (this._onCameraChanged) {
289+
this.input.onGestureEnd = () => this._notifyCameraChanged(true);
279290
}
280291

281292
// --- Interactions (lil-gui) ---
@@ -415,9 +426,56 @@ class RenderEngine {
415426
if (this.depthTexture) this.depthTexture.destroy();
416427
if (this.msaaTexture) this.msaaTexture.destroy();
417428
this._createDepthAndMSAA();
429+
// Refresh the camera uniform (aspect/width/height) for the new size.
430+
this._updateCameraBuffer();
431+
this.render();
432+
}
433+
434+
/** Live-mode hook: host sets the camera transform (reset/view/bookmark). */
435+
setCameraTransform(matrix, center) {
436+
if (!this.camera) return;
437+
this._suppressCameraNotify = true;
438+
try {
439+
if (matrix) this.camera.transform._mat = Float64Array.from(matrix);
440+
if (center) this.camera.transform._center = Array.from(center);
441+
this._updateCameraBuffer();
442+
if (this._cameraBufferId) this.computeDAG.markDirty(this._cameraBufferId);
443+
this.render();
444+
} finally {
445+
this._suppressCameraNotify = false;
446+
}
447+
}
448+
449+
/** Live-mode hook: host sets the background clear color [r,g,b,a] (0..1). */
450+
setClearColor(rgba) {
451+
this._clearColorOverride = rgba || null;
452+
this._applyTheme();
418453
this.render();
419454
}
420455

456+
/** Report the current camera transform to the host (debounced). */
457+
_notifyCameraChanged(immediate = false) {
458+
if (!this._onCameraChanged || this._suppressCameraNotify) return;
459+
const send = () => {
460+
this._camNotifyTimer = null;
461+
try {
462+
this._onCameraChanged({
463+
matrix: Array.from(this.camera.transform._mat),
464+
center: Array.from(this.camera.transform._center),
465+
});
466+
} catch (e) {
467+
console.warn('[engine] on_camera_changed failed:', e && (e.message || e));
468+
}
469+
};
470+
if (immediate) {
471+
if (this._camNotifyTimer) { clearTimeout(this._camNotifyTimer); }
472+
send();
473+
return;
474+
}
475+
if (this._camNotifyTimer) return; // trailing-edge debounce already scheduled
476+
this._camNotifyTimer = setTimeout(send, 100);
477+
}
478+
421479
/**
422480
* Live-mode hook: mark a buffer (or a list of them) as dirty so any
423481
* compute pass triggered by it re-runs on the next render. Pass no args
@@ -902,9 +960,14 @@ class RenderEngine {
902960

903961
_applyTheme() {
904962
const dark = this._isDarkMode();
905-
this.clearColor = dark ? DARK_CLEAR_COLOR : LIGHT_CLEAR_COLOR;
963+
const ov = this._clearColorOverride;
964+
this.clearColor = ov
965+
? { r: ov[0], g: ov[1], b: ov[2], a: ov.length > 3 ? ov[3] : 1.0 }
966+
: (dark ? DARK_CLEAR_COLOR : LIGHT_CLEAR_COLOR);
906967
if (this.canvas && this.canvas.style) {
907-
this.canvas.style.backgroundColor = dark ? DARK_CANVAS_BG : LIGHT_CANVAS_BG;
968+
this.canvas.style.backgroundColor = ov
969+
? `rgb(${Math.round(ov[0] * 255)},${Math.round(ov[1] * 255)},${Math.round(ov[2] * 255)})`
970+
: (dark ? DARK_CANVAS_BG : LIGHT_CANVAS_BG);
908971
}
909972
// Update colorbar background color to match theme
910973
if (this.scene && this.scene.theme && this.scene.theme.buffer_id) {
@@ -958,6 +1021,7 @@ class RenderEngine {
9581021

9591022
dispose() {
9601023
if (this._resizeObserver) this._resizeObserver.disconnect();
1024+
if (this._camNotifyTimer) { clearTimeout(this._camNotifyTimer); this._camNotifyTimer = null; }
9611025
this._teardownThemeObserver();
9621026
if (this.input) this.input.dispose();
9631027
if (this.interactions) this.interactions.dispose();

webgpu/engine/input.js

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ class InputHandler {
1414
this.onRender = onRender;
1515
this.getPositionFn = getPositionFn;
1616

17+
// Live mode: sink for classified non-camera events forwarded to the host.
18+
this._eventSink = null;
19+
this.onGestureEnd = null;
20+
this.rotateSensitivity = 0.5;
21+
22+
this._downButton = 0;
23+
this._downX = 0;
24+
this._downY = 0;
25+
this._moved = false;
26+
1727
this._isRotating = false;
1828
this._isPanning = false;
1929

@@ -30,6 +40,7 @@ class InputHandler {
3040
this._onPointerDown = this._onPointerDown.bind(this);
3141
this._onPointerUp = this._onPointerUp.bind(this);
3242
this._onPointerMove = this._onPointerMove.bind(this);
43+
this._onPointerLeave = this._onPointerLeave.bind(this);
3344
this._onWheel = this._onWheel.bind(this);
3445
this._onDblClick = this._onDblClick.bind(this);
3546
this._onContextMenu = (ev) => ev.preventDefault();
@@ -40,6 +51,7 @@ class InputHandler {
4051
canvas.addEventListener('pointerdown', this._onPointerDown);
4152
canvas.addEventListener('pointerup', this._onPointerUp);
4253
canvas.addEventListener('pointermove', this._onPointerMove);
54+
canvas.addEventListener('pointerleave', this._onPointerLeave);
4355
canvas.addEventListener('wheel', this._onWheel, { passive: false });
4456
canvas.addEventListener('dblclick', this._onDblClick);
4557
canvas.addEventListener('contextmenu', this._onContextMenu);
@@ -49,21 +61,66 @@ class InputHandler {
4961
canvas.addEventListener('touchcancel', this._onTouchEnd);
5062
}
5163

64+
setEventSink(fn) {
65+
this._eventSink = fn;
66+
}
67+
68+
_forward(type, ev) {
69+
if (!this._eventSink) return;
70+
const rect = this.canvas.getBoundingClientRect();
71+
const dpr = window.devicePixelRatio || 1;
72+
const payload = {
73+
type,
74+
button: ev.button == null ? 0 : ev.button,
75+
buttons: ev.buttons == null ? 0 : ev.buttons,
76+
x: ev.clientX,
77+
y: ev.clientY,
78+
canvasX: Math.round((ev.clientX - rect.left) * dpr),
79+
canvasY: Math.round((ev.clientY - rect.top) * dpr),
80+
movementX: ev.movementX || 0,
81+
movementY: ev.movementY || 0,
82+
deltaX: ev.deltaX || 0,
83+
deltaY: ev.deltaY || 0,
84+
ctrlKey: !!ev.ctrlKey,
85+
shiftKey: !!ev.shiftKey,
86+
altKey: !!ev.altKey,
87+
};
88+
try {
89+
this._eventSink(payload);
90+
} catch (e) {
91+
console.warn('[input] event sink failed:', e && (e.message || e));
92+
}
93+
}
94+
5295
// --- Pointer events (mouse & single touch fallback) ---
5396

5497
_onPointerDown(ev) {
5598
ev.preventDefault();
56-
if (ev.button === 0 && !ev.shiftKey && !ev.ctrlKey && !ev.altKey) {
99+
this._downButton = ev.button;
100+
this._downX = ev.clientX;
101+
this._downY = ev.clientY;
102+
this._moved = false;
103+
// ctrl/alt are reserved for the host and never start a camera gesture.
104+
const hostModified = ev.ctrlKey || ev.altKey;
105+
if (!hostModified && ev.button === 0 && !ev.shiftKey) {
57106
this._isRotating = true;
58-
} else if (ev.button === 1 || (ev.button === 0 && ev.shiftKey)) {
107+
} else if (!hostModified && (ev.button === 1 || (ev.button === 0 && ev.shiftKey))) {
59108
this._isPanning = true;
60109
}
61110
this.canvas.setPointerCapture(ev.pointerId);
62111
}
63112

64113
_onPointerUp(ev) {
114+
const wasGesture = this._isRotating || this._isPanning;
65115
this._isRotating = false;
66116
this._isPanning = false;
117+
// A press with no movement is a click; camera gestures set _moved.
118+
if (this._eventSink && !this._moved && ev.button === this._downButton) {
119+
this._forward('click', ev);
120+
}
121+
if (wasGesture && this._moved && this.onGestureEnd) {
122+
try { this.onGestureEnd(); } catch (e) { /* ignore */ }
123+
}
67124
}
68125

69126
_onPointerMove(ev) {
@@ -78,25 +135,48 @@ class InputHandler {
78135
const dpr = window.devicePixelRatio || 1;
79136
const t = this.camera.transform;
80137
if (this._isRotating) {
81-
t.rotate(0.3 * dpr * ev.movementY, 0.3 * dpr * ev.movementX);
138+
this._moved = true;
139+
const s = this.rotateSensitivity * dpr;
140+
t.rotate(s * ev.movementY, s * ev.movementX);
82141
this.camera._notify();
83142
this.onRender();
84-
} else if (this._isPanning) {
143+
return;
144+
}
145+
if (this._isPanning) {
146+
this._moved = true;
85147
t.translate(0.01 * dpr * ev.movementX, -0.01 * dpr * ev.movementY);
86148
this.camera._notify();
87149
this.onRender();
150+
return;
88151
}
152+
// Not a camera gesture — forward to the host (hover, or a modified drag).
153+
if (!this._eventSink) return;
154+
if (ev.buttons !== 0) {
155+
this._moved = true;
156+
this._forward('drag', ev);
157+
} else {
158+
this._forward('mousemove', ev);
159+
}
160+
}
161+
162+
_onPointerLeave(ev) {
163+
if (this._eventSink) this._forward('mouseout', ev);
89164
}
90165

91166
_onWheel(ev) {
92167
ev.preventDefault();
168+
if (this._eventSink && (ev.ctrlKey || ev.altKey)) {
169+
this._forward('wheel', ev);
170+
return;
171+
}
93172
const t = this.camera.transform;
94173
t.scale(1 - ev.deltaY / 1000, t._center);
95174
this.camera._notify();
96175
this.onRender();
97176
}
98177

99178
_onDblClick(ev) {
179+
if (this._eventSink) this._forward('dblclick', ev);
100180
if (!this.getPositionFn) return;
101181
const rect = this.canvas.getBoundingClientRect();
102182
const x = ev.clientX - rect.left;
@@ -219,6 +299,7 @@ class InputHandler {
219299
c.removeEventListener('pointerdown', this._onPointerDown);
220300
c.removeEventListener('pointerup', this._onPointerUp);
221301
c.removeEventListener('pointermove', this._onPointerMove);
302+
c.removeEventListener('pointerleave', this._onPointerLeave);
222303
c.removeEventListener('wheel', this._onWheel);
223304
c.removeEventListener('dblclick', this._onDblClick);
224305
c.removeEventListener('contextmenu', this._onContextMenu);

0 commit comments

Comments
 (0)