Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b6f9e17
feat: websocket-only frontend for VS Code-style notebook hosts (#281)
sspickle Aug 16, 2026
b4b1757
feat: comm-only frontend for Google Colab
sspickle Aug 16, 2026
32dae7b
fix: compound/text/extrusion/pause deadlock forever in Colab — raise …
sspickle Aug 16, 2026
a1dff68
fix: self-healing bootstrap — Colab sometimes drops mid-import display
sspickle Aug 16, 2026
51f6667
fix: retry comm-open from the kernel idle loop
sspickle Aug 16, 2026
f3029f1
fix: comm-open retries must not close previous attempts
sspickle Aug 16, 2026
3a2b741
feat: browser-initiated comm handshake for Colab (kernel target + com…
sspickle Aug 16, 2026
f3d621b
fix: session nonce freezes out zombie bootstrap frames
sspickle Aug 16, 2026
c9bc63a
fix: fallback opens carry the session nonce too
sspickle Aug 16, 2026
32e1647
fix: load the Colab bootstrap as an external CDN script
sspickle Aug 16, 2026
1fd6455
fix: drop the idle-loop comm retry — it made Colab drop display outputs
sspickle Aug 16, 2026
57298f4
fix: never display from import — Colab drops import-time display output
sspickle Aug 16, 2026
3dc277a
fix: self-heal displays the bootstrap once, not per unconnected cell
sspickle Aug 16, 2026
1a0501b
fix: show() injects the CDN bootstrap dynamically
sspickle Aug 16, 2026
c7d7b4b
feat: scene replay on every frontend attach
sspickle Aug 16, 2026
f299def
fix: cache-bust the evolving frontend files with the session nonce
sspickle Aug 16, 2026
dd5ca1a
fix: backfill the journal with cmds emitted before the frontend imported
sspickle Aug 17, 2026
ab0123d
fix: follow-up cmds (title/caption) must not clobber constructors in …
sspickle Aug 17, 2026
48b25c4
fix: journal copies constructors at replay time, not record time
sspickle Aug 17, 2026
8475feb
fix: replay cmds in original emission order (dim-scene bug)
sspickle Aug 17, 2026
d534f6b
feat: VPYTHON_WS_PORT pins the websocket port
sspickle Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions gcurve_size_check.py
Original file line number Diff line number Diff line change
@@ -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)
52 changes: 52 additions & 0 deletions vpython/_commsender.py
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 26 additions & 0 deletions vpython/_frontend_replay.py
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions vpython/_notebook_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,24 @@ 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
"""
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
Expand All @@ -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)
83 changes: 83 additions & 0 deletions vpython/_scene_journal.py
Original file line number Diff line number Diff line change
@@ -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,
}
56 changes: 56 additions & 0 deletions vpython/_wssender.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions vpython/rate_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading