diff --git a/.github/workflows/pure-wheel.yml b/.github/workflows/pure-wheel.yml new file mode 100644 index 0000000..31cf99e --- /dev/null +++ b/.github/workflows/pure-wheel.yml @@ -0,0 +1,58 @@ +name: Pure-Python wheel (wasm/Pyodide) + +# Builds the VPYTHON_PURE_PYTHON=1 wheel (py3-none-any) that Pyodide/micropip +# and other wasm targets can install — they cannot use platform wheels. +# Consumers (e.g. trinket) pin the artifact by URL + sha256; a GitHub release +# gets the wheel attached automatically so pins survive artifact expiry. + +on: + workflow_dispatch: + push: + branches: [master, pyodide-packaging] + release: + types: [published] + +jobs: + pure-wheel: + runs-on: ubuntu-latest + permissions: + contents: write # needed only for the release-asset upload step + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # setuptools_scm-style dev versions need history + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Build pure wheel + run: | + pip install build + VPYTHON_PURE_PYTHON=1 python -m build --wheel --outdir dist + ls -l dist + + - name: Verify wheel is py3-none-any and imports + run: | + python - <<'EOF' + import glob, sys + w = glob.glob('dist/*.whl') + assert w and w[0].endswith('py3-none-any.whl'), f"not a pure wheel: {w}" + print("pure wheel:", w[0]) + EOF + pip install dist/*.whl + python -c "import vpython; print('import ok', vpython.__version__)" + + - name: Checksum + run: shasum -a 256 dist/*.whl | tee dist/SHA256SUMS + + - uses: actions/upload-artifact@v4 + with: + name: vpython-pure-wheel + path: dist/ + + - name: Attach wheel to release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.event.release.tag_name }}" dist/*.whl dist/SHA256SUMS --clobber diff --git a/.github/workflows/upload_pypi.yml b/.github/workflows/upload_pypi.yml index 7f31eb4..c96271a 100644 --- a/.github/workflows/upload_pypi.yml +++ b/.github/workflows/upload_pypi.yml @@ -4,8 +4,14 @@ on: release: types: [created] +# Pre-releases exist to host pinned artifacts (see pure-wheel.yml) — they must +# NEVER publish to PyPI. Guarded per-job because uploads happen per-job: on +# 2026-08-15 a pre-release tag partially published 7.6.6.dev0 (sdist + mac/win +# wheels) before the linux legs failed. + jobs: wheels: + if: ${{ !github.event.release.prerelease }} strategy: max-parallel: 4 @@ -40,6 +46,7 @@ jobs: twine upload dist/*.whl linux_wheels: + if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -71,6 +78,7 @@ jobs: twine upload dist/vpython-*-manylinux*.whl linux_aarch64_wheels: + if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -100,6 +108,7 @@ jobs: twine upload dist/vpython-*-manylinux*.whl sdist: + if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 diff --git a/.gitignore b/.gitignore index 7dfb4c1..9ac7803 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,7 @@ docs/_build/ # PyBuilder target/ + +# local virtualenvs +.venv/ +venv/ diff --git a/setup.py b/setup.py index bcd9cc3..268acfa 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,28 @@ +import os + from setuptools import setup from distutils.extension import Extension -try: - from Cython.Build import cythonize - USE_CYTHON = True - extensions = cythonize('vpython/cyvector.pyx') -except ImportError: - extensions = [Extension('vpython.cyvector', ['vpython/cyvector.c'])] +# Set VPYTHON_PURE_PYTHON=1 to build with no C extension at all. The result is a +# `py3-none-any` wheel, which is what Pyodide/micropip and other wasm targets +# require — they cannot install a platform wheel, and declaring `ext_modules` is +# what makes every wheel a platform wheel. +# +# Nothing is lost but speed: `_vector_import_helper` already falls back to the +# pure-Python `vector` implementation when `cyvector` cannot be imported, so the +# same source runs either way. Ordinary builds are unchanged — this is opt-in. +PURE_PYTHON = os.environ.get('VPYTHON_PURE_PYTHON', '').strip() not in ('', '0', 'false', 'False') + +if PURE_PYTHON: + extensions = [] +else: + try: + from Cython.Build import cythonize + USE_CYTHON = True + extensions = cythonize('vpython/cyvector.pyx') + except ImportError: + extensions = [Extension('vpython.cyvector', ['vpython/cyvector.c'])] install_requires = ['jupyter', 'jupyter-server-proxy', 'jupyterlab-vpython>=3.1.8', 'notebook>=7.0.0', 'numpy', 'ipykernel', @@ -35,14 +50,19 @@ 'Topic :: Multimedia :: Graphics :: 3D Rendering', 'Topic :: Scientific/Engineering :: Visualization', ], - ext_modules=extensions, install_requires=install_requires, - python_requires=">=3.8", + python_requires=">=3.8", # importlib.metadata, used in vpython/__init__.py package_data={'vpython': ['vpython_data/*', 'vpython_libraries/*', 'vpython_libraries/images/*']}, ) +# `ext_modules` is added only for a normal build. Omitting the key entirely (as +# opposed to passing an empty list) is what makes setuptools tag the wheel +# `py3-none-any` rather than a platform wheel. +if not PURE_PYTHON: + setup_args['ext_modules'] = extensions + try: setup(**setup_args) except SystemExit as e: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..32e0f66 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Make the in-tree ``vpython`` package importable regardless of cwd. + +The repo root is prepended (not appended) so these tests always exercise the +working tree rather than any copy of vpython installed in site-packages. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) diff --git a/tests/test_trinket_worker.py b/tests/test_trinket_worker.py new file mode 100644 index 0000000..17c6576 --- /dev/null +++ b/tests/test_trinket_worker.py @@ -0,0 +1,480 @@ +"""CPython tests for the wasm-host transport patches. + +A Web Worker gives vpython a JS host and a single thread: no Jupyter, no +servers, no ``time.sleep`` that anyone can afford. ``trinket_worker`` supplies +the pipe and rewrites vpython's blocking surface to match. These tests stand up +that world on plain CPython -- ``js``/``pyodide.ffi`` are fakes and +``sys.platform`` is forced to ``'emscripten'`` -- so importing the package boots +the *trinket* transport rather than ``no_notebook``'s threads and sockets. + +Assertions deliberately run against the package namespace (``vpython.rate``, +``vpython.sleep``), i.e. exactly the names ``from vpython import *`` binds into +a student's program. That also pins the eager-boot ordering in ``__init__.py``: +if the transport booted after the star-imports, ``vpython.sleep`` would still be +the busy-spinning original and these tests would fail. +""" + +import asyncio +import sys +import time +import types + +import pytest + + +DEFERRAL_SUFFIX = (" is not supported in the worker runtime yet — " + "run without the workerVPython flag to use it.") + + +@pytest.fixture() +def worker_env(monkeypatch): + """Import vpython as it comes up inside the worker, with a fake host. + + Yields ``(vpython_module, sent)`` where ``sent`` collects every JSON string + the transport handed to the host -- the boot flush lands there. + """ + sent = [] + + js = types.ModuleType('js') + setattr(js, '__trinket_vpython_send', lambda s: sent.append(s)) + + ffi = types.ModuleType('pyodide.ffi') + ffi.create_proxy = lambda f: f + pyodide_mod = types.ModuleType('pyodide') + pyodide_mod.ffi = ffi + + monkeypatch.setitem(sys.modules, 'js', js) + monkeypatch.setitem(sys.modules, 'pyodide', pyodide_mod) + monkeypatch.setitem(sys.modules, 'pyodide.ffi', ffi) + monkeypatch.setattr(sys, 'platform', 'emscripten') + + # Force a clean import so the eager boot in __init__.py actually runs -- and + # put sys.modules back exactly as found afterwards. The package that comes + # up in here is emscripten-flavoured (async sleep, raising widgets); leaving + # it cached would hand it to every later `import vpython` in the session, + # including whatever else the suite runs on darwin. + saved = {k: v for k, v in sys.modules.items() + if k == 'vpython' or k.startswith('vpython.')} + for name in saved: + del sys.modules[name] + + try: + import vpython + yield vpython, sent + finally: + for name in [m for m in list(sys.modules) + if m == 'vpython' or m.startswith('vpython.')]: + del sys.modules[name] + sys.modules.update(saved) + + +def test_transport_booted_and_sent_the_handshake(worker_env): + """The eager boot must still stand up the transport, not just the patches.""" + vp, sent = worker_env + assert sent, 'transport sent nothing to the host during boot' + assert vp.baseObj.glow is not None + + +def test_rate_returns_a_coroutine(worker_env): + vp, _ = worker_env + c = vp.rate(30) + assert asyncio.iscoroutine(c) + asyncio.run(c) + + +def test_rate_flushes_updates_to_the_host(worker_env): + """rate() is the pacing beat: awaiting one must push buffered work out.""" + vp, sent = worker_env + before = len(sent) + asyncio.run(vp.rate(60)) + assert len(sent) > before + + +def test_rate_honours_the_render_cap(worker_env): + """rate(1000) must pace at 1000 Hz but still render at most MAX_RENDERS/s. + + Upstream RateKeeper decouples the two (rate_control.py:153); without the + same cap a tight rate(1000) loop floods the page with update packages. + """ + vp, sent = worker_env + from vpython import rate_control + + calls = 40 + before = len(sent) + started = time.monotonic() + + async def burst(): + for _ in range(calls): + await vp.rate(1000) + + asyncio.run(burst()) + elapsed = time.monotonic() - started + flushes = len(sent) - before + + assert flushes < calls, 'no render cap: every rate() call flushed' + # +1 for the flush on the very first call, +1 for scheduling slop. + assert flushes <= elapsed * rate_control.MAX_RENDERS + 2 + + +# --- pacing: rate(N) must deliver N iterations/second, work included --------- +# +# A flat ``asyncio.sleep(1/maxRate)`` sleeps the whole period on top of whatever +# the student's loop body cost, so rate(60) with 10 ms of physics runs at ~37 Hz. +# Upstream subtracts the measured user-code time (_RateKeeper2.__call__'s +# ``userTime``) from the delay; these assert the same property on the worker's +# fixed-timestep version of it. +# +# Wall-clock assertions are deliberately loose: they pin the DIRECTION and rough +# magnitude, because CI timing is noisy and asyncio.sleep only ever overshoots. + + +def _spin(seconds): + """Burn CPU without yielding — synchronous user code, as a student writes it. + + ``asyncio.sleep`` would be a yield point, which is exactly what these tests + must not hand the loop for free. + """ + end = time.monotonic() + seconds + while time.monotonic() < end: + pass + + +def test_rate_paces_at_the_target_with_negligible_user_code(worker_env): + """No regression: an empty loop at rate(N) still takes ~iterations/N.""" + vp, _ = worker_env + iterations, target = 10, 50 + ideal = iterations / target # 0.20 s + + async def loop(): + for _ in range(iterations): + await vp.rate(target) + + started = time.monotonic() + asyncio.run(loop()) + elapsed = time.monotonic() - started + + # Lower bound is one period short of ideal: the first call has no previous + # return to measure a remainder from and so only yields (see _async_rate). + assert elapsed > ideal * 0.7, 'rate(%d) did not pace at all' % target + assert elapsed < ideal * 1.6, 'rate(%d) paced far slower than target' % target + + +def test_rate_subtracts_user_code_time_from_the_period(worker_env): + """THE FIX: work inside the period must not be added on top of it. + + rate(50) is a 20 ms period; with 10 ms of user code per iteration the loop + must still take ~20 ms per iteration, not 30. Ten iterations: 0.2 s, not 0.3. + """ + vp, _ = worker_env + iterations, target, work = 10, 50, 0.010 + ideal = iterations / target # 0.20 s — work absorbed + flat = iterations * (1.0 / target + work) # 0.30 s — work added on + + async def loop(): + for _ in range(iterations): + await vp.rate(target) + _spin(work) + + started = time.monotonic() + asyncio.run(loop()) + elapsed = time.monotonic() - started + + assert elapsed < (ideal + flat) / 2, ( + 'rate(%d) with %.0f ms of user code took %.3f s for %d iterations; ' + 'flat-sleep behaviour is ~%.3f s, compensated is ~%.3f s' + % (target, work * 1000, elapsed, iterations, flat, ideal)) + # The other side of it, or "compensation" that just stopped sleeping whenever + # the body did any work at all would pass the assertion above. Subtracting + # the body's cost must not turn rate(N) into a free-running loop. + assert elapsed > ideal * 0.7, ( + 'rate(%d) with user code ran at %.1f Hz — it stopped pacing rather than ' + 'compensating' % (target, iterations / elapsed)) + + +def test_rate_still_yields_when_user_code_overruns_the_period(worker_env): + """Safety: over-period work must not turn rate() into a non-yielding return. + + What that costs was measured in a browser, not reasoned about, and it is + narrower than it sounds. The scene keeps animating: the flush above is + synchronous, so outbound updates go out without any yield. Stop keeps + working: that is worker.terminate() on the page side, needing nothing from + this thread. The one thing that breaks is INBOUND — the host delivers + browser events by CALLING the transport's dispatch, and that call only gets + a turn when the running coroutine gives one up, so scene.bind handlers and + mouse picks silently never fire. + + That is why this test asserts a bystander coroutine gets to run rather than + anything about output: yielding is the property, and in CPython a + co-scheduled task is the only visible consequence of it. The browser half + (a click handler firing during an over-period loop) lives in trinket's + worker-vpython.spec.js; both fail on the same mutation. + """ + vp, _ = worker_env + calls = 5 + bystander_runs = [] + + async def bystander(): + while True: + bystander_runs.append(time.monotonic()) + await asyncio.sleep(0) + + async def loop(): + task = asyncio.ensure_future(bystander()) + await asyncio.sleep(0) # let it reach its first await + before = len(bystander_runs) + for _ in range(calls): + await vp.rate(1000) # 1 ms period... + _spin(0.005) # ...and 5 ms of user code + gained = len(bystander_runs) - before + task.cancel() + return gained + + gained = asyncio.run(loop()) + assert gained >= calls, ( + 'rate() yielded %d times in %d over-period calls — a loop that stops ' + 'yielding starves the event loop, so the host never gets to deliver a ' + 'browser event: the scene still animates and Stop still works, but ' + 'scene.bind handlers and mouse picks silently never fire' + % (gained, calls)) + + +def test_rate_does_not_accumulate_debt_after_an_overrun(worker_env): + """No catch-up burst: falling behind must not buy zero-sleep iterations. + + A deadline advanced by += period would owe five periods after a 100 ms + stall and then rush the next five calls through instantly. + """ + vp, _ = worker_env + target, catchup = 50, 5 + period = 1.0 / target + + async def loop(): + await vp.rate(target) # arm the timestep + _spin(period * 5) # fall five periods behind + await vp.rate(target) # already late: no sleep owed + started = time.monotonic() + for _ in range(catchup): + await vp.rate(target) + return time.monotonic() - started + + elapsed = asyncio.run(loop()) + assert elapsed > period * catchup * 0.7, ( + '%d calls to rate(%d) after an overrun took %.3f s — the loop burst ' + 'through them repaying debt' % (catchup, target, elapsed)) + + +def test_rate_recomputes_the_period_when_maxrate_changes(worker_env): + """maxRate may differ call to call; the period in force is the current one.""" + vp, _ = worker_env + + async def loop(): + await vp.rate(1000) # arm with a 1 ms period + started = time.monotonic() + await vp.rate(10) # 100 ms period, effective NOW + return time.monotonic() - started + + elapsed = asyncio.run(loop()) + assert elapsed > 0.05, ( + 'rate(10) after rate(1000) slept %.3f s — it reused the old period' + % elapsed) + + +@pytest.mark.parametrize('bad', [0, -1]) +def test_rate_rejects_values_below_one(worker_env, bad): + """Parity with _RateKeeper2.__call__ -- rate(0) raises, it does not clamp.""" + vp, _ = worker_env + with pytest.raises(ValueError, match='greater than or equal to 1'): + vp.rate(bad) + + +def test_sleep_returns_a_coroutine(worker_env): + vp, _ = worker_env + c = vp.sleep(0.01) + assert asyncio.iscoroutine(c) + asyncio.run(c) + + +def test_sleep_flushes_updates_to_the_host(worker_env): + """sleep() is a pacing call too: the first one must push buffered work out.""" + vp, sent = worker_env + before = len(sent) + asyncio.run(vp.sleep(0.001)) + assert len(sent) > before + + +def test_sleep_honours_the_same_render_cap_as_rate(worker_env): + """`while True: sleep(0.001)` must not flood the page. + + rate() was capped; sleep() sat next to it flushing on every call, which is + ~1000 packages/second for a shape a beginner reaches by accident. Both now + share one gate, so this asserts the same property as + test_rate_honours_the_render_cap. + """ + vp, sent = worker_env + from vpython import rate_control + + calls = 40 + before = len(sent) + started = time.monotonic() + + async def burst(): + for _ in range(calls): + await vp.sleep(0.001) + + asyncio.run(burst()) + elapsed = time.monotonic() - started + flushes = len(sent) - before + + assert flushes < calls, 'no render cap: every sleep() flushed' + assert flushes <= elapsed * rate_control.MAX_RENDERS + 2 + + +def test_rate_and_sleep_share_one_flush_gate(worker_env): + """One cap for the pair, not one each — a loop mixing them still obeys it.""" + vp, sent = worker_env + from vpython import rate_control + + calls = 40 + before = len(sent) + started = time.monotonic() + + async def burst(): + for _ in range(calls): + await vp.rate(1000) + await vp.sleep(0.001) + + asyncio.run(burst()) + elapsed = time.monotonic() - started + flushes = len(sent) - before + + assert flushes <= elapsed * rate_control.MAX_RENDERS + 2 + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_pause_raises_with_the_message(worker_env): + vp, _ = worker_env + cv = object.__new__(vp.canvas) # no full construction needed + with pytest.raises(NotImplementedError, match="scene.pause"): + vp.canvas.pause(cv) + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_waitfor_raises_with_the_documented_text(worker_env): + """Exact wording -- the Task 11 browser assertion matches on it.""" + vp, _ = worker_env + cv = object.__new__(vp.canvas) + with pytest.raises(NotImplementedError) as exc: + vp.canvas.waitfor(cv, 'draw_complete') + assert str(exc.value) == 'scene.waitfor' + DEFERRAL_SUFFIX + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_widgets_raise(worker_env): + vp, _ = worker_env + with pytest.raises(NotImplementedError, match="widgets"): + vp.button(text='go', bind=lambda: None) + + +# menu reads its own `choices` before delegating to controls.setup, so each +# widget gets the minimum kwargs that reach the shared code path. +WIDGETS = [ + ('button', {'text': 'go'}), + ('checkbox', {'text': 'on'}), + ('radio', {'text': 'pick'}), + ('winput', {}), + ('menu', {'choices': ['a', 'b']}), + ('slider', {}), +] + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +@pytest.mark.parametrize('name,kwargs', WIDGETS, ids=[w[0] for w in WIDGETS]) +def test_every_widget_class_defers(worker_env, name, kwargs): + """All six share controls.setup, so one patch has to cover all six.""" + vp, _ = worker_env + with pytest.raises(NotImplementedError, match="widgets"): + getattr(vp, name)(bind=lambda: None, **kwargs) + + +# --- the synchronous barriers that used to deadlock silently ---------------- +# +# Each of these waits for a browser reply that, in a worker, can only be +# delivered by the very thread doing the waiting. Before they were patched they +# hung — at 100% CPU, because `_wait` polls with `rate(30)` and rate is now a +# coroutine factory that never sleeps when called from synchronous library code. +# Decision V5: a deferral must be LOUD. These assert the noise. + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_compound_defers(worker_env): + vp, _ = worker_env + with pytest.raises(NotImplementedError) as exc: + vp.compound([]) + assert str(exc.value) == 'compound' + DEFERRAL_SUFFIX + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_text_defers(worker_env): + vp, _ = worker_env + with pytest.raises(NotImplementedError) as exc: + vp.text(text='hello') + assert str(exc.value) == 'text' + DEFERRAL_SUFFIX + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_extrusion_defers(worker_env): + vp, _ = worker_env + with pytest.raises(NotImplementedError) as exc: + vp.extrusion(path=[vp.vec(0, 0, 0), vp.vec(0, 0, -1)], + shape=vp.shapes.circle(radius=1)) + assert str(exc.value) == 'extrusion' + DEFERRAL_SUFFIX + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_clone_defers(worker_env): + """Patched on standardAttributes, so every drawable object is covered. + + clone() is the intermittent one: it spins only `while not baseObj.empty()`, + so whether it hangs depends on where the last flush fell. Raising always is + the point — a construct that deadlocks one run in three is harder to + diagnose than one that deadlocks every time. + """ + vp, _ = worker_env + from vpython import vpython as _vp + ball = object.__new__(vp.sphere) # no wire traffic needed + with pytest.raises(NotImplementedError) as exc: + _vp.standardAttributes.clone(ball) + assert str(exc.value) == 'obj.clone' + DEFERRAL_SUFFIX + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_mouse_pick_defers(worker_env): + """A property, so the deferral has to fire on ATTRIBUTE ACCESS, not a call.""" + vp, _ = worker_env + with pytest.raises(NotImplementedError) as exc: + vp.scene.mouse.pick + assert str(exc.value) == 'scene.mouse.pick' + DEFERRAL_SUFFIX + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_the_module_level_waiter_defers_as_a_backstop(worker_env): + """text/extrusion/pick are every caller today; a future one must not hang.""" + vp, _ = worker_env + from vpython import vpython as _vp + with pytest.raises(NotImplementedError, match='waiting for a scene event'): + _vp._wait(None) + + +def test_the_fixture_leaves_no_emscripten_build_cached(): + """Runs last on purpose: every test above used worker_env. + + If the emscripten-booted package were still in sys.modules, the next + `import vpython` anywhere in the session -- on darwin, in some other test + file -- would silently get async sleep, raising widgets and a transport + talking to a fake `js`. Asserting the cache is clean is both the check and + the guarantee that a later import rebuilds the real thing; actually + importing vpython here is not an option, since on a desktop platform that + starts the no_notebook http server and opens a browser tab. + """ + leaked = sorted(m for m in sys.modules if m == 'vpython' or m.startswith('vpython.')) + assert leaked == [], 'worker_env leaked modules into sys.modules: %s' % leaked diff --git a/vpython/__init__.py b/vpython/__init__.py index e46c197..5bdfe1b 100644 --- a/vpython/__init__.py +++ b/vpython/__init__.py @@ -1,6 +1,7 @@ # importlib.metadata, not pkg_resources: fresh Python 3.12+ environments no # longer ship setuptools, so `import pkg_resources` raises ModuleNotFoundError -# the moment `import vpython` runs (caught by CI's macos-3.12 leg). +# the moment `import vpython` runs (caught by CI's macos-3.12 leg). The same +# import is simply absent on Pyodide/wasm, so this also unblocks wasm targets. from importlib.metadata import version as _dist_version, PackageNotFoundError from .gs_version import glowscript_version @@ -24,6 +25,13 @@ from .vpython import canvas +import sys as _sys +if _sys.platform == 'emscripten': + # Boot the wasm transport EAGERLY: its patches must land before the + # star-imports below bind rate/sleep into the package namespace. + from . import trinket_worker as _tw +del _sys + # Need to initialize canvas before user does anything and before scene = canvas() diff --git a/vpython/trinket_worker.py b/vpython/trinket_worker.py new file mode 100644 index 0000000..336df54 --- /dev/null +++ b/vpython/trinket_worker.py @@ -0,0 +1,246 @@ +"""Transport for Pyodide/wasm hosts — e.g. trinket's #108 Web Worker. + +The notebook transport talks over a Jupyter Comm; the standalone transport +stands up an ``http.server`` plus autobahn websockets in threads. Neither +exists inside a Web Worker: there is no Jupyter, no server, and no threads. +What a worker does have is a host JS environment with ``postMessage`` — so +here the host supplies the pipe and this module supplies the seam. + +The contract with the host, kept deliberately tiny: + +* Before ``import vpython``, the host defines ``__trinket_vpython_send`` on + the JS global scope: a function of one argument, a JSON string. Every + outbound update package (and the bare ``"trigger"`` handshake) goes through + it. What the host does with the string — ``postMessage`` it to a page, hand + it to a renderer — is its business. + +* This module sets ``__trinket_vpython_dispatch`` on the JS global scope: a + function of one argument, a JSON **array** of browser events in glowcomm's + wire format. The host calls it whenever events arrive. Each call processes + the events and then flushes buffered updates back out through + ``__trinket_vpython_send`` — the same request/reply rhythm the websocket + transport uses, with the host (ultimately the browser's ~33 ms + ``canvas_update`` timer) setting the pace. + +Like the other transports, importing this module IS the setup; it ends by +binding the module-global ``sender``, patching vpython's blocking surface (see +``apply_worker_patches``) and starting the update ping-pong. +""" + +import json + +import js +from pyodide.ffi import create_proxy + +from .vpython import GlowWidget, baseObj +from . import vpython as _protocol + + +def _send(msg): + """Ship one update package (or the 'trigger' handshake) to the host. + + ``trigger()`` hands us either the bare string ``'trigger'`` or the + ``{cmds, methods, attrs}`` dict from ``baseObj.package``. Encode + uniformly; the receiving side re-parses. + """ + js.__trinket_vpython_send(json.dumps(msg)) + + +def _dispatch(events_json): + """Process a JSON array of browser events, then flush updates back. + + Mirrors the websocket transports: events go one at a time to + ``handle_msg`` (bound-event handlers rely on that), and every inbound + message — including a bare trigger with no events — is answered with a + flush. ``_isnotebook`` is False here, so ``handle_msg`` does not trigger + by itself; the flush below is that reply. + """ + events = json.loads(events_json) if events_json else [] + for evt in events: + if isinstance(evt, dict) and 'trigger' in evt: + continue # pacing only; the flush below answers it + baseObj.glow.handle_msg({'content': {'data': [evt]}}) + baseObj.trigger() + + +_DEFER = ("{name} is not supported in the worker runtime yet — " + "run without the workerVPython flag to use it.") + + +def apply_worker_patches(): + """Make vpython's blocking surface cooperative (or loudly absent) for a + single-threaded wasm host. Called from the transport bootstrap; separated so + plain-CPython tests can exercise the patches without a live transport. + + Every construct patched here spins the one and only thread while waiting on + the browser — which, in a worker, is the thread the browser's replies have + to arrive on. Waiting is therefore a deadlock, so each is either made + awaitable (``rate``, ``sleep``: the async transform inserts the ``await``) + or made to fail loudly rather than hang. + """ + import asyncio + import time + from . import rate_control + from . import vpython as _vp + + # Upstream RateKeeper decouples the rate() call frequency from the render + # frequency: however often the loop asks, at most MAX_RENDERS renders go out + # per second (rate_control.py:153). Keep that contract — rate(1000) must + # still pace at 1000 Hz, but it must not flush 1000 packages/second at the + # page. Flushes we skip are not lost: the updates stay buffered and go out + # with the next one. + _render_period = 1.0 / rate_control.MAX_RENDERS + _last_flush = [float('-inf')] + + def _flush_if_due(): + """Flush buffered updates, at most MAX_RENDERS times a second. + + Shared by rate() and sleep(): both are pacing calls in a student's loop, + and both must therefore obey the same render cap. Without it, + ``while True: sleep(0.001)`` — a shape a beginner reaches by accident — + floods the page with ~1000 update packages a second, each one a + postMessage plus a handle() on the main thread. + """ + now = time.monotonic() + if now - _last_flush[0] >= _render_period: + _last_flush[0] = now + baseObj.trigger() # flush buffered updates + + # rate(N) means "N iterations per second", not "sleep 1/N between them". + # Upstream measures the time spent in user code between rate() returns + # (_RateKeeper2.__call__'s `userTime`, rate_control.py:173) and subtracts it + # from the delay. A flat sleep does not: rate(60) with 10 ms of physics per + # iteration runs at ~37 Hz, visibly slower than the same program elsewhere. + # So remember when the last call RETURNED (i.e. when the user's iteration + # began) and sleep only the remainder of the period. + _last_return = [None] + + async def _async_rate(maxRate): + _flush_if_due() + # Recomputed every call: maxRate may change between calls, and the + # period in force is the current one — rate(10) after a rate(1000) loop + # must slow down on THIS call, not the next. + period = 1.0 / float(maxRate) + last = _last_return[0] + # FIRST call: nothing has been timed yet, so no part of a period is + # owed; yield and return, as upstream's `count == 1` branch does after + # callInteract(). It costs one period once per program and gets the + # flush we just issued onto the page without an added wait. + remaining = 0.0 if last is None else (last + period) - time.monotonic() + # Always await, even at zero. When user code overruns the period the + # remainder is negative and there is nothing left to wait for, which + # makes an early `return` here look free. It is not, and what it costs + # was established by building that version and running it in a browser + # (see trinket's worker-vpython.spec.js) rather than by reasoning: + # + # * The scene KEEPS ANIMATING. _flush_if_due() above is synchronous — + # it postMessages without yielding — so outbound updates go out + # exactly as before and the picture moves normally. + # * Stop KEEPS WORKING. That is worker.terminate() on the page side, + # unconditional, needing nothing from this thread (trinket's + # worker-client.js says so at the top). + # * INBOUND EVENTS SILENTLY STOP ARRIVING. The host delivers them by + # CALLING __trinket_vpython_dispatch, and that call only gets a turn + # when the running coroutine gives one up. Nothing else in a worker + # run does. So scene.bind handlers, mouse picks and the rest simply + # never fire. + # + # Inbound dispatch is the ONLY half that needs this yield, which is what + # makes losing it so hard to spot: a scene that animates, a Stop button + # that works, and mouse events that quietly do nothing. + # + # asyncio.sleep(0) yields. Clamping at 0 also means a loop that falls + # behind simply stays behind rather than banking debt and then bursting + # through a batch of zero-sleep iterations to catch up. + await asyncio.sleep(remaining if remaining > 0.0 else 0.0) + # Anchor on the ACTUAL return, not on `last + period`: that is what makes + # the clamp above debt-free. + _last_return[0] = time.monotonic() + + def _rate(self, maxRate=100): + # Validate SYNCHRONOUSLY, before building the coroutine: parity with + # _RateKeeper2.__call__ (rate_control.py:265), where rate(0) raises at + # the call site. Inside the coroutine the error would surface only on + # await — and not at all if the program never awaits. + if maxRate < 1: + raise ValueError("rate value must be greater than or equal to 1") + return _async_rate(maxRate) + + # rate is a module-level INSTANCE bound into user namespaces at import time + # (rate_control.py: `rate = _RateKeeper2(...)`); patching the class __call__ + # changes the already-bound object everywhere. + rate_control._RateKeeper2.__call__ = _rate + + async def _async_sleep(dt): + _flush_if_due() + await asyncio.sleep(dt) + _vp.sleep = _async_sleep # BEFORE __init__'s star-import binds it + + def _deferred(name): + def _raise(*args, **kwargs): + raise NotImplementedError(_DEFER.format(name=name)) + return _raise + + _vp.canvas.pause = _deferred('scene.pause') + _vp.canvas.waitfor = _deferred('scene.waitfor') + # button/checkbox/radio/winput/menu/slider all subclass `controls`, but each + # defines its own __init__ that calls `controls.setup` — setup, not + # __init__, is the one shared entry point, so that is what gets patched. + _vp.controls.setup = _deferred('widgets (button/slider/menu/checkbox/radio/winput)') + + # The OTHER synchronous barriers — the ones that look like ordinary drawing + # rather than like waiting, which is exactly why they have to be loud. + # + # Each of these spins the one and only thread until the browser answers, and + # in a worker the browser's answer arrives *on that thread* (via + # __trinket_vpython_dispatch). So the wait can never end: they deadlock. + # Worse, they do it at 100% CPU — `_wait()` polls with `rate(30)`, and rate + # is now a coroutine factory, so called from synchronous library code it + # builds a coroutine and throws it away without ever sleeping. + # + # compound(...) vpython.py: `while not baseObj.sent: time.sleep(.001)` + # text(...) vpython.py: _wait(canvas) — measures the glyph run + # extrusion(...) vpython.py: _wait(canvas) — measures the swept shape + # scene.mouse.pick vpython.py: _wait(canvas) — waits for setpick + # obj.clone() vpython.py: `while not baseObj.empty(): rate(60)` + # + # A student who hits one of these gets no scene, no error and a Stop button + # that works — the silent no-op decision V5 exists to forbid. Making them + # raise costs the feature and keeps the diagnosis. (`clone` is the one that + # only *sometimes* hangs, depending on whether the buffer happens to be + # empty; a construct that deadlocks intermittently is worse than one that + # always does, not better.) + _vp.compound.__init__ = _deferred('compound') + _vp.text.__init__ = _deferred('text') + _vp.extrusion.__init__ = _deferred('extrusion') + _vp.standardAttributes.clone = _deferred('obj.clone') + # pick is a property; keep the original setter, which already refuses. + _vp.Mouse.pick = property(_deferred('scene.mouse.pick'), _vp.Mouse.pick.fset) + # Backstop for any other caller of the module-level waiter: the four above + # are every one in the package today, but a future one would otherwise hang + # silently rather than say so. + _vp._wait = _deferred('waiting for a scene event') + + +# GlowWidget() records itself as baseObj.glow. Outside a notebook it sets the +# module-global sender to None, so ours is installed after it. +GlowWidget() +_protocol.sender = _send + +js.__trinket_vpython_dispatch = create_proxy(_dispatch) + +# Patch before the first flush — and, via __init__'s eager boot, before the +# star-imports bind rate/sleep into the package namespace. +apply_worker_patches() + +# Start the ping-pong exactly as with_notebook does. Because __init__.py boots +# this transport EAGERLY — before `scene = canvas()` — the buffer is empty here, +# so this first trigger() is the bare 'trigger' handshake rather than a package; +# the scene canvas and its lights are constructed just afterwards and flush on +# the next trigger (the first rate() call, or the host's next dispatch ping). +# What matters either way is that it sets baseObj.sent, which appendcmd() and +# addmethod() spin on. +baseObj.trigger() + +# Dummy name to import, matching the other transports. +_ = None diff --git a/vpython/vpython.py b/vpython/vpython.py index 1fa951d..c8d02cb 100644 --- a/vpython/vpython.py +++ b/vpython/vpython.py @@ -263,6 +263,10 @@ def __init__(self, **kwargs): baseObj._canvas_constructing): if _isnotebook: from .with_notebook import _ + elif sys.platform == 'emscripten': + # Pyodide/wasm (e.g. a Web Worker): no Jupyter, no servers, no + # threads. The host JS environment supplies the pipe instead. + from .trinket_worker import _ else: from .no_notebook import _ baseObj._view_constructed = True diff --git a/vpython/vpython_libraries/glowcomm_host.js b/vpython/vpython_libraries/glowcomm_host.js new file mode 100644 index 0000000..58ffc43 --- /dev/null +++ b/vpython/vpython_libraries/glowcomm_host.js @@ -0,0 +1,1015 @@ +// 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 0) { + if (dosend) evt = evt.concat(output_sliders); + else evt = output_sliders; + dosend = true; + } + if (dosend) return evt; + else return null; + } + + // glowcomm.js send() (:150-170) — the WHAT of one pacing tick. Upstream's + // send() also owned the WHEN (it re-armed its own setTimeout); that half is + // the host's, which is why this is a method it calls rather than a loop. + // + // Note the fallback. An empty tick still sends {event:'update_canvas', + // trigger:1}: the transport treats a 'trigger' entry as pacing and processes + // nothing, but the MESSAGE is the request half of a request/reply — it is + // what makes the kernel flush the updates it has buffered. A tick that sent + // nothing would stall a program that never calls rate(). + function tick() { + last_tick = now_ms(); + var out = drain(); + if (out.length === 0) out = [{event:'update_canvas', 'trigger':1}]; + if (send) send(out); + return out; + } + + // One tick of the host's clock that is NOT the request half of a + // request/reply. When the PROGRAM is already flushing on its own — vpython's + // rate() triggers a render at up to MAX_RENDERS a second from inside the + // animation loop — the handshake above buys nothing and costs one message per + // tick on the hottest path in the system (measured: ~30 host messages a + // second on top of the ~85 the loop was already sending). The half of a tick + // that is still needed is the browser's own half: queued events, and the + // camera/mouse state only update_canvas() knows. So: drain, send if there is + // anything, and otherwise stay quiet. + // + // last_tick is still stamped, because the host's clock IS running: events + // must keep batching into the next tick rather than each paying a round trip. + function poll() { + last_tick = now_ms(); + var out = drain(); + if (out.length > 0 && send) send(out); + return out; + } + + // --------------------------------------------------------------------------- + // The wire format. Ported verbatim from glowcomm.js. + // --------------------------------------------------------------------------- + + // attrs are X in {'a': '23X....'} available: none + var attrs = {'a':'pos', 'b':'up', 'c':'color', 'd':'trail_color', // don't use single and double quotes; available: comma, but maybe that would cause trouble + 'e':'ambient', 'f':'axis', 'g':'size', 'h':'origin', 'i':'textcolor', + 'j':'direction', 'k':'linecolor', 'l':'bumpaxis', 'm':'dot_color', + 'n':'foreground', 'o':'background', 'p':'ray', 'E':'center', '#':'forward', '+':'resizable', + + // scalar attributes + 'q':'graph', 'r':'canvas', 's':'trail_radius', + 't':'visible', 'u':'opacity', 'v':'shininess', 'w':'emissive', + 'x':'make_trail', 'y':'trail_type', 'z':'interval', 'A':'pps', 'B':'retain', + 'C':'red', 'D':'green', 'E':'ccw', 'F':'blue', 'G':'length', 'H':'width', 'I':'height', 'J':'radius', + 'K':'thickness', 'L':'shaftwidth', 'M':'headwidth', 'N':'headlength', 'O':'pickable', + 'P':'coils', 'Q':'xoffset', 'R':'yoffset', + 'S':'border', 'T':'line', 'U':'box', 'V':'space', 'W':'linewidth', + 'X':'xmin', 'Y':'xmax', 'Z':'ymin', '`':'ymax', + '~':'ctrl', '!':'shift', '@':'alt', + + // text attributes: + '$':'text', '%':'align', '^':'caption', + '-':'fast','&':'title', '*':'xtitle', '(':'ytitle', + + // Miscellany: + ')':'lights', '_':'objects', '=':'bind', + '[':'pixel_pos', ']':'texpos', + '{':'v0', '}':'v1', ';':'v2', ':':'v3', '<':'vs', '>':'type', + '?':'font', '/':'texture'}; + + // attrsb are X in {'b': '23X....'}; ran out of easily typable one-character codes + var attrsb = {'a':'userzoom', 'b':'userspin', 'c':'range', 'd':'autoscale', 'e':'fov', + 'f':'normal', 'g':'data', 'h':'checked', 'i':'disabled', 'j':'selected', + 'k':'vertical', 'l':'min', 'm':'max', 'n':'step', 'o':'value', + 'p':'left', 'q':'right', 'r':'top', 's':'bottom', 't':'_cloneid', + 'u':'logx', 'v':'logy', 'w':'dot', 'x':'dot_radius', + 'y':'markers', 'z':'legend', 'A':'label','B':'delta', 'C':'marker_color', + 'D':'size_units', 'E':'userpan', 'F':'scroll', 'G':'choices', 'H':'depth', 'I':'round', + 'J':'name', 'K':'offset', 'L':'attach_idx', 'M':'ccw' + }; + + // methods are X in {'m': '23X....'} + var methods = {'a':'select', 'b':'pos', 'c':'start', 'd':'stop', 'f':'clear', // unused eghijklmnopvxyzCDFAB + 'q':'plot', 's':'add_to_trail', + 't':'follow', 'u':'_attach_arrow', 'w':'clear_trail', + 'G':'bind', 'H':'unbind', 'I':'waitfor', 'J':'pause', 'K':'pick', + 'M':'delete', 'N':'capture'}; + + var vecattrs = ['pos', 'up', 'color', 'trail_color', 'axis', 'size', 'origin', '_attach_arrow', + 'direction', 'linecolor', 'bumpaxis', 'dot_color', 'ambient', 'add_to_trail', 'textcolor', + 'foreground', 'background', 'ray', 'ambient', 'center', 'forward', 'normal', + 'marker_color']; + + var textattrs = ['text', 'align', 'caption', 'title', 'title_align', 'xtitle', 'ytitle', 'selected', 'capture', + 'label', 'append_to_caption', 'append_to_title', 'bind', 'unbind', 'pause', 'choices']; + + // patt gets idx and attr code; vpatt gets x,y,z of a vector + var patt = /(\d+)(.)(.*)/; + var vpatt = /([^,]*),([^,]*),(.*)/; + var quadpatt = /([^,]*),([^,]*),(.*)/; + var plotpatt = /([^,]*),([^,]*)/; + + function decode(data) { + // data is {'cmds':list of constructors, 'attrs': list of attributes and (time-ordered) methods + // Attribute and method lists: [ 'XiK0.0,1.0,1.0', .....] X is a or b (attributes) or m (methods) + // i is object index, K is a key to an attribute or method in the dictionaries above + var output = [], s, m, idx, attr, val, datatype, out, i, as, ms; + var as = []; + var ms = []; + + if ('attrs' in data) { + var c = data['attrs']; + for (i=0; i -1) { + val = m[3].match(vpatt); + val = glow.vec(Number(val[1]), Number(val[2]), Number(val[3])); + } else if (attr == 'vs') { + var vs; + val = m[3].match(quadpatt); + if (val === null) { + val = m[3].match(vpatt); + vs = [Number(val[1]), Number(val[2]), Number(val[3])]; + } else { + vs = [Number(val[1]), Number(val[2]), Number(val[3]), Number(val[4])]; + } + } else if (textattrs.indexOf(attr) > -1) { + if (attr == 'choices') { // menu choices are wrapped in a list + val = m[3].slice(2, -2).split("', '"); // choices separated by ', ' + } else { + // '\n' doesn't survive JSON transmission, so in vpython.py we replace '\n' with '
' + val = m[3].replace(/
/g, "\n"); + } + } else if (attr == 'rotate') { // angle,x,y,z,x,y,z + var temp = m[3]; + val = []; + var first = temp.match(/([^,]*)/); + val.push(Number(first[1])); + var v1 = temp.slice(first[1].length+1); + m = v1.match(/([^,]*),([^,]*),([^,]*)/); + val.push(glow.vec(Number(m[1]), Number(m[2]), Number(m[3]))); + var v2 = temp.slice(first[1].length + 1 + m[0].length + 1); + m = v2.match(vpatt); + val.push(glow.vec(Number(m[1]), Number(m[2]), Number(m[3]))); + } else if (attr == 'plot' || attr == 'data') { + val = []; + var start = m[1].length+1; // start of arguments + while (true) { + m = s.slice(start).match(plotpatt); + val.push([ Number(m[1]), Number(m[2]) ]); + start += m[1].length+m[2].length+2; + if (start > s.length) break; + } + } else if (attr == 'waitfor' || attr == 'pause' || attr == 'delete') { + val = m[3]; + } else if (attr == 'follow') { + if (m[3] == 'None') val = null; + else val = Number(m[3]); + } else val = Number(m[3]); + out = {'idx':idx, 'attr':attr, 'val':val}; + if (datatype == 'attr') as.push(out); + else ms.push(out); + } + } + if (as.length > 0) data['attrs'] = as; + else data['attrs'] = []; + if (ms.length > 0) data['methods'] = ms; + return data; + } + + function fix_location(cfgx) { + if ('location' in cfgx) { + var loc = cfgx['location']; + var id = loc[0]; + if (id == -1) { + cfgx['pos'] = glow.print_anchor; // this doesn't work; throw an error in vpython.py + } else { + var cvs = glowObjs[id]; + var where = loc[1]; + if (where === 1) cfgx['pos'] = cvs.title_anchor; + else cfgx['pos'] = cvs.caption_anchor; + } + delete cfgx['location']; + } + return cfgx; + } + + function o2vec3(p) { + return glow.vec(p[0], p[1], p[2]); + } + + function handle_cmds(dcmds) { + //console.log('CMDS') + for (var icmds=0; icmds 0) { + for (var k = 0; k < len4; k++) { + objects[k] = glowObjs[val[k]]; + } + } + } else if (attr == "lights") { + if (val == 'empty_list') val = []; + cfg[attr] = val; + } else { + cfg[attr] = val; + } + } + if (!construct) { // commands such as "center" (for a canvas) + var parametric = ['splice', 'modify']; + var val = cfg[attr]; + if (attr == 'append_to_caption' || attr == 'append_to_title' ) glowObjs[idx][attr](val); + else if (method !== null) { + var npargs = 0; + var info; + if (parametric.indexOf(method) > -1) { + npargs = val.length - 1; + info = val[npargs]; // a list of dictionaries + } else { + info = val; + } + for (var j=0; j < info.length; j++) { + var dj = info[j]; + for (var a in dj) { + if (dj[a] instanceof Array) dj[a] = o2vec3(dj[a]); + } + } + if ( npargs === 0 ) { + glowObjs[idx][method](info); + } else if ( method === 'modify' ) { // 1 parameter + glowObjs[idx][method](val[0], info[0]); + } else if ( method === 'splice' ) { // 2 parameters + glowObjs[idx][method](val[0], val[1], info); + } else { + throw new Error('Too many parameters in '+method); + } + } else glowObjs[idx][attr] = val; + continue; + } + // creating the objects + cfg.idx = idx; // reinsert idx, having looped thru all other attributes + // triangle and quad objects should not have a canvas attribute; canvas is provided in the vertex objectsE + if ((obj == 'triangle' || obj == 'quad') && cfg.canvas !== undefined) delete cfg.canvas; + switch (obj) { + case 'box': {glowObjs[idx] = glow.box(cfg); break} + case 'sphere': {glowObjs[idx] = glow.sphere(cfg); break} + case 'simple_sphere': {glowObjs[idx] = glow.simple_sphere(cfg); break} + case 'arrow': {glowObjs[idx] = glow.arrow(cfg); break} + case 'cone': {glowObjs[idx] = glow.cone(cfg); break} + case 'cylinder': {glowObjs[idx] = glow.cylinder(cfg); break} + case 'helix': {glowObjs[idx] = glow.helix(cfg); break} + case 'pyramid': {glowObjs[idx] = glow.pyramid(cfg); break} + case 'ring': {glowObjs[idx] = glow.ring(cfg); break} + case 'curve': {glowObjs[idx] = glow.curve(cfg); break} + case 'points': {glowObjs[idx] = glow.points(cfg); break} + case 'vertex': {glowObjs[idx] = glow.vertex(cfg); break} + case 'triangle': {glowObjs[idx] = glow.triangle(cfg); break} + case 'quad': {glowObjs[idx] = glow.quad(cfg); break} + case 'label': {glowObjs[idx] = glow.label(cfg); break} + case 'ellipsoid': {glowObjs[idx] = glow.sphere(cfg); break} + case 'graph': { // currently graph gives an error for non-fundamental arguments + delete cfg.idx; + glowObjs[idx] = glow.graph(cfg); + break + } + case 'gcurve': { // currently gcurve give an error for non-fundamental arguments + delete cfg.idx; + glowObjs[idx] = glow.gcurve(cfg); + break + } + case 'gdots': { // currently gdots give an error for non-fundamental arguments + delete cfg.idx; + glowObjs[idx] = glow.gdots(cfg); + break + } + case 'gvbars': { // currently gvbars give an error for non-fundamental arguments + delete cfg.idx; + glowObjs[idx] = glow.gvbars(cfg); + break + } + case 'ghbars': { // currently ghbars give an error for non-fundamental arguments + delete cfg.idx; + glowObjs[idx] = glow.ghbars(cfg); + break + } + case 'compound': { + if (cfg._cloneid !== undefined) { + var idoriginal = cfg._cloneid; + delete cfg._cloneid; + glowObjs[idx] = glowObjs[idoriginal].clone(cfg); + } else { + var obj = glowObjs[idx] = glow.compound(objects, cfg); + // Return computed compound pos and size to Python + send_compound(obj.canvas['idx'], obj.pos, obj.size, obj.up); + } + break + } + case 'extrusion': { + var obj = glowObjs[idx] = glow.extrusion(cfg); + // Return computed compound pos and size to Python + send_compound(obj.canvas['idx'], obj.pos, obj.size, obj.up); + break + } + case 'text': { + if (cfg._cloneid !== undefined) { + var idoriginal = cfg._cloneid; + delete cfg._cloneid; + glowObjs[idx] = glowObjs[idoriginal].clone(cfg); + } else { + // Return text parameters to Python + var obj = glowObjs[idx] = glow.text(cfg); + send_compound(obj.canvas['idx'], glow.vec(obj.length, obj.descender, 0), + obj.__comp.size, obj.up); + } + break + } + case 'local_light': {glowObjs[idx] = glow.local_light(cfg); break} + case 'distant_light': {glowObjs[idx] = glow.distant_light(cfg); break} + case 'canvas': { + // glowcomm.js looked up Jupyter's '#glowscript' div here. The + // host tells us where its scene goes instead; GlowScript reads + // the mount point off the global __context. + // + // Two things matter. (1) __context is GlowScript's own scratch + // space — canvas_selected, canvas_all, print_container — so + // merge into it; replacing it wipes state belonging to canvases + // already on the page (upstream did the lookup at most once, + // this runs on every canvas cmd). (2) glow stores a *jQuery* + // object (`glowscript_container` set does $(value), and the + // print-area path calls container.css(...)), so a raw element + // has to be wrapped or the first print() throws. + if (container !== null) { + var jq = glow.$ || globalThis.$ || globalThis.jQuery; + var ctx = globalThis.__context || (globalThis.__context = {}); + if (jq) ctx.glowscript_container = jq(container); + else { + if (typeof console !== 'undefined') console.warn( + 'glowcomm_host: no jQuery found to wrap the container; ' + + 'GlowScript expects $(container) and print() will fail'); + ctx.glowscript_container = container; + } + } + glowObjs[idx] = glow.canvas(cfg); + glowObjs[idx]['idx'] = idx; + break + } + case 'attach_arrow': { + var attrs = ['pos', 'size', 'axis', 'up', 'color']; + var o = glowObjs[cfg['obj']]; + delete cfg['obj']; + var attr = cfg['attr']; + delete cfg['attr']; + var val = cfg['attrval']; + delete cfg['attrval']; + if (attrs.indexOf(attr) < 0) attr = '_attach_arrow'; + o.attr = val; + glowObjs[idx] = glow.attach_arrow( o, attr, cfg ); + break + } + case 'attach_trail': { + if ( typeof cfg['_obj'] === 'string' ) { + var o = cfg['_obj']; // the string '_func' + } else { + var o = glowObjs[cfg['_obj']]; + } + delete cfg['_obj']; + glowObjs[idx] = glow.attach_trail(o, cfg); + break + } + case 'wtext': { + cfg.objName = obj; + cfg = fix_location(cfg); + glowObjs[idx] = glow.wtext(cfg); + break + } + case 'winput': { + cfg.objName = obj; + cfg.bind = control_handler; + cfg = fix_location(cfg); + glowObjs[idx] = glow.winput(cfg); + break + } + case 'checkbox': { + cfg.objName = obj; + cfg.bind = control_handler; + cfg = fix_location(cfg); + glowObjs[idx] = glow.checkbox(cfg); + break + } + case 'radio': { + cfg.objName = obj; + cfg.bind = control_handler; + cfg = fix_location(cfg); + glowObjs[idx] = glow.radio(cfg); + break + } + case 'button': { + cfg.objName = obj; + cfg.bind = control_handler; + cfg = fix_location(cfg); + glowObjs[idx] = glow.button(cfg); + break + } + case 'slider': { + cfg.objName = obj; + cfg.bind = control_handler; + cfg = fix_location(cfg); + glowObjs[idx] = glow.slider(cfg); + break + } + case 'menu': { + cfg.objName = obj; + cfg.bind = control_handler; + cfg = fix_location(cfg); + glowObjs[idx] = glow.menu(cfg); + if (cfg['selected'] === 'None') { + cfg['selected'] = null; + } + break + } + default: + console.log("Unable to create object"); + } + } // end of cmds (constructors and special data) + } + + async function handle_methods(dmeth) { + //console.log('METHODS') + for (var idmeth=0; idmeth 0) { + await obj.pause(val); + } else { + await obj.pause(); + } + process_pause(); + } else if (method === 'pick') { + var p = glowObjs[val].mouse.pick(); // wait for pick render; val is canvas + var seg = null; + if (p !== null) { + if (is_a(p, glow.curve)) seg = p.segment; + p = p.idx; + } + send_pick(val, p, seg); + } else obj[method](val); + } + } + + function handle_attrs(dattrs) { + //console.log('ATTRS') + for (var idattrs=0; idattrs 0) handle_cmds(data.cmds); + if (data.methods !== undefined && data.methods.length > 0) handle_methods(data.methods); + if (data.attrs !== undefined && data.attrs.length > 0) handle_attrs(data.attrs); + } + + // Forget every object: the next scene generation reuses idx 0, 1, 2, ... + // Queued events go with them — they name idxs that no longer mean anything — + // and the canvas diff starts over, so the new scene's first tick reports its + // own state rather than a delta against the old one. + function reset() { + glowObjs = []; + waitfor_canvas = null; + waitfor_options = null; + events = []; + last_tick = -Infinity; + reset_canvas_state(); + } + + // reset(), plus a best-effort ask for the objects to take themselves off the + // page. GlowScript objects use remove(); a canvas uses delete(). + function destroy() { + for (var i = 0; i < glowObjs.length; i++) { + var o = glowObjs[i]; + if (!o) continue; + try { + if (typeof o.remove === 'function') o.remove(); + else if (typeof o['delete'] === 'function') o['delete'](); + } catch (e) { /* already gone, or not removable */ } + } + reset(); + } + + // Test-only accessor: the internal glowObjs registry (idx -> GlowScript object). + // Not part of the host contract — do not use it from page code. + function _objs() { return glowObjs; } + + return { handle: handle, tick: tick, poll: poll, pacingStopped: pacing_stopped, + reset: reset, destroy: destroy, _objs: _objs }; +} + +createGlowFrontend.version = GLOWCOMM_HOST_VERSION; + +var api = { createGlowFrontend: createGlowFrontend, version: GLOWCOMM_HOST_VERSION }; +if (typeof module !== 'undefined' && module.exports) module.exports = api; +if (typeof self !== 'undefined') self.createGlowFrontend = createGlowFrontend;