diff --git a/gcurve_size_check.py b/gcurve_size_check.py new file mode 100644 index 0000000..0e519c2 --- /dev/null +++ b/gcurve_size_check.py @@ -0,0 +1,23 @@ +# Visual check for PR #290 (issue #287): constructor size= must be honoured. +# BEFORE the fix: both curves render at the default thickness and both dot +# sets at the default radius — size= silently ignored. +# AFTER the fix: the second of each pair is visibly fatter. +from vpython import * +from math import sin + +g = graph(title='#287 check: thin = default, FAT = size argument honoured', fast=False) + +thin_curve = gcurve(color=color.blue, label='gcurve default') +fat_curve = gcurve(color=color.red, size=10, label='gcurve size=10') +thin_dots = gdots(color=color.green, label='gdots default') +fat_dots = gdots(color=color.orange, size=14, label='gdots size=14') + +for i in range(60): + x = i / 10 + thin_curve.plot(x, sin(x) + 2.5) + fat_curve.plot(x, sin(x) + 1.2) + thin_dots.plot(x, sin(x) - 1.2) + fat_dots.plot(x, sin(x) - 2.5) + +while True: + rate(10) diff --git a/vpython/_commsender.py b/vpython/_commsender.py new file mode 100644 index 0000000..509f772 --- /dev/null +++ b/vpython/_commsender.py @@ -0,0 +1,52 @@ +"""Kernel->browser sender for the Colab (comm-only) frontend. + +Same buffering contract as _wssender.WsSender — the scene is built during +`import vpython`, before any frontend can possibly attach — but the channel +is an ipykernel Comm (Colab's google.colab.kernel.comms shim on the browser +side), so there is no cross-thread marshaling: everything runs on the +kernel's main thread and comm.send() is called directly. + +One wrinkle the ws sender doesn't have: in Colab mode rate() self-clocks +scene flushes (the browser's pacing triggers can't reach a blocked kernel), +so trigger() runs repeatedly before the frontend attaches. Buffering every +resulting bare 'trigger' handshake would just stuff the backlog with noise, +so unattached 'trigger' strings are dropped; real packages are kept. +""" + + +class CommSender: + def __init__(self): + self._comm = None + self._backlog = [] + self.replay = None # callable -> list of wire objdata packages + + @property + def connected(self): + return self._comm is not None + + def attach(self, comm): + """The browser acked on this comm: replay the scene (if a replay + source is set — it supersedes anything buffered), else flush.""" + self._comm = comm + if self.replay is not None: + self._backlog = [] + for objdata in self.replay(): + comm.send(objdata) + return + backlog, self._backlog = self._backlog, [] + for objdata in backlog: + comm.send(objdata) + + def detach(self): + self._comm = None + + def pending(self): + return len(self._backlog) + + def __call__(self, objdata): + if self._comm is None: + if objdata == 'trigger': + return # empty handshake; meaningless to a frontend that missed it + self._backlog.append(objdata) + else: + self._comm.send(objdata) diff --git a/vpython/_frontend_replay.py b/vpython/_frontend_replay.py new file mode 100644 index 0000000..38c7ad8 --- /dev/null +++ b/vpython/_frontend_replay.py @@ -0,0 +1,26 @@ +"""Builds the sender.replay callable from the scene journal. + +Lazy vpython imports: this module is imported by the frontends during +vpython's own import, and the journal itself must stay pure. +""" + + +def make_replay(journal): + from .vpython import baseObj, vector + + def value_of(idx, attr): + obj = baseObj.object_registry.get(idx) + if obj is None: + return None + try: + val = getattr(obj, attr) + except Exception: + return None + if type(val) is vector: + return val.value # [x, y, z], same conversion trigger() uses + return val + + def replay(): + return [baseObj.package(journal.replay_objdata(value_of))] + + return replay diff --git a/vpython/_notebook_helpers.py b/vpython/_notebook_helpers.py index 9e16800..09c04e7 100644 --- a/vpython/_notebook_helpers.py +++ b/vpython/_notebook_helpers.py @@ -49,6 +49,15 @@ def _undo_vpython_import_in_spyder(): del sys.modules[modname] +def _is_colab(environ=None): + """Google Colab kernel? (its shell class is 'Shell', not + ZMQInteractiveShell, so the notebook check below misses it).""" + environ = os.environ if environ is None else environ + if 'COLAB_RELEASE_TAG' in environ or 'COLAB_GPU' in environ: + return True + return 'google.colab' in sys.modules + + def __checkisnotebook(): """ Check whether we are running in a notebook or not @@ -56,6 +65,8 @@ def __checkisnotebook(): try: if __is_spyder(): return False # Spyder detected so return False + if _is_colab(): + return True # notebook-style pacing/flush behavior applies shell = get_ipython().__class__.__name__ if shell == 'ZMQInteractiveShell': # Jupyter notebook or qtconsole? return True @@ -71,3 +82,34 @@ def __checkisnotebook(): _isnotebook = __checkisnotebook() _in_spyder = __is_spyder() _in_spyder_or_similar_IDE = __is_spyder_or_similar_IDE() + + +def _use_ws_frontend(environ=None): + """Should this notebook kernel speak the whole protocol over the + websocket (VS Code style) instead of Comm + nbextension JS? + + VS Code notebooks never run vpython's injected JavaScript and give + third-party renderers no Comm access, so they get the websocket-only + frontend automatically. VPYTHON_FRONTEND overrides in both directions: + 'ws' forces it on (other renderer hosts, testing), anything else set + ('jupyter', 'classic', ...) forces it off even under VS Code. + """ + environ = os.environ if environ is None else environ + override = environ.get('VPYTHON_FRONTEND') + if override: + return override.strip().lower() == 'ws' + return 'VSCODE_PID' in environ or 'VSCODE_CWD' in environ + + +def _use_colab_frontend(environ=None): + """Comm-only frontend for Google Colab: output frames run our JS and + Colab shims Jupyter comms into them (google.colab.kernel.comms), but no + websocket can reach the kernel VM (the port proxy rejects programmatic + fetch/ws from the sandboxed output iframe — probed 2026-08). + VPYTHON_FRONTEND='colab' forces it on; any other value forces it off. + """ + environ = os.environ if environ is None else environ + override = environ.get('VPYTHON_FRONTEND') + if override: + return override.strip().lower() == 'colab' + return _is_colab(environ) diff --git a/vpython/_scene_journal.py b/vpython/_scene_journal.py new file mode 100644 index 0000000..a0470de --- /dev/null +++ b/vpython/_scene_journal.py @@ -0,0 +1,83 @@ +"""Records the scene so any frontend can be rebuilt at any time. + +Frontends are ephemeral: Colab re-renders output frames on scroll, VS Code +can evict outputs, pages reload. The kernel is the only durable holder of +the scene, so it must be able to replay it — reset, then every cmd in its +original emission order, then the current value of every attribute that +ever changed — whenever a (new) frontend attaches. + +Emission order matters: canvas construction emits its ctor, then a +lights='empty_list' follow-up (wiping glow's built-in default lights), then +the two standard distant_light ctors. Replaying follow-ups after the +constructors would run that wipe last and delete the standard lights — the +scene rebuilds but renders ambient-only (dim). + +Pure bookkeeping: no vpython imports (the hooks in vpython.py call in), no +wire encoding (the caller pushes the returned objdata through +baseObj.package, the same encoder the live path uses). +""" + +_CTOR, _EXTRA = 'ctor', 'extra' + + +class SceneJournal: + def __init__(self): + self._cmds = {} # idx -> constructor cmd (live reference) + self._log = [] # (_CTOR, idx) | (_EXTRA, cmd) in emission order + self._dirty = set() # (idx, attr) ever changed after construction + + def record_cmd(self, cmd): + idx = cmd.get('idx') + if cmd.get('cmd') == 'delete': + self._cmds.pop(idx, None) + self._log = [(kind, ref) for (kind, ref) in self._log + if not (kind == _CTOR and ref == idx) + and not (kind == _EXTRA and ref.get('idx') == idx)] + self._dirty = {(i, a) for (i, a) in self._dirty if i != idx} + return + if cmd.get('cmd') is None: + # Follow-up on an existing object (title/caption/lights/...): + # same idx as its constructor — must NOT clobber it, and must + # keep its place in the emission order. + self._log.append((_EXTRA, cmd)) + return + if idx not in self._cmds: + self._log.append((_CTOR, idx)) + # Store the LIVE reference, copy at replay time: constructors are + # enriched after appendcmd (canvas adds its attrs afterwards), and a + # record-time copy ships a bare canvas (observed: dim default scene). + self._cmds[idx] = cmd + + def record_attr(self, idx, attr): + self._dirty.add((idx, attr)) + + def constructors(self): + return [dict(self._cmds[ref]) for (kind, ref) in self._log + if kind == _CTOR] + + def dirty_attrs(self): + return set(self._dirty) + + def _replay_cmds(self): + out = [{'cmd': 'reset', 'idx': -1}] + for (kind, ref) in self._log: + out.append(dict(self._cmds[ref]) if kind == _CTOR else dict(ref)) + return out + + def replay_objdata(self, value_of): + """Build the {'cmds', 'methods', 'attrs'} objdata that reconstructs + the scene. value_of(idx, attr) returns the CURRENT wire-ready value, + or None when the object is gone (entry skipped).""" + attrs = {} + for (idx, attr) in self._dirty: + if idx not in self._cmds: + continue + val = value_of(idx, attr) + if val is None: + continue + attrs.setdefault(idx, {})[attr] = val + return { + 'cmds': self._replay_cmds(), + 'methods': [], + 'attrs': attrs, + } diff --git a/vpython/_wssender.py b/vpython/_wssender.py new file mode 100644 index 0000000..e164d7e --- /dev/null +++ b/vpython/_wssender.py @@ -0,0 +1,56 @@ +"""Kernel->browser sender for the websocket-only frontend. + +Classic Jupyter sends scene packages over an ipykernel Comm. Hosts like VS +Code give third-party renderers no Comm access, so this sender writes the +same packages to the tornado websocket instead. + +Two realities shape it: + +- The renderer connects *after* the first packages exist (`import vpython` + builds the scene before any frontend can possibly attach), so everything + is buffered until a connection appears, then flushed in order. + +- vpython calls the sender from the kernel's main thread, but the tornado + server lives on its own thread with its own IOLoop; tornado websockets are + not thread-safe, so every write is marshaled with IOLoop.add_callback. +""" +import json + + +class WsSender: + def __init__(self): + self._handler = None + self._ioloop = None + self._backlog = [] + self.replay = None # callable -> list of wire objdata packages + + def attach(self, handler, ioloop): + """A renderer connected: replay the scene (if a replay source is + set — it supersedes anything buffered), else flush the backlog.""" + self._handler = handler + self._ioloop = ioloop + if self.replay is not None: + self._backlog = [] + for objdata in self.replay(): + ioloop.add_callback(handler.write_message, json.dumps(objdata)) + return + backlog, self._backlog = self._backlog, [] + for text in backlog: + ioloop.add_callback(handler.write_message, text) + + def detach(self): + """The renderer went away; buffer until another one connects.""" + self._handler = None + self._ioloop = None + + def pending(self): + return len(self._backlog) + + def __call__(self, objdata): + # sender() receives either the literal handshake string 'trigger' or + # a JSON-able package (dict from baseObj.package, list of cmds). + text = objdata if isinstance(objdata, str) else json.dumps(objdata) + if self._handler is None: + self._backlog.append(text) + else: + self._ioloop.add_callback(self._handler.write_message, text) diff --git a/vpython/rate_control.py b/vpython/rate_control.py index a44c9ae..f6bd438 100644 --- a/vpython/rate_control.py +++ b/vpython/rate_control.py @@ -18,6 +18,11 @@ # Unresolved bug: rate(X) yields only about 0.8X iterations per second. +# Colab (comm-only frontend): the browser's 33 ms pacing triggers arrive as +# Jupyter comm messages, which a kernel blocked in a cell never processes — +# so rate() must self-clock the flush. with_colab sets this True. +_direct_trigger = False + MIN_RENDERS = 10 MAX_RENDERS = 60 INTERACT_PERIOD = 1.0/MAX_RENDERS @@ -259,6 +264,10 @@ def sendtofrontend(self): # Must send events one at a time to GW.handle_msg because bound events need the loop code: msg = {'content':{'data':[m]}} # message format used by notebook self._sender(msg) + if _direct_trigger: + # Comm-only host: synthesize the pacing trigger the browser + # cannot deliver mid-cell, so trigger() flushes updates now. + self._sender({'content': {'data': [{'event': 'update_canvas', 'trigger': 1}]}}) def __call__(self, N): # rate(N) calls this function self.rval = N diff --git a/vpython/test/test_colabfrontend.py b/vpython/test/test_colabfrontend.py new file mode 100644 index 0000000..0014fff --- /dev/null +++ b/vpython/test/test_colabfrontend.py @@ -0,0 +1,75 @@ +"""Unit tests for the Colab (comm-only) frontend pieces.""" +from vpython._notebook_helpers import _use_colab_frontend +from vpython._commsender import CommSender + + +# ---- detection ------------------------------------------------------------ + +def test_colab_frontend_off_elsewhere(): + assert _use_colab_frontend(environ={}) is False + assert _use_colab_frontend(environ={'VSCODE_PID': '1'}) is False + + +def test_colab_frontend_auto_detects_colab(): + assert _use_colab_frontend(environ={'COLAB_RELEASE_TAG': 'r1'}) is True + assert _use_colab_frontend(environ={'COLAB_GPU': '0'}) is True + + +def test_env_override_wins_both_ways(): + assert _use_colab_frontend(environ={'VPYTHON_FRONTEND': 'colab'}) is True + assert _use_colab_frontend( + environ={'COLAB_RELEASE_TAG': 'r1', 'VPYTHON_FRONTEND': 'jupyter'}) is False + + +# ---- the sender ----------------------------------------------------------- + +class FakeComm: + def __init__(self): + self.sent = [] + + def send(self, data): + self.sent.append(data) + + +def test_packages_buffer_until_attach_then_flush_in_order(): + s = CommSender() + s([{'cmd': 'canvas', 'idx': 1}]) + s({'attrs': [{'idx': 1}]}) + assert s.pending() == 2 and not s.connected + comm = FakeComm() + s.attach(comm) + assert s.connected + assert comm.sent == [[{'cmd': 'canvas', 'idx': 1}], {'attrs': [{'idx': 1}]}] + + +def test_bare_triggers_are_dropped_while_unattached_but_sent_after(): + s = CommSender() + s('trigger') + s('trigger') + assert s.pending() == 0 # noise, not scene state + comm = FakeComm() + s.attach(comm) + s('trigger') + assert comm.sent == ['trigger'] + + +def test_detach_returns_to_buffering(): + s = CommSender() + comm = FakeComm() + s.attach(comm) + s.detach() + s([{'cmd': 'sphere', 'idx': 2}]) + assert not s.connected and s.pending() == 1 + comm2 = FakeComm() + s.attach(comm2) + assert comm2.sent == [[{'cmd': 'sphere', 'idx': 2}]] + + +def test_attach_with_replay_source_sends_replay_and_drops_backlog(): + s = CommSender() + s([{'cmd': 'canvas', 'idx': 1}]) # stale pre-attach buffering + s.replay = lambda: [{'cmds': [{'cmd': 'reset', 'idx': -1}], 'attrs': 'X'}] + comm = FakeComm() + s.attach(comm) + assert comm.sent == [{'cmds': [{'cmd': 'reset', 'idx': -1}], 'attrs': 'X'}] + assert s.pending() == 0 diff --git a/vpython/test/test_scene_journal.py b/vpython/test/test_scene_journal.py new file mode 100644 index 0000000..1461598 --- /dev/null +++ b/vpython/test/test_scene_journal.py @@ -0,0 +1,107 @@ +"""Scene journal: enough recorded state to rebuild any frontend at attach. + +Frontends are ephemeral (Colab re-renders output frames on scroll; VS Code +can evict outputs; pages reload). The journal records every constructor cmd +and every attribute ever touched, and builds a replay package — reset, all +constructors, current attribute values — so a brand-new frontend instance +can reconstruct the scene from nothing. +""" +from vpython._scene_journal import SceneJournal + + +def test_records_constructors_in_creation_order(): + j = SceneJournal() + j.record_cmd({'cmd': 'canvas', 'idx': 1, 'width': 640}) + j.record_cmd({'cmd': 'sphere', 'idx': 2, 'canvas': 1}) + j.record_cmd({'cmd': 'box', 'idx': 3, 'canvas': 1}) + cmds = j.constructors() + assert [c['cmd'] for c in cmds] == ['canvas', 'sphere', 'box'] + + +def test_late_enriched_constructor_keys_are_included_at_replay(): + # canvas builds its cmd incrementally AFTER appendcmd; the journal must + # reflect the enriched dict, not a bare record-time snapshot. + j = SceneJournal() + cmd = {'cmd': 'canvas', 'idx': 1} + j.record_cmd(cmd) + cmd['ambient'] = [0.2, 0.2, 0.2] + assert j.constructors()[0]['ambient'] == [0.2, 0.2, 0.2] + # but the returned list is still a copy: mutating it is inert + j.constructors()[0]['hacked'] = True + assert 'hacked' not in j.constructors()[0] + + +def test_delete_removes_object_and_its_dirty_attrs(): + j = SceneJournal() + j.record_cmd({'cmd': 'sphere', 'idx': 2}) + j.record_attr(2, 'pos') + j.record_cmd({'cmd': 'delete', 'idx': 2}) + assert j.constructors() == [] + assert j.dirty_attrs() == set() + + +def test_dirty_attrs_accumulate_across_flushes(): + j = SceneJournal() + j.record_cmd({'cmd': 'sphere', 'idx': 2}) + j.record_attr(2, 'pos') + j.record_attr(2, 'color') + j.record_attr(2, 'pos') # repeated set: still one entry + assert j.dirty_attrs() == {(2, 'pos'), (2, 'color')} + + +def test_replay_objdata_reset_constructors_then_current_attr_values(): + j = SceneJournal() + j.record_cmd({'cmd': 'canvas', 'idx': 1}) + j.record_cmd({'cmd': 'sphere', 'idx': 2, 'canvas': 1}) + j.record_attr(2, 'pos') + j.record_attr(9, 'pos') # object the registry no longer knows: skipped + + values = {(2, 'pos'): [1, 2, 3]} + def value_of(idx, attr): + return values.get((idx, attr)) + + objdata = j.replay_objdata(value_of) + assert objdata['cmds'][0] == {'cmd': 'reset', 'idx': -1} + assert [c['cmd'] for c in objdata['cmds'][1:]] == ['canvas', 'sphere'] + assert objdata['attrs'] == {2: {'pos': [1, 2, 3]}} + assert objdata['methods'] == [] + + +def test_empty_journal_still_replays_a_bare_reset(): + j = SceneJournal() + objdata = j.replay_objdata(lambda i, a: None) + assert objdata['cmds'] == [{'cmd': 'reset', 'idx': -1}] + assert objdata['attrs'] == {} + + +def test_replay_preserves_live_emission_order_of_followups(): + # THE Colab dim-scene bug: canvas construction emits, in this order, + # canvas ctor -> lights='empty_list' (wipe glow's built-in defaults) + # -> two distant_light ctors (the standard lighting). + # Replaying constructors-then-extras moves the wipe AFTER the standard + # lights, deleting them: the scene renders ambient-only (dim). Replay + # must preserve the original emission order. + j = SceneJournal() + j.record_cmd({'cmd': 'canvas', 'idx': 1}) + j.record_cmd({'lights': 'empty_list', 'idx': 1}) + j.record_cmd({'cmd': 'distant_light', 'idx': 2, 'canvas': 1}) + j.record_cmd({'cmd': 'distant_light', 'idx': 3, 'canvas': 1}) + j.record_cmd({'cmd': 'sphere', 'idx': 4, 'canvas': 1}) + cmds = j.replay_objdata(lambda i, a: None)['cmds'] + assert cmds[0] == {'cmd': 'reset', 'idx': -1} + wipe_at = next(i for i, c in enumerate(cmds) + if c.get('lights') == 'empty_list') + light_at = [i for i, c in enumerate(cmds) + if c.get('cmd') == 'distant_light'] + assert wipe_at < min(light_at), ( + 'lights wipe replayed after the standard lights: scene goes dim') + + +def test_followup_cmds_do_not_clobber_the_constructor(): + j = SceneJournal() + j.record_cmd({'cmd': 'canvas', 'idx': 1}) + j.record_cmd({'title': 'my scene', 'idx': 1}) # canvas.title follow-up + assert [c['cmd'] for c in j.constructors()] == ['canvas'] + objdata = j.replay_objdata(lambda i, a: None) + assert objdata['cmds'][1]['cmd'] == 'canvas' + assert objdata['cmds'][2] == {'title': 'my scene', 'idx': 1} diff --git a/vpython/test/test_wsfrontend.py b/vpython/test/test_wsfrontend.py new file mode 100644 index 0000000..0f9b807 --- /dev/null +++ b/vpython/test/test_wsfrontend.py @@ -0,0 +1,112 @@ +"""Unit tests for the websocket-only frontend (VS Code notebooks). + +Pure-python: the sender takes its ioloop/handler as injected fakes, and the +mode-selection helpers take an environment dict. +""" +import json + +from vpython._notebook_helpers import _use_ws_frontend +from vpython._wssender import WsSender + + +# ---- mode selection ------------------------------------------------------- + +def test_ws_frontend_off_in_plain_jupyter(): + assert _use_ws_frontend(environ={}) is False + + +def test_ws_frontend_auto_detects_vscode(): + assert _use_ws_frontend(environ={'VSCODE_PID': '123'}) is True + assert _use_ws_frontend(environ={'VSCODE_CWD': '/x'}) is True + + +def test_env_override_forces_ws_frontend_anywhere(): + assert _use_ws_frontend(environ={'VPYTHON_FRONTEND': 'ws'}) is True + + +def test_env_override_forces_classic_even_under_vscode(): + env = {'VSCODE_PID': '123', 'VPYTHON_FRONTEND': 'jupyter'} + assert _use_ws_frontend(environ=env) is False + + +# ---- the sender ----------------------------------------------------------- + +class FakeIOLoop: + """Records add_callback calls; run() executes them (simulating the + tornado thread servicing its queue).""" + def __init__(self): + self.queue = [] + + def add_callback(self, fn, *args): + self.queue.append((fn, args)) + + def run(self): + q, self.queue = self.queue, [] + for fn, args in q: + fn(*args) + + +class FakeHandler: + def __init__(self): + self.sent = [] + + def write_message(self, text): + self.sent.append(text) + + +def test_packages_sent_before_connect_are_buffered_then_flushed_in_order(): + s = WsSender() + s('trigger') + s([{'cmd': 'canvas', 'idx': 1}]) + handler, ioloop = FakeHandler(), FakeIOLoop() + s.attach(handler, ioloop) + ioloop.run() + assert handler.sent[0] == 'trigger' + assert json.loads(handler.sent[1]) == [{'cmd': 'canvas', 'idx': 1}] + + +def test_send_after_connect_goes_through_the_ioloop_not_directly(): + s = WsSender() + handler, ioloop = FakeHandler(), FakeIOLoop() + s.attach(handler, ioloop) + ioloop.run() + s({'attrs': [{'idx': 1, 'pos': [0, 0, 0]}]}) + # nothing written until the tornado thread services its callback queue + assert handler.sent == [] + ioloop.run() + assert json.loads(handler.sent[0]) == {'attrs': [{'idx': 1, 'pos': [0, 0, 0]}]} + + +def test_detach_buffers_again_until_reconnect(): + s = WsSender() + handler, ioloop = FakeHandler(), FakeIOLoop() + s.attach(handler, ioloop) + ioloop.run() + s.detach() + s('trigger') + assert s.pending() == 1 + handler2 = FakeHandler() + s.attach(handler2, ioloop) + ioloop.run() + assert handler2.sent == ['trigger'] + + +def test_strings_pass_through_unjsonified(): + s = WsSender() + handler, ioloop = FakeHandler(), FakeIOLoop() + s.attach(handler, ioloop) + s('trigger') + ioloop.run() + assert handler.sent == ['trigger'] + + +def test_attach_with_replay_source_sends_replay_and_drops_backlog(): + s = WsSender() + s('trigger') + s([{'cmd': 'canvas', 'idx': 1}]) + s.replay = lambda: [{'cmds': [{'cmd': 'reset', 'idx': -1}]}] + handler, ioloop = FakeHandler(), FakeIOLoop() + s.attach(handler, ioloop) + ioloop.run() + assert [json.loads(t) for t in handler.sent] == [{'cmds': [{'cmd': 'reset', 'idx': -1}]}] + assert s.pending() == 0 diff --git a/vpython/vpython.py b/vpython/vpython.py index 1fa951d..27fd301 100644 --- a/vpython/vpython.py +++ b/vpython/vpython.py @@ -17,7 +17,7 @@ def sign(x): # for compatibility with Web VPython import sys from . import __version__, __gs_version__ -from ._notebook_helpers import _isnotebook +from ._notebook_helpers import _isnotebook, _use_ws_frontend, _use_colab_frontend from ._vector_import_helper import (vector, mag, norm, cross, dot, adjust_up, adjust_axis, object_rotate) @@ -207,6 +207,7 @@ class baseObj(object): attach_trails = [] # needed only for functions follow_objects = [] # entries are [invisible object to follow, function to call for pos, prevous pos] attrs = set() # each element is (idx, attr name) + _journal = None # SceneJournal set by replay-capable frontends (colab/ws) @classmethod def initialize(cls): @@ -262,7 +263,17 @@ def __init__(self, **kwargs): if not (baseObj._view_constructed or baseObj._canvas_constructing): if _isnotebook: - from .with_notebook import _ + if _use_colab_frontend(): + # Google Colab: comm-only — output frames run our JS and + # Colab shims Jupyter comms into them; no websocket can + # reach the kernel VM. + from .with_colab import _ + elif _use_ws_frontend(): + # VS Code-style hosts: no nbextension JS, no Comm; the + # whole protocol rides the tornado websocket (issue #281). + from .with_wsfrontend import _ + else: + from .with_notebook import _ else: from .no_notebook import _ baseObj._view_constructed = True @@ -289,6 +300,7 @@ def delete(self): def appendcmd(self,cmd): # The following code makes sure that constructors are sent to the front end first. cmd['idx'] = self.idx + if baseObj._journal is not None: baseObj._journal.record_cmd(cmd) while not baseObj.sent: # baseObj.sent is always True in the notebook case time.sleep(0.001) baseObj.updates['cmds'].append(cmd) # this is an "atomic" (uninterruptable) operation @@ -299,6 +311,7 @@ def addmethod(self, method, data): baseObj.updates['methods'].append((self.idx, method, data)) # this is an "atomic" (uninterruptable) operation def addattr(self, attr): + if baseObj._journal is not None: baseObj._journal.record_attr(self.idx, attr) while not baseObj.sent: # baseObj.sent is always True in the notebook case time.sleep(0.001) baseObj.attrs.add((self.idx, attr)) # this is an "atomic" (uninterruptable) operation @@ -346,6 +359,7 @@ def decrObjCnt(cls): def __del__(self): cmd = {"cmd": "delete", "idx": self.idx} + if baseObj._journal is not None: baseObj._journal.record_cmd(cmd) if (baseObj.glow is not None and sender is not None): sender([cmd]) else: @@ -373,10 +387,15 @@ def __del__(self): # and sent as a block to the browser at render times. class GlowWidget(object): - def __init__(self, wsport=None, wsuri=None): + def __init__(self, wsport=None, wsuri=None, sender_override=None): global sender baseObj.glow = self - if _isnotebook: + if sender_override is not None: + # websocket-only frontend (with_wsfrontend): packages go out over + # the tornado websocket; there is no Comm and no injected JS. + sender = sender_override + self.show = True + elif _isnotebook: from ipykernel.comm import Comm if (wsport): self.comm = Comm(target_name='glow', data={'wsport':wsport, 'wsuri':wsuri}) @@ -428,6 +447,18 @@ def handle_close(self, data): print ("comm closed") def _wait(cvs): # wait for an event + if _use_colab_frontend(): + # These waits spin until the BROWSER replies (computed extents for + # compound/text/extrusion, events for pause/waitfor/pick). Colab's + # comm channel only delivers messages when the kernel is idle between + # cells, so the reply can never arrive while we spin: a guaranteed + # hang. Fail loudly instead (same philosophy as issue #281's fix). + raise NotImplementedError( + "This operation (compound/text/extrusion geometry, or " + "scene.pause/waitfor/mouse picking) needs an immediate reply " + "from the browser, which Google Colab's messaging cannot " + "deliver while a cell is running. It is not yet supported in " + "Colab.") cvs._waitfor = None if _isnotebook: baseObj.trigger() # in notebook environment must send methods immediately while cvs._waitfor is None: @@ -2918,7 +2949,10 @@ class canvas(baseObj): def __init__(self, **args): baseObj._canvas_constructing = True - if _isnotebook: + # The ws and colab frontends own their own containers (announced by + # with_wsfrontend / with_colab); the classic HTML/JS cell bootstrap + # below would render as dead output there. + if _isnotebook and not _use_ws_frontend() and not _use_colab_frontend(): from IPython.display import display, HTML, Javascript display(HTML("""
""")) display(Javascript("""if (typeof Jupyter !== "undefined") { window.__context = { glowscript_container: $("#glowscript").removeAttr("id")};}else{ element.textContent = ' ';}""")) diff --git a/vpython/vpython_libraries/glowcomm_colab.js b/vpython/vpython_libraries/glowcomm_colab.js new file mode 100644 index 0000000..bec6811 --- /dev/null +++ b/vpython/vpython_libraries/glowcomm_colab.js @@ -0,0 +1,162 @@ +// glowcomm_colab.js — browser side of the Colab (comm-only) VPython frontend. +// +// Injected inline by with_colab.py via display(HTML). Loads GlowScript from +// a CDN (Colab output frames may load external scripts; jsDelivr serves the +// vpython/vscode-vpython repo's media/ directory), then registers a Jupyter +// comm target through Colab's shim (google.colab.kernel.comms) and drives +// glowcomm_host.js: +// +// downlink: comm message (msg.data = package | 'trigger') -> fe.handle +// uplink: pacer -> fe.tick() -> send(events) -> comm.send(events) +// +// Colab comm API (probed 2026-08): registerTarget(name, cb); the comm object +// is {send, close, messages} with messages an async iterator. The kernel may +// retry the comm open (it can't know when this script has run), so every +// open is acked and the LATEST comm becomes the active channel. +// +// Adaptive pacing: 33 ms while the kernel is answering, easing off to 500 ms +// when nothing has come back for 2 s (kernel blocked in a long computation +// or between cells) — bounds the message backlog a blocked kernel must +// swallow when it wakes. + +window.__VPYTHON_COLAB_BOOT = function (opts) { + 'use strict'; + var CDN = opts.cdn; // ends with '/' + var NONCE = opts.nonce; // this session's token; stale saved frames have an old one + var TICK_MS = 33, SLOW_MS = 500, EASE_AFTER_MS = 2000; + + var root = document.getElementById('vpython-colab-root'); + var status = document.createElement('div'); + status.style.cssText = 'font-family:monospace;font-size:12px;opacity:.8'; + var container = document.createElement('div'); + root.appendChild(status); + root.appendChild(container); + + function setStatus(m) { status.textContent = m; } + function fail(m) { + var line = document.createElement('pre'); + line.textContent = 'VPython (Colab): ' + m; + line.style.cssText = 'color:#c00;white-space:pre-wrap'; + root.appendChild(line); + } + + function loadScript(name) { + return new Promise(function (resolve, reject) { + var s = document.createElement('script'); + s.src = CDN + name; + s.onload = resolve; + s.onerror = function () { reject(new Error('failed to load ' + CDN + name)); }; + document.head.appendChild(s); + }); + } + + // Hide any AMD loader while classic UMD scripts execute (else jQuery UI + // registers as a module and never patches $.fn — learned in VS Code). + var hadDefine = Object.prototype.hasOwnProperty.call(window, 'define'); + var savedDefine = window.define; + try { window.define = undefined; } catch (e) { } + function restoreAmd() { + if (hadDefine) { window.define = savedDefine; } + else { try { delete window.define; } catch (e) { } } + } + + setStatus('VPython: loading GlowScript from CDN…'); + loadScript('jquery.min.js') + .then(function () { return loadScript('jquery-ui.custom.min.js'); }) + .then(function () { return loadScript('glow.min.js'); }) + .then(function () { + window.Jupyter_VPython = CDN + 'data/'; // texture prefix for glow + function loadFont(file, slot) { + return new Promise(function (resolve, reject) { + window.opentype_load(window.Jupyter_VPython + file, function (err, font) { + if (err) { reject(new Error('font ' + file + ': ' + err)); return; } + window[slot] = font; + resolve(); + }); + }); + } + return Promise.all([ + loadFont('Roboto-Medium.ttf', '__font_sans'), + loadFont('NimbusRomNo9L-Med.otf', '__font_serif') + ]); + }) + .then(function () { return loadScript('glowcomm_host.js?v=' + NONCE); }) + .then(function () { + restoreAmd(); + var jq = window.$ || window.jQuery; + if (!jq || !jq.fn || typeof jq.fn.resizable !== 'function') { + throw new Error('jQuery UI did not attach ($.fn.resizable missing)'); + } + if (typeof window.createGlowFrontend !== 'function') { + throw new Error('glowcomm_host loaded but createGlowFrontend missing'); + } + + var active = null, fe = null, lastReply = Date.now(); + + function startPacer() { + (function tickLoop() { + var idle = Date.now() - lastReply > EASE_AFTER_MS; + try { fe.tick(); } catch (e) { fail('tick: ' + e); } + setTimeout(tickLoop, idle ? SLOW_MS : TICK_MS); + })(); + } + + // Shared by both handshake directions: bind a live comm as the active + // channel, create the frontend on first use, and pump its messages. + function useComm(comm, ackFirst) { + active = comm; // latest wins on both sides + if (ackFirst) { comm.send({ ack: 1 }); } + if (!fe) { + fe = window.createGlowFrontend({ + container: container, + glow: window, + send: function (events) { if (active) { active.send(events); } } + }); + startPacer(); + setStatus(''); + } + (async function () { + try { + for await (var m of comm.messages) { + lastReply = Date.now(); + var d = (m && m.data !== undefined) ? m.data : m; + fe.handle(d === 'trigger' ? 'trigger' : d); + } + } catch (e) { /* iterator ends when comm closes; a newer comm takes over */ } + })(); + } + + var comms = google.colab.kernel.comms; + + // Fallback: kernel-initiated opens (retried from the kernel idle loop). + comms.registerTarget('vpython-glow', function (comm, openMsg) { + // Ignore kernel-initiated opens meant for a different session's frame. + var d = openMsg && (openMsg.data || (openMsg.content && openMsg.content.data)); + if (d && d.nonce && d.nonce !== NONCE) { return; } + useComm(comm, true); // ack tells the kernel which comm to attach + }); + + // Preferred: WE open the comm, because only we know when this script + // has actually finished loading. The kernel registered the target + // before displaying this bootstrap, so it always exists by now. The + // kernel processes the open at its next idle moment and flushes the + // buffered scene. No races, no retries. + if (typeof comms.open === 'function') { + Promise.resolve(comms.open('vpython-glow-kernel', { nonce: NONCE })) + .then(function (comm) { + if (comm && comm.send && comm.messages) { useComm(comm, true); } + else { setStatus('VPython: comms.open returned unusable comm; ' + + 'waiting for kernel-initiated connect…'); } + }) + .catch(function (e) { + setStatus('VPython: comms.open failed (' + e + '); ' + + 'waiting for kernel-initiated connect…'); + }); + setStatus('VPython: ready — connecting to the kernel…'); + } else { + setStatus('VPython: ready — waiting for the kernel to connect ' + + '(run the next cell if this lingers)…'); + } + }) + .catch(function (e) { restoreAmd(); fail((e && e.stack) || String(e)); }); +}; diff --git a/vpython/vpython_libraries/glowcomm_host.js b/vpython/vpython_libraries/glowcomm_host.js new file mode 100644 index 0000000..71c3220 --- /dev/null +++ b/vpython/vpython_libraries/glowcomm_host.js @@ -0,0 +1,1022 @@ +// glowcomm_host.js — host-agnostic browser front-end for the VPython wire protocol. +// +// Ported from glowcomm.js, which is the Jupyter-notebook front end: it owns a +// Comm/WebSocket, loads fonts out of the nbextensions data directory, paces +// itself with a setTimeout loop, and calls GlowScript constructors that Jupyter +// happens to have put on `window`. Everything in that list is the *host's* job. +// +// What is left after removing it is the part that is actually the protocol: turn +// a decoded {cmds, methods, attrs} package into GlowScript objects. That is this +// file. It knows nothing about Jupyter, websockets, Pyodide, or any particular +// embedding page; the host supplies the GlowScript constructor registry, a +// container element, and a send() for the event channel. +// +// var fe = createGlowFrontend({container: el, send: fn, glow: window}) +// fe.handle(ops) // ops is the parsed {cmds, methods, attrs} package, or 'trigger' +// fe.tick() // one pacing tick: sample the canvas, drain queued events +// fe.poll() // like tick(), but silent when there is nothing to say +// fe.pacingStopped() // the host's clock has stopped; flush and stay flushing +// fe.reset() // forget every object (new scene generation) +// fe.destroy() // reset + ask the objects to remove themselves +// +// createGlowFrontend.version // vpython-jupyter version this file ships with +// +// Mouse/key event capture IS ported (see "the event channel" below). Widgets, +// pause and waitfor are not: those are deferred by design (spec V5, they raise +// NotImplementedError on the Python side) and their call sites warn and drop. + +'use strict'; + +// The half of the pair that is NOT the wheel. This file and the wheel are built +// from the same checkout and copied into a host by hand +// (trinket: scripts/sync-vpython-worker.sh), so the one question a running +// deploy has to be able to answer is "are these two the same vintage?". +// Carrying the vpython-jupyter version here lets a host log it next to the wheel +// filename and see a mismatch instead of debugging one. Keep it in step with +// SETUPTOOLS_SCM_PRETEND_VERSION when the wheel is rebuilt. +var GLOWCOMM_HOST_VERSION = '7.6.5'; + +function createGlowFrontend(opts) { + opts = opts || {}; + + // The GlowScript constructor registry. glowcomm.js called `sphere(cfg)` and + // friends as bare globals; here they come off `glow` so a test (or a second + // scene) can supply its own. + var glow = opts.glow || globalThis; + // Where the host wants the canvas to appear (may be null; see 'canvas' below). + var container = opts.container || null; + // Host's outbound channel for events. Only the deferred stubs use it today. + var send = opts.send || null; + + // Every object Python has created, indexed by the idx Python assigned. + var glowObjs = []; + + // glowcomm.js could rely on `vec`/`curve`/`points` being real constructors on + // the page, so it used a bare `instanceof`. Here the registry is injected and + // may not be constructible (the unit tests pass plain functions), which makes + // `instanceof` throw. Same answer as `instanceof` for real GlowScript classes. + function is_a(value, Ctor) { + try { return typeof Ctor === 'function' && value instanceof Ctor; } + catch (e) { return false; } + } + + // glowcomm.js decoded and consumed the Comm payload in place (it owned that + // object and threw it away afterwards). Here the package belongs to the + // caller and may be replayed or inspected, so the two mutating paths — + // decode() rewriting data.attrs, and handle_cmds() deleting cmd/idx/method — + // work on shallow copies instead. + function shallow_copy(obj) { + var copy = {}, k; + for (k in obj) { + if (Object.prototype.hasOwnProperty.call(obj, k)) copy[k] = obj[k]; + } + return copy; + } + + // --------------------------------------------------------------------------- + // The event channel back to Python. + // + // glowcomm.js pushed events onto a module-global list and drained it from a + // free-running setTimeout loop that also owned the render pacing. Here the + // HOST owns when a tick happens (it calls tick() off its own clock) and this + // file owns what goes in it. The event objects themselves are byte-faithful + // to glowcomm.js, because those shapes ARE the wire contract that + // vpython.py's handle_msg (:394-425) and canvas.handle_event (:3287) read. + // --------------------------------------------------------------------------- + + var events = []; // queued outbound events, drained by tick()/flush() + var last_tick = -Infinity; // when the host last called tick() + + // Backstop only. The host is expected to SAY when its clock stops + // (pacingStopped(), below); this catches a host that forgets to, or one whose + // clock dies without warning. Hosts pace at glowcomm.js's ~33 ms `interval`, + // so three misses. + var PACING_GRACE_MS = 100; + + function now_ms() { + if (typeof performance !== 'undefined' && performance.now) return performance.now(); + return Date.now(); + } + + // The contents of one outbound message: everything queued, plus whatever the + // canvas poll has to say. glowcomm.js's send() built exactly this list, in + // this order, and it built it for EVERY message — there was only one path + // out. Keeping the poll on both paths is what makes an event-driven flush + // carry current camera/keys state instead of whatever was true when the + // host's clock last ran. + function drain() { + var update = update_canvas(); + var out = events; + events = []; + if (update !== null) out = out.concat(update); + return out; + } + + function flush() { + if (events.length === 0) return; // nothing to say; don't poll, don't send + var out = drain(); + if (out.length > 0 && send) send(out); + } + + // Queue one event for Python. While the host is ticking, tick() drains this + // within a frame and the events coalesce — that batching is the only reason + // glowcomm.js has a queue at all. When the host is NOT ticking, nothing will + // ever drain the queue, so the event goes out on its own. + // + // That second case is not a corner: a host whose pacing clock belongs to the + // RUN (trinket's does) has no clock at all by the time the user clicks, and + // `scene.bind('click', f)` followed by the end of the program is exactly + // what an interactive VPython example looks like. The click has to carry + // itself. + function queue(evt) { + events.push(evt); + if (now_ms() - last_tick > PACING_GRACE_MS) flush(); + } + + // The host's clock has stopped. Called BY the host, because only the host + // knows: inferring it from PACING_GRACE_MS leaves a window — an event + // arriving in the ~100 ms after the final tick looks like it has a tick + // coming, so it is queued for one that never arrives and sits there until + // some later event happens to flush it. Anything queued goes out now, and + // every subsequent event flushes itself. + function pacing_stopped() { + last_tick = -Infinity; + flush(); + } + + // pick and compound/text/extrusion are synchronous barriers: Python is + // blocked inside _wait() until the answer comes back. They never wait for a + // tick, even when one is due — but they go out with anything already queued, + // so ordering is preserved. + + function send_pick(cvs, p, seg) { + var evt = {event: 'pick', 'canvas': cvs, 'pick': p, 'segment':seg}; + events.push(evt); + flush(); + } + + function send_compound(cvs, pos, size, up) { + var evt = {event: '_compound', 'canvas': cvs, 'pos': [pos.x, pos.y, pos.z], + 'size': [size.x, size.y, size.z], 'up': [up.x, up.y, up.z]}; + events.push(evt); + flush(); + } + + // glowcomm.js process() (:311-346): one browser event, in the shape + // canvas.handle_event() destructures. `event` is GlowScript's event object. + function process(event) { + // mouse events: mouseup, mousedown, mousemove, mouseenter, mouseleave, click + // key events: keydown, keyup + // other: resize + var etype = event.type; + var evt = {event:etype}; + var idx = event.canvas['idx']; + evt.canvas = idx; + if (etype != 'resize') { + if (etype.slice(0,3) == 'key') { + evt.key = event.key; + evt.which = event.which; + evt.alt = event.alt; + evt.ctrl = event.ctrl; + evt.shift = event.shift; + } else { + var pos = event.pos; + evt.pos = [pos.x, pos.y, pos.z]; + evt.press = event.press; + evt.release = event.release; + evt.which = event.which; + var ray = event.canvas.mouse.ray; + evt.ray = [ ray.x, ray.y, ray.z ]; + evt.alt = event.canvas.mouse.alt; + evt.ctrl = event.canvas.mouse.ctrl; + evt.shift = event.canvas.mouse.shift; + } + } else { + evt.width = event.canvas.width; + evt.height = event.canvas.height; + } + if ('bind' in event) evt.bind = true; + queue(evt); + } + + function process_binding(event) { // event associated with a previous bind command + event.bind = true; + process(event); + } + + // These three must NOT forward anything. pause, waitfor and widgets are + // deferred by design (spec V5): the Python side raises NotImplementedError + // before any of them can reach the browser, so these handlers are reachable + // only if that deferral is lifted without porting them. Anything this file + // could synthesize would be a PARTIAL event, and a partial event does not + // fail politely: handle_msg indexes object_registry by evt['idx'] and reads + // evt['value']/evt['text'] for widgets (vpython.py:400-415), and + // handle_event reads evt['alt']/['shift']/['ctrl'] for a mouse event + // (vpython.py:3335) — a stub raises KeyError inside the kernel's message + // loop. Warning and dropping is the honest behaviour. + + function not_wired(feature) { + if (typeof console !== 'undefined') console.warn('glowcomm_host: ' + feature + ' not wired yet'); + } + + function process_waitfor(event) { + not_wired('waitfor'); + } + + function process_pause() { + not_wired('pause'); + } + + function control_handler(obj) { // button, menu, slider, radio, checkbox, winput + not_wired('widgets'); + } + + var waitfor_canvas = null; + var waitfor_options = null; + // possible event types to bind: + var binds = ['mousedown', 'mouseup', 'mousemove', 'click', 'mouseenter', 'mouseleave', + 'keydown', 'keyup', 'redraw', 'draw_complete', 'resize']; + + // --------------------------------------------------------------------------- + // Canvas polling: the half of the state only the BROWSER knows. + // --------------------------------------------------------------------------- + + // The previous sample, so a still scene sends nothing. glowcomm.js kept these + // as module globals seeded with vec(0,0,0); here they are per-front-end and + // re-seeded by reset(), because a new scene generation starts from scratch. + var lastpos, lastray, lastforward, lastup, lastcenter; + var lastrange, lastautoscale, lastsliders, lastkeysdown; + // glowcomm.js's control_handler() samples slider values in here so a drag + // reports once per render instead of once per pixel. Widgets are deferred + // (see control_handler above) so it stays empty, but update_canvas' half of + // that mechanism is ported whole rather than left as a hole to re-derive. + // + // WHOEVER WIRES control_handler, READ THIS. Upstream reports a slider ONLY + // through update_canvas(), i.e. only when something else causes a message to + // go out. That was safe in the notebook, where the clock never stops. Here + // the host's clock belongs to the RUN (trinket's does), and flush() — + // deliberately — returns early on an empty queue rather than polling the + // canvas, so once a program has ended a slider drag would sit in this object + // until some unrelated mouse event happened to flush it. The fix at that + // point is for control_handler to queue() a widget event for the slider (the + // shape vpython.py handle_msg reads: {'idx':…,'value':…,'widget':'slider'}) + // instead of relying on this poll — which is what upstream's control_handler + // already does for every OTHER widget: only the slider branch returns early + // instead of ending in events.push(evt). + var sliders; + + function vzero() { return (typeof glow.vec === 'function') ? glow.vec(0, 0, 0) : null; } + + function reset_canvas_state() { + lastpos = vzero(); lastray = vzero(); lastforward = vzero(); + lastup = vzero(); lastcenter = vzero(); + lastrange = 1; + lastautoscale = true; + lastsliders = {}; + lastkeysdown = []; + sliders = {}; + } + reset_canvas_state(); + + // glowcomm.js update_canvas() (:193-275). Mouse position and camera state + // for the canvas the mouse is over, diffed against the last sample; returns + // an array of events, or null when nothing changed. + function update_canvas() { + var dosend = false; + var evt = null; + // `canvas.hasmouse` is a static on GlowScript's canvas class — the only + // way it changes is with the mouse. + var cvs = glow.canvas ? glow.canvas.hasmouse : null; + // ...and being a class static, it OUTLIVES a scene: after reset() it can + // still point at a canvas from a torn-down generation, whose idx now + // means something else (or nothing) in Python's object_registry. Only + // report a canvas this front-end still owns. + if (cvs && glowObjs[cvs.idx] !== cvs) cvs = null; + + if (cvs !== null && cvs !== undefined) { + evt = {event:'update_canvas'}; + var idx = cvs.idx; + evt.canvas = idx; + var pos = cvs.mouse.pos; + if (!lastpos || !pos.equals(lastpos)) {evt.pos = [pos.x,pos.y,pos.z]; dosend=true;} + lastpos = pos; + var ray = cvs.mouse.ray; + if (!lastray || !ray.equals(lastray)) {evt.ray = [ray.x,ray.y,ray.z]; dosend=true;} + lastray = ray; + + // glowcomm.js calls the bare global keysdown(); a host that supplied + // its own registry may not have it, in which case report no change. + var k = (typeof glow.keysdown === 'function') ? glow.keysdown() : lastkeysdown; + var test = true; // assume keysdown() is same as lastkeysdown + if (k.length !== lastkeysdown.length) test = false; + else { + for (var i=0; i