diff --git a/.gitignore b/.gitignore
index 383c36cab..2e610c7c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -143,3 +143,4 @@ docs/source/_imgui_images/
# generated by docs/source/generate_api.py on every docs build
docs/source/api/
docs/source/user_guide/event_tables.rst
+docs/source/_static/*.whl
diff --git a/docs/source/_static/_pyodide_iframe.html b/docs/source/_static/_pyodide_iframe.html
new file mode 100644
index 000000000..be0dd92b3
--- /dev/null
+++ b/docs/source/_static/_pyodide_iframe.html
@@ -0,0 +1,65 @@
+
+
+
+
+
+ Fastplotlib examples in the browser using Pyodide
+
+
+
+
+
+
+
+
+
Output:
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/source/_static/style.css b/docs/source/_static/style.css
index e69de29bb..de72de3c1 100644
--- a/docs/source/_static/style.css
+++ b/docs/source/_static/style.css
@@ -0,0 +1,5 @@
+section[id^="interactive-example"] iframe {
+ width: 100%;
+ height: 720px;
+ border: none;
+}
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 4a9cbe8ab..c47b1510b 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -50,6 +50,94 @@
"generate_api",
]
+# note this is largely copied from the pygfx PR branch: https://github.com/pygfx/pygfx/pull/1273
+# -- Build wheel so Pyodide examples can use exactly this version of fpl -----------------------------------------------------
+import subprocess
+import shutil
+
+short_version = ".".join(str(i) for i in fastplotlib.version_info[:3])
+wheel_name = f"fastplotlib-{short_version}-py3-none-any.whl"
+
+# Build the wheel
+subprocess.run([sys.executable, "-m", "build", "-nw"], cwd=ROOT_DIR)
+wheel_filename = os.path.join(ROOT_DIR, "dist", wheel_name)
+assert os.path.isfile(wheel_filename), f"{wheel_name} does not exist"
+
+# Copy into static
+# TODO: you can use --outdir on the build command directly. also use the html_static_path in this namespace
+print("Copy wheel to _static dir")
+shutil.copy(
+ wheel_filename,
+ os.path.join(ROOT_DIR, "docs", "source", "_static", wheel_name),
+)
+
+# -- Sphix Gallery -----------------------------------------------------
+
+## pyodide demos, adapted from wgpu, adapted from rendercanvas... might make sense to put this in the scaper?
+iframe_placeholder_rst = """
+.. only:: html
+
+ Interactive example
+ -------------------
+
+ Try this example in your browser using Pyodide. Might not work with all examples and all devices. Check the output and your browser's console for details.
+
+ .. raw:: html
+
+
+"""
+python_files = {}
+
+# I have a feeling this import might be really sketchy to have on CI... but hey - this is a hack for a hack
+from examples.serve_browser_examples import patch_imageio_for_pyodide
+
+
+def add_pyodide_to_examples(app):
+ if app.builder.name != "html":
+ return
+
+ gallery_dir = ROOT_DIR / "docs" / "source" / "_gallery"
+ example_files = gallery_dir.glob("**/*.py")
+
+ for py_file in example_files:
+ fname = py_file.name
+ with open(py_file, "rb") as f:
+ py = f.read().decode()
+ py = patch_imageio_for_pyodide(py)
+ if fname:
+ print("Adding Pyodide example to", fname)
+ fname_rst = py_file.with_suffix(".rst")
+ # Update rst file
+ rst = iframe_placeholder_rst.replace("example.py", fname)
+ # we likely don't want append here?
+ with open(fname_rst, "ab") as f:
+ # TODO: skip if it already ends with the placeholder? otherwise the append will keep on appending (we have to hook this into the gen_rst to skip if possible?)
+ f.write(rst.encode())
+ python_files[py_file.relative_to(gallery_dir)] = py
+
+def add_files_to_run_pyodide_examples(app, exception):
+ if app.builder.name != "html":
+ return
+
+ gallery_build_dir = os.path.join(app.outdir, "_gallery")
+
+ # Write html file that can load pyodide examples
+ with open(
+ os.path.join(ROOT_DIR, "docs", "source", "_static", "_pyodide_iframe.html"), "rb"
+ ) as f:
+ html = f.read().decode()
+ html = html.replace('"fastplotlib"', f'"../_static/{wheel_name}"')
+ with open(os.path.join(gallery_build_dir, "pyodide.html"), "wb") as f:
+ f.write(html.encode())
+
+ # Write the python files
+ for fname, py in python_files.items():
+ print("Writing", fname)
+ with open(os.path.join(gallery_build_dir, fname), "wb") as f:
+ f.write(py.encode())
+
+
+
sphinx_gallery_conf = {
"gallery_dirs": "_gallery",
"notebook_extensions": {}, # remove the download notebook button
@@ -150,3 +238,11 @@
"rendercanvas": ("https://rendercanvas.readthedocs.io/stable/", None),
# "fastplotlib": ("https://www.fastplotlib.org/", None),
}
+
+html_css_files = [
+ "style.css",
+]
+
+def setup(app):
+ app.connect("builder-inited", add_pyodide_to_examples)
+ app.connect("build-finished", add_files_to_run_pyodide_examples)
diff --git a/examples/serve_browser_examples.py b/examples/serve_browser_examples.py
new file mode 100644
index 000000000..23683c059
--- /dev/null
+++ b/examples/serve_browser_examples.py
@@ -0,0 +1,323 @@
+"""
+Serve browser examples locally.
+===============================
+
+
+A little script to serve pygfx examples on localhost so you can try them in the browser.
+This is an iteration of the same script in rendercanvas and wgpu-py although some changes:
+
+* swapped in pathlib for os.path (mostly)
+* avoided pyscript for now, but likely a good idea to use for webworker recovery etc.
+
+Files are loaded from disk on each request, so you can leave the server running
+and just update examples, update pygfx and build the wheel, etc.
+"""
+
+import os
+import sys
+import webbrowser
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from pathlib import Path
+
+import flit
+
+import fastplotlib
+
+
+# sphinx_gallery_pygfx_docs = 'hidden'
+# sphinx_gallery_pygfx_test = 'off'
+
+# "hosted" upstream deps, will break in the future
+# wgpu_wheel = "https://wgpu-py--753.org.readthedocs.build/en/753/_static/wgpu-0.32.0-py3-none-any.whl" # very hacky way to serve this but it does work...
+# pygfx_wheel = "https://pygfx--1273.org.readthedocs.build/1273/_static/pygfx-0.17.0-py3-none-any.whl"
+
+# local wheels (in dist/)
+wgpu_wheel = "wgpu-0.32.0-py3-none-any.whl"
+pygfx_wheel = "pygfx-0.17.0-py3-none-any.whl"
+
+# rendercanvas_wheel = "rendercanvas-2.7.2-py3-none-any.whl" # still moved manually ...
+rendercanvas_wheel = "https://rendercanvas.readthedocs.io/latest/_static/rendercanvas-2.7.2-py3-none-any.whl"
+
+
+# the pygfx wheel will be listed after this. it might be possible to still get deps from pyproject.toml
+fpl_deps = [rendercanvas_wheel, wgpu_wheel, pygfx_wheel, "imgui-bundle", "hsluv", "pylinalg", "jinja2", "httpx", "trimesh", "gltflib", "imageio"]
+
+root = Path(__file__).parent.parent.absolute()
+
+short_version = ".".join(str(i) for i in fastplotlib.version_info[:3])
+wheel_name = f"fastplotlib-{short_version}-py3-none-any.whl"
+
+example_files = list((root / "examples").glob("**/*.py"))
+
+def get_html_index():
+ """Create a landing page."""
+
+ examples_list = [f"
" for name in example_files]
+
+ html = """
+
+
+
+ fastplotlib browser examples
+
+
+
+
+ Rebuild the wheel
+ """
+
+ html += "List of examples that might run in Pyodide:\n"
+ html += f"
{''.join(examples_list)}
\n\n"
+
+ html += "\n\n"
+ return html
+
+
+html_index = get_html_index()
+
+# TODO: a pyodide example for the compute examples (so we can capture output?)
+# modified from _pyodide_iframe.html from rendercanvas, and then again from pygfx
+pyodide_compute_template = """
+
+
+
+
+ {example_script} via Pyodide
+
+
+
+
+
+ Back to list
+
+
+ {docstring}
+
+
+
Loading ...
+
+
+
Output:
+
+
+
+
+
+"""
+
+
+imageio_patch = """
+from pyodide.http import pyfetch
+from pyodide.ffi import run_sync
+
+async def _imread_async(filename, **kwargs):
+ # pyodide working version of iio.imread, uses fetch and waiting on it, also replaced the "imageio:"
+ filename = str(filename) # maybe breaks some Path stuff?
+ if filename.startswith("imageio:"):
+ filename = filename.replace("imageio:", "https://raw.githubusercontent.com/imageio/imageio-binaries/master/images/")
+
+ if "." in filename:
+ kwargs["extension"] = "." + filename.rsplit(".", 1)[-1]
+
+ response = await pyfetch(filename)
+ res = iio.imread((await response.bytes()), **kwargs)
+ return res
+
+def _imread(filename:str, **kwargs):
+ print(f"Loading image: {filename!r} with kwargs: {kwargs}")
+ res = run_sync(_imread_async(filename, **kwargs))
+ return res
+"""
+
+def patch_imageio_for_pyodide(python_code:str) -> str:
+ if "iio.imread(" not in python_code:
+ return python_code
+
+ return imageio_patch + "\n\n" + python_code.replace("iio.imread(", "_imread(")
+
+
+
+def build_wheel():
+ toml_filename = (root / "pyproject.toml")
+ # spews more and more with each build... might need to clear stdout or something?
+ flit.main(["-f", str(toml_filename.resolve()), "build", "--no-use-vcs", "--format", "wheel"])
+ wheel_filename = root / "dist" / wheel_name
+ assert wheel_filename.is_file(), f"{wheel_name} does not exist"
+ # also copy the wgpu wheel if it's in a repo nearby... so make it a bit less work to update both.
+ try:
+ # would also be fun if this actually built the wheel too... but that might be too much atm.
+ wgpu_wheel_filename = root.parent / "wgpu-py" / "dist" / wgpu_wheel
+ if wgpu_wheel_filename.is_file():
+ # TODO: can use Pathlib copy instead?
+ target = root / "dist" / wgpu_wheel
+ with open(wgpu_wheel_filename, "rb") as src, open(target, "wb") as dst:
+ dst.write(src.read())
+ print(f"Copied {wgpu_wheel} to dist folder.")
+ else:
+ print(f"{wgpu_wheel} not found in nearby repo, skipping copy. falling 'back' to hosted wheel")
+ # maye fall back to online wheel?
+ except Exception as e:
+ print(f"Error copying {wgpu_wheel}: {e}")
+ # alsp pygfx
+ try:
+ # would also be fun if this actually built the wheel too... but that might be too much atm.
+ pygfx_wheel_filename = root.parent / "pygfx" / "dist" / pygfx_wheel
+ if pygfx_wheel_filename.is_file():
+ # TODO: can use Pathlib copy instead?
+ target = root / "dist" / pygfx_wheel
+ with open(pygfx_wheel_filename, "rb") as src, open(target, "wb") as dst:
+ dst.write(src.read())
+ print(f"Copied {pygfx_wheel} to dist folder.")
+ else:
+ print(f"{pygfx_wheel} not found in nearby repo, skipping copy. falling 'back' to hosted wheel")
+ # maye fall back to online wheel?
+ except Exception as e:
+ print(f"Error copying {pygfx_wheel}: {e}")
+
+
+def get_docstring_from_py_file(fname):
+ filename = root / "examples" / fname
+ docstate = 0
+ doc = ""
+ with open(filename, "rb") as f:
+ for line in f:
+ line = line.decode()
+ if docstate == 0:
+ if line.lstrip().startswith('"""'):
+ docstate = 1
+ else:
+ if docstate == 1 and line.lstrip().startswith(("---", "===")):
+ docstate = 2
+ doc = ""
+ elif '"""' in line:
+ doc += line.partition('"""')[0]
+ break
+ else:
+ doc += line
+
+ return doc.replace("\n", " ")
+
+
+class MyHandler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path == "/":
+ self.respond(200, html_index, "text/html")
+ elif self.path == "/build":
+ try:
+ self.respond(200, "Building wheel... ", "text/html")
+ build_wheel()
+ except Exception as err:
+ self.respond(500, str(err), "text/plain")
+ else:
+ html = f" Wheel build: {wheel_name}
Back to list"
+ self.respond(200, html, "text/html")
+ elif self.path.endswith(".whl"):
+ requested_path = Path(self.path)
+ filename = root / "dist" / requested_path.name
+ if os.path.isfile(filename):
+ with open(filename, "rb") as f:
+ data = f.read()
+ self.respond(200, data, "application/octet-stream")
+ else:
+ self.respond(404, "wheel not found")
+ elif self.path.endswith(".html"):
+ # name = self.path.strip("/")
+ pyname = self.path.replace(".html", ".py").lstrip("/")
+ try:
+ doc = get_docstring_from_py_file(pyname)
+ deps = [*fpl_deps, f"/{wheel_name}"]
+ html = pyodide_compute_template.format(
+ docstring=doc,
+ example_script=pyname,
+ # todo: refactor this to a list and maybe get other deps from pyodide.loadPackagesFromImports
+ dependencies="\n".join(
+ [f"await micropip.install({dep!r});" for dep in deps]
+ ),
+ )
+ self.respond(200, html, "text/html")
+ except Exception as err:
+ self.respond(404, f"example not found: {err}")
+ elif self.path.endswith(".py"):
+ filename = os.path.join(root, "examples", self.path.strip("/"))
+ if os.path.isfile(filename):
+ with open(filename, "rb") as f:
+ data = f.read()
+ data = patch_imageio_for_pyodide(data.decode()).encode()
+ self.respond(200, data, "text/plain")
+ else:
+ self.respond(404, "py file not found")
+
+ # TODO: we could try to mount a virtual filesystem and fill it... but I think using fetch and serving the files could work easier.
+ elif self.path.startswith("/home/data/"):
+ requested_path = Path(self.path)
+ # this is for the pyodide examples that need to load data files - we mount the examples/data folder to /home/data in pyodide, so we can serve those files here.
+ filename = root / "examples" / "data" / requested_path.relative_to("/home/data")
+ if os.path.isfile(filename):
+ with open(filename, "rb") as f:
+ data = f.read()
+ self.respond(200, data, "application/octet-stream")
+ else:
+ self.respond(404, "data file not found")
+
+ else:
+ self.respond(404, "not found")
+
+ def respond(self, code, body, content_type="text/plain"):
+ self.send_response(code)
+ self.send_header("Content-type", content_type)
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
+ self.end_headers()
+ if isinstance(body, str):
+ body = body.encode()
+ self.wfile.write(body)
+
+
+if __name__ == "__main__":
+ port = 8000
+ if len(sys.argv) > 1:
+ try:
+ port = int(sys.argv[-1])
+ except ValueError:
+ pass
+
+ build_wheel()
+ print("Opening page in web browser ...")
+ webbrowser.open(f"http://localhost:{port}/")
+ HTTPServer(("", port), MyHandler).serve_forever()
+
+
diff --git a/fastplotlib/graphics/shaders/_highlight_shaders.py b/fastplotlib/graphics/shaders/_highlight_shaders.py
index 6b114bf7a..ed96a55f0 100644
--- a/fastplotlib/graphics/shaders/_highlight_shaders.py
+++ b/fastplotlib/graphics/shaders/_highlight_shaders.py
@@ -248,8 +248,7 @@ def get_bindings(self, wobject, shared, scene):
return {0: group0}
def get_code(self):
- wgsl = super().get_code()
-
+ wgsl = super().get_code().replace("\r\n", "\n") # newlines on windows were \r\n breaking the matching here
# Pre-sample: inject visibility LUT remapping. fpl_texcoord is always
# declared here so the highlight block below can safely reference it
# regardless of whether visibility is active
diff --git a/pyproject.toml b/pyproject.toml
index be331b5fe..ae9562be0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,8 @@ dependencies = [
[project.optional-dependencies]
docs = [
+ "build",
+ "flit",
"sphinx",
"sphinx-gallery",
"pydata-sphinx-theme",
@@ -74,7 +76,7 @@ Repository = "https://github.com/fastplotlib/fastplotlib"
# ===== Building
[build-system]
-requires = ["flit_core >=3.2,<4"]
+requires = ["flit_core >=3.2,<5"]
build-backend = "flit_core.buildapi"
# ===== Tooling