From 64e041272c8c07c5c80318300e5805b63edb0832 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 13 Oct 2026 14:15:41 +0200 Subject: [PATCH 01/17] feat(viewer): add toolbar plugins with a Python half `app.plugins = [Standard(), Camera()]` adds toolbar rows to the viewer; by default there are none. A `Plugin` subclass names its built frontend (`static/plugin.js`), handles what its buttons send with `@action`, and answers with `send()`; `send(..., key=...)` is also replayed to browsers that connect later. The App serves each plugin at /plugins// and lists them at /plugins.json for the viewer page to load. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ src/compas_threejs/viewer/__init__.py | 3 +- src/compas_threejs/viewer/app.py | 46 +++++++++++ src/compas_threejs/viewer/inbox.py | 10 +++ src/compas_threejs/viewer/plugin.py | 99 +++++++++++++++++++++++ tests/test_plugins.py | 112 ++++++++++++++++++++++++++ 6 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 src/compas_threejs/viewer/plugin.py create mode 100644 tests/test_plugins.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 19db84c..23f3f3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* `Plugin` and `@action` in `compas_threejs.viewer`: a toolbar plugin's Python half, talking to its frontend buttons with `@action` handlers and `send()`. +* `App.plugins`: the viewer's plugins, in toolbar order. The App serves each plugin's built frontend and lists them at `/plugins.json`. + ### Changed +* The viewer has no toolbar unless plugins add one, e.g. `app.plugins = [Standard(), Camera()]` from `compas_threejs_plugins`. + ### Removed diff --git a/src/compas_threejs/viewer/__init__.py b/src/compas_threejs/viewer/__init__.py index 9ba444c..1ca0b90 100644 --- a/src/compas_threejs/viewer/__init__.py +++ b/src/compas_threejs/viewer/__init__.py @@ -1,5 +1,6 @@ from .app import App +from .plugin import Plugin, action from .remote import Remote from .workspace import CameraView, Workspace -__all__ = ["App", "CameraView", "Remote", "Workspace"] +__all__ = ["App", "CameraView", "Plugin", "Remote", "Workspace", "action"] diff --git a/src/compas_threejs/viewer/app.py b/src/compas_threejs/viewer/app.py index cf3c0e1..bbdb265 100644 --- a/src/compas_threejs/viewer/app.py +++ b/src/compas_threejs/viewer/app.py @@ -10,6 +10,7 @@ from .inbox import Inbox from .outbox import Outbox +from .plugin import Plugin from .server import AppServer from .workspace import CameraView from .workspace import Workspace @@ -101,6 +102,10 @@ def __init__(self, host: str = "127.0.0.1", websocket_port: int = 9001, frontend self.outbox = Outbox(self.server) self.inbox = Inbox(self) + # Plugins: none by default, so the viewer starts without a toolbar + self._plugins = [] + self.server.add_route("/plugins.json", self._plugins_manifest) + # Setter Attributes self._loop_interval = 0.01 self._loop = None @@ -126,6 +131,46 @@ def get_workspace(self, workspace_id: str) -> Workspace: # ---- ATTRIBUTES (forwarded to the main workspace) ------------------------------------------ + @property + def plugins(self) -> list: + """The viewer's plugins, in toolbar order. Set before `start()`. + + >>> app.plugins = [Standard(), Camera()] + """ + return list(self._plugins) + + @plugins.setter + def plugins(self, plugins): + if self.websocket_server_thread: + raise RuntimeError("Set App.plugins before App.start()") + plugins = list(plugins) + names = set() + for plugin in plugins: + if not isinstance(plugin, Plugin): + raise TypeError(f"Not a Plugin: {plugin!r}") + if plugin.name in names: + raise ValueError(f"Two plugins are named {plugin.name!r}") + names.add(plugin.name) + for plugin in self._plugins: + plugin.app = None + for plugin in plugins: + plugin.app = self + self._plugins = plugins + + def get_plugin(self, name: str): + """The plugin named `name`, or None.""" + return next((plugin for plugin in self._plugins if plugin.name == name), None) + + def _plugins_manifest(self): + return [plugin.manifest() for plugin in self._plugins] + + def _mount_plugins(self): + for plugin in self._plugins: + static_dir = plugin.static_dir + if not static_dir or not (static_dir / "plugin.js").exists(): + raise FileNotFoundError(f"Plugin {plugin.name!r} has no built frontend at {static_dir}/plugin.js - build it with `npm run build` in the plugin's repository.") + self.server.add_static_mount(f"/plugins/{plugin.name}", static_dir, name=f"plugin_{plugin.name}") + @property def loop(self) -> callable: """Set or get a user-defined callback function that will be called in each iteration of the main loop.""" @@ -296,6 +341,7 @@ def _initialize_server(self): def start(self, show=False): """Initializes the server and keeps the main thread alive.""" + self._mount_plugins() self._initialize_server() # Re-send current main workspace settings now that the server (and its loop) exists diff --git a/src/compas_threejs/viewer/inbox.py b/src/compas_threejs/viewer/inbox.py index 4502971..0fbaf92 100644 --- a/src/compas_threejs/viewer/inbox.py +++ b/src/compas_threejs/viewer/inbox.py @@ -45,6 +45,7 @@ def __init__(self, app=None): "object_transform": self._handle_object_transform, "create_geometry": self._handle_create_geometry, "material_edit": self._handle_material_edit, + "plugin": self._handle_plugin, } # ---- REGISTRATION (called by Workspace when something is sent out) ------------------------- @@ -283,6 +284,15 @@ def _handle_loaded_json(self, message, outbox, workspace_id): if action_id and action_id in self.buttons: self.buttons[action_id](json_data) + def _handle_plugin(self, message, outbox, workspace_id): + """Hands a plugin's `channel.send(action, data)` to that plugin's Python half.""" + name = message.get("plugin") + plugin = self.app.get_plugin(name) if self.app else None + if not plugin: + console.log(f"[yellow]Received a message for unknown plugin {name!r}[/yellow]") + return + plugin.handle(message.get("action"), message.get("data")) + def _handle_other_action(self, message, outbox, workspace_id): """Handles messages from frontend elements not tied to a backend-registered guid, dispatched by a fixed action name instead - see `register_action`.""" diff --git a/src/compas_threejs/viewer/plugin.py b/src/compas_threejs/viewer/plugin.py new file mode 100644 index 0000000..a9fc2c1 --- /dev/null +++ b/src/compas_threejs/viewer/plugin.py @@ -0,0 +1,99 @@ +from pathlib import Path + +import compas_pb +from rich.console import Console + +console = Console() + + +def action(name): + """Marks a `Plugin` method as the handler of the frontend's `channel.send(name, data)`. + + The method is called as ``method(data)`` on the server thread. + """ + + def decorate(method): + method._plugin_action = name + return method + + return decorate + + +class Plugin: + """A viewer plugin: toolbar buttons in the frontend, and their Python half. + + A subclass sets `name` and `static`, the folder holding the plugin's built frontend, + `plugin.js` (and `plugin.css`, if it has one). `plugin.js` default-exports + `definePlugin({ name, buttons })` from `@compas-dev/compas-threejs-ts`, with the same + `name`. Methods marked with `@action("...")` handle what the frontend sends; `send` + talks back. + + Examples + -------- + >>> app = App() + >>> app.plugins = [Standard(), Camera()] + >>> app.start() + """ + + name = None + static = None + + def __init__(self): + if not self.name: + raise ValueError(f"{type(self).__name__} has no plugin name") + self.app = None + self._handlers = {} + for attribute in dir(type(self)): + method = getattr(self, attribute, None) + name = getattr(method, "_plugin_action", None) + if name: + self._handlers.setdefault(name, []).append(method) + + @property + def static_dir(self): + return Path(self.static) if self.static else None + + def manifest(self): + """The entry `plugins.json` lists for this plugin; paths are relative to the page.""" + entry = {"name": self.name, "module": f"plugins/{self.name}/plugin.js"} + if (self.static_dir / "plugin.css").exists(): + entry["style"] = f"plugins/{self.name}/plugin.css" + return entry + + def on(self, action_name): + """Adds a handler for `action_name` from outside the plugin, as a decorator. + + >>> @camera.on("save") + ... def saved(data): + ... print(data) + """ + + def decorate(callback): + self._handlers.setdefault(action_name, []).append(callback) + return callback + + return decorate + + def handle(self, action_name, data): + handlers = self._handlers.get(action_name) + if not handlers: + console.log(f"[yellow]Plugin {self.name!r} has no handler for {action_name!r}[/yellow]") + return + for handler in handlers: + handler(data) + + def send(self, action_name, data=None, *, key=None): + """Sends `data` to the frontend's `channel.on(action_name, ...)` handlers. + + With a `key`, the message is also kept (replacing the last one with that key) and + replayed to browsers that connect later - for state, like a list of saved views. + """ + if not self.app: + raise RuntimeError(f"Plugin {self.name!r} is not added to an App") + message = {"dispatch": "plugin", "plugin": self.name, "action": action_name, "data": data} + persisted = key is not None + self.app.outbox.send_bytes( + compas_pb.pb_dump_bts(message), + f"plugin/{self.name}/{key}" if persisted else "", + persist=persisted, + ) diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000..6e73680 --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,112 @@ +import json + +import compas_pb +import pytest +from fastapi.testclient import TestClient + +from compas_threejs.viewer import App +from compas_threejs.viewer import Plugin +from compas_threejs.viewer import action + + +@pytest.fixture +def static(tmp_path): + (tmp_path / "plugin.js").write_text("export default { name: 'views' };") + return tmp_path + + +def make_plugin(static_dir): + class Views(Plugin): + name = "views" + static = static_dir + + def __init__(self): + super().__init__() + self.saved = [] + + @action("save") + def save(self, data): + self.saved.append(data) + self.send("views", [view["name"] for view in self.saved], key="views") + + return Views() + + +def receive(app, message): + app.on_message(json.dumps(message).encode("utf-8")) + + +def test_app_has_no_plugins_by_default(): + app = App() + assert app.plugins == [] + assert TestClient(app.server.fastapi_app).get("/plugins.json").json() == [] + + +def test_plugins_are_listed_in_order(static, tmp_path_factory): + other_static = tmp_path_factory.mktemp("other") + (other_static / "plugin.js").write_text("") + (other_static / "plugin.css").write_text("") + + class Other(Plugin): + name = "other" + static = other_static + + app = App() + app.plugins = [make_plugin(static), Other()] + app._mount_plugins() + client = TestClient(app.server.fastapi_app) + + assert client.get("/plugins.json").json() == [ + {"name": "views", "module": "plugins/views/plugin.js"}, + {"name": "other", "module": "plugins/other/plugin.js", "style": "plugins/other/plugin.css"}, + ] + assert client.get("/plugins/views/plugin.js").text == "export default { name: 'views' };" + + +def test_plugin_names_must_be_unique(static): + app = App() + with pytest.raises(ValueError): + app.plugins = [make_plugin(static), make_plugin(static)] + + +def test_a_plugin_needs_its_built_frontend(tmp_path): + app = App() + app.plugins = [make_plugin(tmp_path)] + with pytest.raises(FileNotFoundError): + app._mount_plugins() + + +def test_actions_reach_the_plugin_and_replies_are_kept_by_key(static): + app = App() + views = make_plugin(static) + app.plugins = [views] + + receive(app, {"dispatch": "plugin", "plugin": "views", "action": "save", "data": {"name": "A"}}) + receive(app, {"dispatch": "plugin", "plugin": "views", "action": "save", "data": {"name": "B"}}) + + assert views.saved == [{"name": "A"}, {"name": "B"}] + # No server is running, so the outbox queues: (bytes, obj_id, persist, ...) + sent = [(compas_pb.pb_load_bts(entry[0]), entry[1], entry[2]) for entry in app.outbox._queue] + assert sent[-1] == ({"dispatch": "plugin", "plugin": "views", "action": "views", "data": ["A", "B"]}, "plugin/views/views", True) + + +def test_handlers_can_be_added_from_outside(static): + app = App() + views = make_plugin(static) + app.plugins = [views] + seen = [] + + @views.on("save") + def saved(data): + seen.append(data) + + receive(app, {"dispatch": "plugin", "plugin": "views", "action": "save", "data": {"name": "A"}}) + assert seen == [{"name": "A"}] + + +def test_unkeyed_messages_are_not_kept(static): + app = App() + views = make_plugin(static) + app.plugins = [views] + views.send("flash", {"text": "hi"}) + assert app.outbox._queue[-1][1:3] == ("", False) From aecdabb198ef76949be2d904f076b31d77bc7078 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 13 Oct 2026 14:50:45 +0200 Subject: [PATCH 02/17] feat(viewer): hide, show, disable and enable plugin buttons from Python `plugin.hide("display")` / `show` / `disable("scale")` / `enable` take the ids of button groups or single buttons. The state is sent as one persisted `plugin_buttons` message, so browsers that connect later get it too, and state set before the plugin is added to an App is sent when it is. A plugin may list its `ids`, so a misspelled one raises. Co-Authored-By: Claude Opus 5.5 --- src/compas_threejs/viewer/app.py | 2 + src/compas_threejs/viewer/plugin.py | 58 ++++++++++++++++++++++++++++- tests/test_plugins.py | 37 ++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/compas_threejs/viewer/app.py b/src/compas_threejs/viewer/app.py index bbdb265..8f031b0 100644 --- a/src/compas_threejs/viewer/app.py +++ b/src/compas_threejs/viewer/app.py @@ -155,6 +155,8 @@ def plugins(self, plugins): plugin.app = None for plugin in plugins: plugin.app = self + if plugin.hidden or plugin.disabled: + plugin.send_buttons() self._plugins = plugins def get_plugin(self, name: str): diff --git a/src/compas_threejs/viewer/plugin.py b/src/compas_threejs/viewer/plugin.py index a9fc2c1..3aa8920 100644 --- a/src/compas_threejs/viewer/plugin.py +++ b/src/compas_threejs/viewer/plugin.py @@ -24,25 +24,35 @@ class Plugin: A subclass sets `name` and `static`, the folder holding the plugin's built frontend, `plugin.js` (and `plugin.css`, if it has one). `plugin.js` default-exports - `definePlugin({ name, buttons })` from `@compas-dev/compas-threejs-ts`, with the same + `definePlugin({ name, groups })` from `@compas-dev/compas-threejs-ts`, with the same `name`. Methods marked with `@action("...")` handle what the frontend sends; `send` talks back. + `hide`, `show`, `disable` and `enable` take the ids of button groups or single + buttons, as the frontend names them in `groups`. A subclass may list them in `ids`, + so that a misspelled id raises instead of doing nothing. + Examples -------- >>> app = App() - >>> app.plugins = [Standard(), Camera()] + >>> standard = Standard() + >>> app.plugins = [standard, Camera()] + >>> standard.hide("display") # a whole group + >>> standard.disable("scale") # one button >>> app.start() """ name = None static = None + ids = None def __init__(self): if not self.name: raise ValueError(f"{type(self).__name__} has no plugin name") self.app = None self._handlers = {} + self._hidden = set() + self._disabled = set() for attribute in dir(type(self)): method = getattr(self, attribute, None) name = getattr(method, "_plugin_action", None) @@ -82,6 +92,50 @@ def handle(self, action_name, data): for handler in handlers: handler(data) + @property + def hidden(self): + """The ids of the hidden groups and buttons.""" + return frozenset(self._hidden) + + @property + def disabled(self): + """The ids of the disabled groups and buttons.""" + return frozenset(self._disabled) + + def hide(self, *ids): + """Hides these groups or buttons.""" + self._hidden.update(self._known(ids)) + self.send_buttons() + + def show(self, *ids): + """Shows these groups or buttons again.""" + self._hidden.difference_update(self._known(ids)) + self.send_buttons() + + def disable(self, *ids): + """Greys out these groups or buttons; their clicks and shortcuts do nothing.""" + self._disabled.update(self._known(ids)) + self.send_buttons() + + def enable(self, *ids): + """Enables these groups or buttons again.""" + self._disabled.difference_update(self._known(ids)) + self.send_buttons() + + def _known(self, ids): + if self.ids is not None: + unknown = [id_ for id_ in ids if id_ not in self.ids] + if unknown: + raise ValueError(f"Plugin {self.name!r} has no group or button {', '.join(map(repr, unknown))}; it has {', '.join(self.ids)}") + return ids + + def send_buttons(self): + """Sends which groups and buttons are hidden or disabled; kept for browsers that connect later.""" + if not self.app: + return # sent when the plugin is added to an App + message = {"dispatch": "plugin_buttons", "plugin": self.name, "hidden": sorted(self._hidden), "disabled": sorted(self._disabled)} + self.app.outbox.send_bytes(compas_pb.pb_dump_bts(message), f"plugin/{self.name}/buttons", persist=True) + def send(self, action_name, data=None, *, key=None): """Sends `data` to the frontend's `channel.on(action_name, ...)` handlers. diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 6e73680..61731c4 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -110,3 +110,40 @@ def test_unkeyed_messages_are_not_kept(static): app.plugins = [views] views.send("flash", {"text": "hi"}) assert app.outbox._queue[-1][1:3] == ("", False) + + +def sent_buttons(app): + return [compas_pb.pb_load_bts(entry[0]) | {"key": entry[1], "persist": entry[2]} for entry in app.outbox._queue if entry[1].endswith("/buttons")] + + +def test_hide_and_disable_groups_and_buttons(static): + app = App() + views = make_plugin(static) + app.plugins = [views] + + views.hide("display") + views.disable("scale", "move") + views.show("display") + views.enable("move") + + assert views.hidden == frozenset() + assert views.disabled == {"scale"} + assert sent_buttons(app)[-1] == {"dispatch": "plugin_buttons", "plugin": "views", "hidden": [], "disabled": ["scale"], "key": "plugin/views/buttons", "persist": True} + + +def test_button_state_set_before_adding_the_plugin_is_sent(static): + app = App() + views = make_plugin(static) + views.hide("display") + assert sent_buttons(app) == [] + + app.plugins = [views] + assert sent_buttons(app)[-1]["hidden"] == ["display"] + + +def test_declared_ids_catch_typos(static): + views = make_plugin(static) + views.ids = ("display", "scale") + views.hide("display") + with pytest.raises(ValueError, match="'scael'"): + views.disable("scael") From a56f056e7be8c54dd61ea78691fe242f90186346 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 13 Oct 2026 15:18:37 +0200 Subject: [PATCH 03/17] feat(viewer): Plugin.attached() and App.find_geometry() for plugin state `attached()` runs once a plugin is added to an App, sending state set before that (hidden/disabled buttons; subclasses add their own, like Standard's locks). `find_geometry(guid)` gives plugins the geometry the frontend names by guid. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ src/compas_threejs/viewer/app.py | 7 +++++-- src/compas_threejs/viewer/plugin.py | 8 ++++++++ tests/test_plugins.py | 10 ++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f3f3c..be87525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `Plugin` and `@action` in `compas_threejs.viewer`: a toolbar plugin's Python half, talking to its frontend buttons with `@action` handlers and `send()`. * `App.plugins`: the viewer's plugins, in toolbar order. The App serves each plugin's built frontend and lists them at `/plugins.json`. +* `Plugin.hide`/`show`/`disable`/`enable`: hide or disable a plugin's button groups, buttons or object actions by id. +* `Plugin.attached()`, called once a plugin is added to an App, and `App.find_geometry(guid)`. ### Changed * The viewer has no toolbar unless plugins add one, e.g. `app.plugins = [Standard(), Camera()]` from `compas_threejs_plugins`. +* The transform gizmo and object locking are plugin behaviour (`Standard` in `compas_threejs_plugins`); the viewer applies a gizmo edit to the geometry as before. ### Removed diff --git a/src/compas_threejs/viewer/app.py b/src/compas_threejs/viewer/app.py index 8f031b0..e86e5c9 100644 --- a/src/compas_threejs/viewer/app.py +++ b/src/compas_threejs/viewer/app.py @@ -155,14 +155,17 @@ def plugins(self, plugins): plugin.app = None for plugin in plugins: plugin.app = self - if plugin.hidden or plugin.disabled: - plugin.send_buttons() + plugin.attached() self._plugins = plugins def get_plugin(self, name: str): """The plugin named `name`, or None.""" return next((plugin for plugin in self._plugins if plugin.name == name), None) + def find_geometry(self, guid: str): + """The geometry shown in the viewer with this guid (as plugins get it from the frontend), or None.""" + return self.inbox.geometry_registry.get(str(guid)) + def _plugins_manifest(self): return [plugin.manifest() for plugin in self._plugins] diff --git a/src/compas_threejs/viewer/plugin.py b/src/compas_threejs/viewer/plugin.py index 3aa8920..3bf2c35 100644 --- a/src/compas_threejs/viewer/plugin.py +++ b/src/compas_threejs/viewer/plugin.py @@ -129,6 +129,14 @@ def _known(self, ids): raise ValueError(f"Plugin {self.name!r} has no group or button {', '.join(map(repr, unknown))}; it has {', '.join(self.ids)}") return ids + def attached(self): + """Called once the plugin is added to an App: sends the state set before that. + + A subclass with state of its own extends it, calling ``super().attached()``. + """ + if self._hidden or self._disabled: + self.send_buttons() + def send_buttons(self): """Sends which groups and buttons are hidden or disabled; kept for browsers that connect later.""" if not self.app: diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 61731c4..931cfd7 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -147,3 +147,13 @@ def test_declared_ids_catch_typos(static): views.hide("display") with pytest.raises(ValueError, match="'scael'"): views.disable("scael") + + +def test_find_geometry_by_guid(): + from compas.geometry import Box + + app = App() + box = Box(1) + app.add_geometry(box) + assert app.find_geometry(str(box.guid)) is box + assert app.find_geometry("missing") is None From 3e263f331cd8c21e7e2d9a2a59b3d1941c5f9453 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 15 Oct 2026 11:51:20 +0200 Subject: [PATCH 04/17] feat(viewer)!: workspace-aware plugins, and plugins per workspace A plugin is one object for all workspaces (browser tabs), so it needs to know which tab is talking to it: - @action handlers and on() callbacks are called with (data, workspace), the Workspace of the tab the message came from. - Inside a handler, Plugin.send answers that tab; elsewhere it goes to main, and send(..., workspace=) picks one. Plugin.send_all reaches every tab; its keyed messages are replayed to tabs opened later, in any workspace (AppServer.shared_state, workspace_id=None). - Button state is sent with send_all, so tabs other than main get it. - Workspace.plugins: the subset of App.plugins a workspace's tabs show, served at /plugins.json?workspace=. Messages for a plugin the workspace doesn't show are dropped. - Workspace.url. BREAKING CHANGE: plugin handlers take (data, workspace) instead of (data). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- src/compas_threejs/viewer/app.py | 7 +- src/compas_threejs/viewer/inbox.py | 6 +- src/compas_threejs/viewer/outbox.py | 2 + src/compas_threejs/viewer/plugin.py | 55 ++++++++++--- src/compas_threejs/viewer/server.py | 24 ++++-- src/compas_threejs/viewer/workspace.py | 32 ++++++++ tests/test_plugins.py | 109 +++++++++++++++++++++++-- 8 files changed, 211 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be87525..da3e6a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `App.plugins`: the viewer's plugins, in toolbar order. The App serves each plugin's built frontend and lists them at `/plugins.json`. * `Plugin.hide`/`show`/`disable`/`enable`: hide or disable a plugin's button groups, buttons or object actions by id. * `Plugin.attached()`, called once a plugin is added to an App, and `App.find_geometry(guid)`. +* Workspace-aware plugins: `@action` handlers get `(data, workspace)`, the `Workspace` of the browser tab that sent the message; inside a handler `Plugin.send` answers that tab, `send(..., workspace=)` picks one, and `Plugin.send_all` reaches every tab. Button state reaches every tab. +* `Workspace.plugins`: the subset of `App.plugins` a workspace's tabs show, served at `/plugins.json?workspace=`. `Workspace.url`. ### Changed -* The viewer has no toolbar unless plugins add one, e.g. `app.plugins = [Standard(), Camera()]` from `compas_threejs_plugins`. -* The transform gizmo and object locking are plugin behaviour (`Standard` in `compas_threejs_plugins`); the viewer applies a gizmo edit to the geometry as before. +* The viewer has no toolbar unless plugins add one, e.g. `app.plugins = [Essentials(), Views()]` from `compas_threejs_essentials` and `compas_threejs_views`. +* The transform gizmo and object locking are plugin behaviour (`Essentials` in `compas_threejs_essentials`); the viewer applies a gizmo edit to the geometry as before. ### Removed diff --git a/src/compas_threejs/viewer/app.py b/src/compas_threejs/viewer/app.py index e86e5c9..cedae7a 100644 --- a/src/compas_threejs/viewer/app.py +++ b/src/compas_threejs/viewer/app.py @@ -135,6 +135,8 @@ def get_workspace(self, workspace_id: str) -> Workspace: def plugins(self) -> list: """The viewer's plugins, in toolbar order. Set before `start()`. + A workspace shows all of them, or the subset in its `Workspace.plugins`. + >>> app.plugins = [Standard(), Camera()] """ return list(self._plugins) @@ -166,8 +168,9 @@ def find_geometry(self, guid: str): """The geometry shown in the viewer with this guid (as plugins get it from the frontend), or None.""" return self.inbox.geometry_registry.get(str(guid)) - def _plugins_manifest(self): - return [plugin.manifest() for plugin in self._plugins] + def _plugins_manifest(self, workspace: str = "main"): + plugins = self.workspaces[workspace].plugins if workspace in self.workspaces else self._plugins + return [plugin.manifest() for plugin in plugins] def _mount_plugins(self): for plugin in self._plugins: diff --git a/src/compas_threejs/viewer/inbox.py b/src/compas_threejs/viewer/inbox.py index 0fbaf92..0290ca1 100644 --- a/src/compas_threejs/viewer/inbox.py +++ b/src/compas_threejs/viewer/inbox.py @@ -291,7 +291,11 @@ def _handle_plugin(self, message, outbox, workspace_id): if not plugin: console.log(f"[yellow]Received a message for unknown plugin {name!r}[/yellow]") return - plugin.handle(message.get("action"), message.get("data")) + workspace = self.app.get_workspace(workspace_id) + if plugin not in workspace.plugins: + console.log(f"[yellow]Plugin {name!r} is not in workspace {workspace_id!r}[/yellow]") + return + plugin.handle(message.get("action"), message.get("data"), workspace) def _handle_other_action(self, message, outbox, workspace_id): """Handles messages from frontend elements not tied to a backend-registered guid, diff --git a/src/compas_threejs/viewer/outbox.py b/src/compas_threejs/viewer/outbox.py index dd7aaa3..5194d31 100644 --- a/src/compas_threejs/viewer/outbox.py +++ b/src/compas_threejs/viewer/outbox.py @@ -26,6 +26,8 @@ def send_bytes( state - used so a removed object's earlier "add" broadcast isn't replayed to clients that connect (or reconnect) after the removal. + `workspace_id=None` sends to every workspace (see `AppServer.broadcast`). + `broadcast`, if False, still persists `binary_data` under `obj_id` for future reconnects but skips sending it to clients already connected - used to silently refresh the reconnect-replay snapshot after already notifying live clients through diff --git a/src/compas_threejs/viewer/plugin.py b/src/compas_threejs/viewer/plugin.py index 3bf2c35..aa2f6e6 100644 --- a/src/compas_threejs/viewer/plugin.py +++ b/src/compas_threejs/viewer/plugin.py @@ -1,3 +1,4 @@ +from contextvars import ContextVar from pathlib import Path import compas_pb @@ -5,11 +6,15 @@ console = Console() +# The workspace whose message a plugin is handling: where `Plugin.send` answers. +_asking_workspace = ContextVar("asking_workspace", default=None) + def action(name): """Marks a `Plugin` method as the handler of the frontend's `channel.send(name, data)`. - The method is called as ``method(data)`` on the server thread. + The method is called as ``method(data, workspace)`` on the server thread, with the + `Workspace` of the browser tab the message came from. """ def decorate(method): @@ -25,8 +30,12 @@ class Plugin: A subclass sets `name` and `static`, the folder holding the plugin's built frontend, `plugin.js` (and `plugin.css`, if it has one). `plugin.js` default-exports `definePlugin({ name, groups })` from `@compas-dev/compas-threejs-ts`, with the same - `name`. Methods marked with `@action("...")` handle what the frontend sends; `send` - talks back. + `name`. Methods marked with `@action("...")` handle what the frontend sends, and are + told which workspace (browser tab) sent it; `send` answers that tab, `send_all` talks + to every tab. + + The plugin is one object for all workspaces: a workspace shows the plugins in + `Workspace.plugins`, by default all of `App.plugins`. `hide`, `show`, `disable` and `enable` take the ids of button groups or single buttons, as the frontend names them in `groups`. A subclass may list them in `ids`, @@ -74,8 +83,8 @@ def on(self, action_name): """Adds a handler for `action_name` from outside the plugin, as a decorator. >>> @camera.on("save") - ... def saved(data): - ... print(data) + ... def saved(data, workspace): + ... print(data, workspace.workspace_id) """ def decorate(callback): @@ -84,13 +93,18 @@ def decorate(callback): return decorate - def handle(self, action_name, data): + def handle(self, action_name, data, workspace): + """Calls the handlers of `action_name` with `data` and the `Workspace` it came from.""" handlers = self._handlers.get(action_name) if not handlers: console.log(f"[yellow]Plugin {self.name!r} has no handler for {action_name!r}[/yellow]") return - for handler in handlers: - handler(data) + token = _asking_workspace.set(workspace) + try: + for handler in handlers: + handler(data, workspace) + finally: + _asking_workspace.reset(token) @property def hidden(self): @@ -138,18 +152,34 @@ def attached(self): self.send_buttons() def send_buttons(self): - """Sends which groups and buttons are hidden or disabled; kept for browsers that connect later.""" + """Sends every workspace which groups and buttons are hidden or disabled; kept for browsers that connect later.""" if not self.app: return # sent when the plugin is added to an App message = {"dispatch": "plugin_buttons", "plugin": self.name, "hidden": sorted(self._hidden), "disabled": sorted(self._disabled)} - self.app.outbox.send_bytes(compas_pb.pb_dump_bts(message), f"plugin/{self.name}/buttons", persist=True) + self.app.outbox.send_bytes(compas_pb.pb_dump_bts(message), f"plugin/{self.name}/buttons", persist=True, workspace_id=None) - def send(self, action_name, data=None, *, key=None): - """Sends `data` to the frontend's `channel.on(action_name, ...)` handlers. + def send(self, action_name, data=None, *, workspace=None, key=None): + """Sends `data` to the frontend's `channel.on(action_name, ...)` handlers in one workspace. + + That is `workspace` (a `Workspace` or its id) if given; otherwise, inside an action + handler, the workspace the handled message came from, and elsewhere "main". With a `key`, the message is also kept (replacing the last one with that key) and replayed to browsers that connect later - for state, like a list of saved views. """ + if workspace is None: + workspace = _asking_workspace.get() or "main" + workspace_id = workspace if isinstance(workspace, str) else workspace.workspace_id + self._send(action_name, data, key, workspace_id) + + def send_all(self, action_name, data=None, *, key=None): + """Like `send`, to every workspace - for state the plugin shares between browser tabs. + + A keyed message is also replayed to tabs opened later, in any workspace. + """ + self._send(action_name, data, key, None) + + def _send(self, action_name, data, key, workspace_id): if not self.app: raise RuntimeError(f"Plugin {self.name!r} is not added to an App") message = {"dispatch": "plugin", "plugin": self.name, "action": action_name, "data": data} @@ -158,4 +188,5 @@ def send(self, action_name, data=None, *, key=None): compas_pb.pb_dump_bts(message), f"plugin/{self.name}/{key}" if persisted else "", persist=persisted, + workspace_id=workspace_id, ) diff --git a/src/compas_threejs/viewer/server.py b/src/compas_threejs/viewer/server.py index 38fe1dc..e74439c 100644 --- a/src/compas_threejs/viewer/server.py +++ b/src/compas_threejs/viewer/server.py @@ -16,6 +16,9 @@ class AppServer: def __init__(self, frontend_dir=None): self.workspaces = defaultdict(set) self.workspace_states = defaultdict(dict) + # State sent to every workspace (`workspace_id=None` in `broadcast`), e.g. a plugin's + # button state, replayed to each tab before that tab's own state. + self.shared_state = {} self.app_instance = None self.loop = None @@ -57,9 +60,9 @@ async def _websocket_endpoint(self, websocket: WebSocket): workspace_id = websocket.query_params.get("workspace", "main") self.workspaces[workspace_id].add(websocket) - # Replay only the history belonging to this specific workspace - current_state = self.workspace_states[workspace_id].copy() - for guid, buffer in current_state.items(): + # Replay the shared state, then only the history belonging to this specific workspace + current_state = [*self.shared_state.values(), *self.workspace_states[workspace_id].values()] + for buffer in current_state: if isinstance(buffer, dict): await websocket.send_json(buffer) else: @@ -103,17 +106,21 @@ async def broadcast( ): """Broadcast binary data or dictionary configurations cleanly to workspace clients. + `workspace_id=None` sends to the clients of every workspace, and persists in the + shared state that every workspace replays. + `broadcast`, if False, still applies the persist/remove_key bookkeeping above but skips sending anything to already-connected clients - used to silently refresh a persisted snapshot for future reconnects only (see `Outbox.send_bytes`). """ + state = self.shared_state if workspace_id is None else self.workspace_states[workspace_id] if remove_key is not None: # Drops the object's earlier persisted "add" broadcast so it isn't replayed to # clients that connect (or reconnect) after this removal. - self.workspace_states[workspace_id].pop(remove_key, None) + state.pop(remove_key, None) elif persist: - key = obj_id or str(len(self.workspace_states[workspace_id])) - self.workspace_states[workspace_id][key] = binary_data + key = obj_id or str(len(state)) + state[key] = binary_data if binary_data is None: # Pure state cleanup (see Outbox.forget) - nothing to broadcast live. @@ -122,7 +129,10 @@ async def broadcast( if not broadcast: return - target_clients = self.workspaces[workspace_id] + if workspace_id is None: + target_clients = set().union(*self.workspaces.values()) + else: + target_clients = self.workspaces[workspace_id] if not target_clients: return diff --git a/src/compas_threejs/viewer/workspace.py b/src/compas_threejs/viewer/workspace.py index d3708d8..911676a 100644 --- a/src/compas_threejs/viewer/workspace.py +++ b/src/compas_threejs/viewer/workspace.py @@ -2,6 +2,7 @@ from enum import IntEnum from typing import Optional from typing import Union +from urllib.parse import quote import compas_pb from compas.colors import Color @@ -75,6 +76,37 @@ def __init__(self, app, workspace_id: str = "main"): self._camera_position = Point(8, -15, 15) self._camera_target = Point(0, 0, 0) self._show_edges = False + self._plugins = None + + @property + def plugins(self) -> list: + """The plugins this workspace's browser tabs show: a subset of `App.plugins`. + + By default all of them. Plugins are shared between workspaces - the same objects, + told which workspace each message comes from. A tab loads the list when it opens. + + >>> beam = app.get_workspace("beam") + >>> beam.plugins = [essentials, timber] + """ + if self._plugins is None: + return self.app.plugins + return [plugin for plugin in self._plugins if plugin.app is self.app] + + @plugins.setter + def plugins(self, plugins): + if plugins is None: + self._plugins = None + return + plugins = list(plugins) + for plugin in plugins: + if plugin not in self.app.plugins: + raise ValueError(f"Plugin {getattr(plugin, 'name', plugin)!r} is not in App.plugins; add it there first") + self._plugins = plugins + + @property + def url(self) -> str: + """The URL of this workspace's viewer page, as `App.url` gives the main one.""" + return f"{self.app.url}&workspace={quote(self.workspace_id)}" def _send_scene_message(self, type_: str, **fields): # Each scene setting (camera position, background color, ...) represents a single diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 931cfd7..f296486 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,3 +1,4 @@ +import asyncio import json import compas_pb @@ -7,6 +8,7 @@ from compas_threejs.viewer import App from compas_threejs.viewer import Plugin from compas_threejs.viewer import action +from compas_threejs.viewer.server import AppServer @pytest.fixture @@ -25,15 +27,15 @@ def __init__(self): self.saved = [] @action("save") - def save(self, data): + def save(self, data, workspace): self.saved.append(data) self.send("views", [view["name"] for view in self.saved], key="views") return Views() -def receive(app, message): - app.on_message(json.dumps(message).encode("utf-8")) +def receive(app, message, workspace_id="main"): + app.on_message(json.dumps(message).encode("utf-8"), workspace_id) def test_app_has_no_plugins_by_default(): @@ -97,11 +99,11 @@ def test_handlers_can_be_added_from_outside(static): seen = [] @views.on("save") - def saved(data): - seen.append(data) + def saved(data, workspace): + seen.append((data, workspace.workspace_id)) receive(app, {"dispatch": "plugin", "plugin": "views", "action": "save", "data": {"name": "A"}}) - assert seen == [{"name": "A"}] + assert seen == [({"name": "A"}, "main")] def test_unkeyed_messages_are_not_kept(static): @@ -129,6 +131,8 @@ def test_hide_and_disable_groups_and_buttons(static): assert views.hidden == frozenset() assert views.disabled == {"scale"} assert sent_buttons(app)[-1] == {"dispatch": "plugin_buttons", "plugin": "views", "hidden": [], "disabled": ["scale"], "key": "plugin/views/buttons", "persist": True} + # Button state reaches every workspace + assert {entry[3] for entry in app.outbox._queue if entry[1].endswith("/buttons")} == {None} def test_button_state_set_before_adding_the_plugin_is_sent(static): @@ -157,3 +161,96 @@ def test_find_geometry_by_guid(): app.add_geometry(box) assert app.find_geometry(str(box.guid)) is box assert app.find_geometry("missing") is None + + +def sent_to(app): + """(message, workspace_id) of every queued plugin message.""" + return [(compas_pb.pb_load_bts(entry[0]), entry[3]) for entry in app.outbox._queue if entry[0] and compas_pb.pb_load_bts(entry[0]).get("dispatch") == "plugin"] + + +def test_handlers_answer_the_workspace_that_asked(static): + app = App() + views = make_plugin(static) + app.plugins = [views] + app.get_workspace("beam") + + receive(app, {"dispatch": "plugin", "plugin": "views", "action": "save", "data": {"name": "A"}}, "beam") + assert sent_to(app)[-1][1] == "beam" + + # Outside a handler, send goes to main unless told otherwise + views.send("flash") + views.send("flash", workspace=app.get_workspace("beam")) + views.send("flash", workspace="other") + assert [workspace for _, workspace in sent_to(app)[-3:]] == ["main", "beam", "other"] + + +def test_send_all_reaches_every_workspace(static): + app = App() + views = make_plugin(static) + app.plugins = [views] + views.send_all("views", ["A"], key="views") + assert app.outbox._queue[-1][1:4] == ("plugin/views/views", True, None) + + +def test_shared_state_is_replayed_to_every_workspace(): + server = AppServer() + asyncio.run(server.broadcast(b"shared", "k", workspace_id=None)) + asyncio.run(server.broadcast(b"own", "k", workspace_id="beam")) + with TestClient(server.fastapi_app) as client: + with client.websocket_connect("/ws?workspace=beam") as ws: + assert ws.receive_bytes() == b"shared" + assert ws.receive_bytes() == b"own" + with client.websocket_connect("/ws?workspace=other") as ws: + assert ws.receive_bytes() == b"shared" + + +def make_named(static_dir, plugin_name): + class Named(Plugin): + name = plugin_name + static = static_dir + + def __init__(self): + super().__init__() + self.calls = [] + + @action("ping") + def ping(self, data, workspace): + self.calls.append(workspace.workspace_id) + + return Named() + + +def test_workspaces_show_a_subset_of_the_plugins(static): + app = App() + essentials, draw = make_named(static, "essentials"), make_named(static, "draw") + app.plugins = [essentials, draw] + beam = app.get_workspace("beam") + beam.plugins = [essentials] + + client = TestClient(app.server.fastapi_app) + assert [entry["name"] for entry in client.get("/plugins.json").json()] == ["essentials", "draw"] + assert [entry["name"] for entry in client.get("/plugins.json?workspace=beam").json()] == ["essentials"] + # A workspace without its own list, or not created yet, has all of them + assert len(client.get("/plugins.json?workspace=nowhere").json()) == 2 + + # Messages for a plugin the workspace doesn't show are dropped + receive(app, {"dispatch": "plugin", "plugin": "draw", "action": "ping"}, "beam") + receive(app, {"dispatch": "plugin", "plugin": "essentials", "action": "ping"}, "beam") + assert draw.calls == [] and essentials.calls == ["beam"] + + +def test_workspace_plugins_come_from_the_app(static): + app = App() + essentials = make_named(static, "essentials") + beam = app.get_workspace("beam") + with pytest.raises(ValueError, match="not in App.plugins"): + beam.plugins = [essentials] + app.plugins = [essentials] + beam.plugins = [essentials] + app.plugins = [] + assert beam.plugins == [] + + +def test_workspace_url(): + app = App() + assert app.get_workspace("beam 1").url == app.url + "&workspace=beam%201" From b31216336c5a0b5792b6adabdaecc2578ba0a518 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 19 Oct 2026 09:52:45 +0200 Subject: [PATCH 05/17] feat(viewer): App.on_new_workspace() and Plugin.new_workspace() Set up a workspace a browser tab asks for before it exists (e.g. one a plugin opened with viewer.workspaceUrl), before the tab loads its plugins. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/compas_threejs/viewer/app.py | 21 +++++++++++++++++++-- src/compas_threejs/viewer/plugin.py | 7 +++++++ tests/test_plugins.py | 27 +++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da3e6a4..dc9fe1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `Plugin.attached()`, called once a plugin is added to an App, and `App.find_geometry(guid)`. * Workspace-aware plugins: `@action` handlers get `(data, workspace)`, the `Workspace` of the browser tab that sent the message; inside a handler `Plugin.send` answers that tab, `send(..., workspace=)` picks one, and `Plugin.send_all` reaches every tab. Button state reaches every tab. * `Workspace.plugins`: the subset of `App.plugins` a workspace's tabs show, served at `/plugins.json?workspace=`. `Workspace.url`. +* `App.on_new_workspace(callback)` and `Plugin.new_workspace(workspace)`: set up a workspace a browser tab asks for before it exists (e.g. one a plugin opened with `viewer.workspaceUrl`), before the tab loads its plugins. ### Changed diff --git a/src/compas_threejs/viewer/app.py b/src/compas_threejs/viewer/app.py index cedae7a..b921fc6 100644 --- a/src/compas_threejs/viewer/app.py +++ b/src/compas_threejs/viewer/app.py @@ -104,6 +104,7 @@ def __init__(self, host: str = "127.0.0.1", websocket_port: int = 9001, frontend # Plugins: none by default, so the viewer starts without a toolbar self._plugins = [] + self._new_workspace_callbacks = [] self.server.add_route("/plugins.json", self._plugins_manifest) # Setter Attributes @@ -168,9 +169,25 @@ def find_geometry(self, guid: str): """The geometry shown in the viewer with this guid (as plugins get it from the frontend), or None.""" return self.inbox.geometry_registry.get(str(guid)) + def on_new_workspace(self, callback: callable) -> callable: + """Calls ``callback(workspace)`` when a browser tab asks for a workspace that doesn't exist yet. + + It runs before the tab loads its plugins, so the callback can set `Workspace.plugins` + and fill the workspace - e.g. for a tab a plugin opened with `viewer.workspaceUrl(id)`. + Plugins get the same through `Plugin.new_workspace`. Usable as a decorator. + """ + self._new_workspace_callbacks.append(callback) + return callback + def _plugins_manifest(self, workspace: str = "main"): - plugins = self.workspaces[workspace].plugins if workspace in self.workspaces else self._plugins - return [plugin.manifest() for plugin in plugins] + # The page asks for its plugins first of all, so this is where a tab's workspace starts. + if workspace not in self.workspaces: + created = self.get_workspace(workspace) + for plugin in self._plugins: + plugin.new_workspace(created) + for callback in self._new_workspace_callbacks: + callback(created) + return [plugin.manifest() for plugin in self.workspaces[workspace].plugins] def _mount_plugins(self): for plugin in self._plugins: diff --git a/src/compas_threejs/viewer/plugin.py b/src/compas_threejs/viewer/plugin.py index aa2f6e6..06773cc 100644 --- a/src/compas_threejs/viewer/plugin.py +++ b/src/compas_threejs/viewer/plugin.py @@ -151,6 +151,13 @@ def attached(self): if self._hidden or self._disabled: self.send_buttons() + def new_workspace(self, workspace): + """Called when a browser tab asks for a workspace that doesn't exist yet, before it loads its plugins. + + A plugin that opens tabs (`viewer.workspaceUrl(id)` in the frontend) overrides it to + set up the workspace: its `Workspace.plugins`, and its content. See `App.on_new_workspace`. + """ + def send_buttons(self): """Sends every workspace which groups and buttons are hidden or disabled; kept for browsers that connect later.""" if not self.app: diff --git a/tests/test_plugins.py b/tests/test_plugins.py index f296486..7d63347 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -254,3 +254,30 @@ def test_workspace_plugins_come_from_the_app(static): def test_workspace_url(): app = App() assert app.get_workspace("beam 1").url == app.url + "&workspace=beam%201" + + +def test_a_tab_asking_for_a_new_workspace_sets_it_up_first(static): + app = App() + essentials, timber = make_named(static, "essentials"), make_named(static, "timber") + seen = [] + + class Timber(type(timber)): + def new_workspace(self, workspace): + seen.append(("plugin", workspace.workspace_id)) + workspace.plugins = [self] + + timber = Timber() + app.plugins = [essentials, timber] + + @app.on_new_workspace + def created(workspace): + seen.append(("app", workspace.workspace_id)) + + client = TestClient(app.server.fastapi_app) + assert [entry["name"] for entry in client.get("/plugins.json?workspace=beam").json()] == ["timber"] + assert seen == [("plugin", "beam"), ("app", "beam")] + # Only once, and not for workspaces Python made + client.get("/plugins.json?workspace=beam") + app.get_workspace("column") + client.get("/plugins.json?workspace=column") + assert len(seen) == 2 From a5518d3beb6cbe633082c9c0ecfbb156a5c58165 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 19 Oct 2026 09:54:22 +0200 Subject: [PATCH 06/17] docs: plugin proposals for multi-workspace apps Co-Authored-By: Claude Opus 5.5 --- PLUGIN_PROPOSALS.md | 212 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 PLUGIN_PROPOSALS.md diff --git a/PLUGIN_PROPOSALS.md b/PLUGIN_PROPOSALS.md new file mode 100644 index 0000000..c1626e6 --- /dev/null +++ b/PLUGIN_PROPOSALS.md @@ -0,0 +1,212 @@ +# Plugin system: proposals for multi-workspace apps + +Next steps for viewer plugins, worked out against a real app, +[`timber_model_viewer`](#case-study-timber_model_viewer). These are proposals, not +decisions. + +## Where the plugin system stands + +The viewer has no toolbar of its own. Everything interactive comes from plugins, +installed with pip and enabled in Python: + +```python +app = App() +app.plugins = [Essentials(), Views(), Draw()] # toolbar sections, in this order +``` + +A plugin is one Python package with two halves: + +- **Frontend** - `.vue` components built into `static/plugin.js` (shipped in the + wheel). It declares named toolbar `groups` of buttons and `objectActions` (shown in + the FUNCTIONS panel for the selected object), and builds its behaviour on the + viewer's primitives (`useViewer()`, `viewer.three`). +- **Python** - a `Plugin` subclass: `@action` handlers for what the frontend sends, + `send()` to answer, and `hide` / `show` / `disable` / `enable` for its buttons. + +The viewer page loads the plugins listed at `/plugins.json`; `vue`, `three` and the +viewer API are shared through an import map. Existing plugins: +`compas_threejs_essentials` (gizmo, hide/lock, screenshot), `compas_threejs_views` +(view presets, saved views) and `compas_threejs_draw` (drawing tools). + +Implemented on `feature/plugins` in `compas_threejs` and `compas_threejs_ts`. + +## Case study: timber_model_viewer + +The timber app is a good test of what plugins still lack. Today it: + +1. **Opens a workspace per element.** "Open beam" (an object action defined in + Python) creates a workspace (`app.get_workspace(str(beam.guid))`), fills it with + that beam and its processings, and calls `workspace.open_in_browser()`, which opens + a new tab on the same viewer page with `?workspace=`. +2. **Serves side pages.** The tree, graph and table viewers are separate HTML pages + (`app.add_static_mount("/tree", ...)`) fed by JSON routes + (`app.add_route("/tree/api/tree", ...)`). +3. **Adds its own tools.** Load model, Process joinery, Export BTLX - through a custom + frontend build (`frontend-src/`) on an earlier viewer extension branch. + +Much of this already fits a plugin. A `Timber` plugin's Python half has `self.app`, so +it can create workspaces, add routes, and serve extra pages from its own `static/` +folder (served at `/plugins/timber/`). Load model and Process joinery become toolbar +buttons; Open beam and Export BTLX become object actions. The app would then be: + +```python +app.plugins = [Essentials(), Views(), Timber()] +``` + +with no custom viewer build. Four things stand in the way. + +## Proposals + +### 1. Workspace-aware plugins + +**Problem.** A plugin only talks to the main workspace: `Plugin.send()` broadcasts to +`"main"`, and `@action` handlers aren't told which workspace (browser tab) a message +came from - the inbox knows it, but `Plugin.handle` drops it. A button in a beam's tab +can't answer that tab. + +**Proposal.** Pass the workspace through, both ways: + +```python +class Timber(Plugin): + name = "timber" + + @action("open_element") + def _open(self, data, workspace): # the workspace the click came from + beam_ws = self.app.get_workspace(data["guid"]) + ... + self.send("opened", {"workspace": data["guid"]}, workspace=workspace) +``` + +- Handlers that take a second parameter get the `Workspace`; one-parameter handlers + keep working. +- `send(..., workspace=None)` defaults to the main workspace, as today; `key=` state + is kept per workspace. +- `hide` / `disable` could take a `workspace=` too, so a tab can have its own button + state. + +### 2. Plugins per workspace + +**Problem.** Every tab loads every plugin, from one `/plugins.json`. A beam tab +probably wants Essentials, but not Draw. + +**Proposal.** + +```python +beam_ws = app.get_workspace(str(beam.guid)) +beam_ws.plugins = [essentials, timber] # same instances, a subset +``` + +- The page asks for `/plugins.json?workspace=`; a workspace without its own list + gets `app.plugins`. +- Plugin instances are shared across workspaces (one Python state per plugin), which + is why proposal 1 is needed: the plugin learns which tab is talking to it. + +### 3. Open a workspace in the user's browser + +**Problem.** `workspace.open_in_browser()` calls `webbrowser.open` on the machine +running Python. With `App(host="0.0.0.0")` and the viewer used from another computer, +the tab opens on the wrong machine (or nowhere). + +**Proposal.** Let the frontend open it, from the click that asked for it: + +- a viewer primitive `viewer.workspaceUrl(id)` (the current page's URL with + `workspace=`), so a plugin button can `window.open(viewer.workspaceUrl(guid))`; +- or a Python call that asks the browser to do it: + `workspace.open_in_browser(from_workspace=workspace)` sending an "open this URL" + message to that tab. + +The first is a two-line primitive and keeps the opening in the button that was +clicked (browsers block pop-ups that aren't triggered by a click). Python still +creates and fills the workspace first. + +### 4. Panels + +**Problem.** The tree, graph and table are separate pages. They work, but they don't +know about the 3D view: clicking a beam in the tree can't select it in the viewer. + +**Proposal.** A third extension point next to `groups` and `objectActions`: + +```ts +definePlugin({ + name: "timber", + groups: { model: { load: LoadButton, joinery: JoineryButton } }, + objectActions: { open: OpenElementAction, btlx: ExportBtlxAction }, + panels: { tree: TreePanel, graph: GraphPanel, table: TablePanel }, +}); +``` + +- A panel is a Vue component docked in the viewer (left, right or bottom - the docking + layout from the earlier panel-docking work, PR #28, could be reused), shown, hidden + and disabled by id from Python like buttons. +- It has the same `useViewer()` as buttons, so it can select objects + (`viewer.selection`), follow the selection, and talk to its Python half over its + channel instead of separate HTTP routes. +- Separate pages stay possible for anything that needs a full window - the plugin's + `static/` folder is already served. + +## Suggested order + +1. **Workspace-aware plugins** and **plugins per workspace** (proposals 1 and 2): + small, needed by any multi-tab plugin, and backward compatible. +2. **`viewer.workspaceUrl`** (proposal 3): a tiny core primitive. +3. **Panels** (proposal 4): the larger design question - best done while porting the + timber app as the first plugin outside the three we have, so the API is shaped by a + real use. + +## Open questions + +- Should a workspace's plugin list be fixed when the tab opens, or changeable live? +- Panels: one shared dock per side, or one dock per plugin? Resizable? Remembered per + user (browser storage) or set from Python? +- Cross-plugin talk is so far a shared-state primitive (`viewer.state.snap`, set by + Draw and read by Essentials). If more of those appear, a general mechanism + (`viewer.provide` / `viewer.inject` between plugins) may be cleaner than adding each + one to the core. + +## Ideas for more plugins + +Plugins the COMPAS ecosystem could have, with what each would need from the viewer +core - which shows where the plugin API still has to grow. + +### General tools + +Useful whatever is being modelled. + +| Plugin | What it does | Frontend / Python | Needs from core | +|---|---|---|---| +| **Measure** | Distances, angles and areas, clicked and snapped like Draw | Mostly frontend; Python can read the results | Nothing new: `beginInteraction`, `pointerRay` and object snap exist | +| **Section** | Clipping planes / section boxes to look inside a model | Frontend; Python sets planes for a figure or report | A clipping-plane primitive (renderer setting) | +| **Scene tree / Layers** | Objects grouped by layer or type; show, hide, lock, isolate | Python holds the groups; frontend shows them | `panels` (proposal 4) | +| **Inspector** | Attributes and metadata of the selection, editable, with changes sent back to Python | Both | `panels`; could replace the core's metadata panel | +| **Parameters** | Sliders, number fields, selects driving a Python function live | Both | Nothing - the core's `ui` elements (Slider, Button, ...) could move here, as the toolbar did | +| **Timeline / Animation** | Play / pause / scrub frames computed in Python | Both | Cheap per-frame updates (transforms without rebuilding); PR #20 (Play/Pause object motion) is a start | +| **Export** | glTF / OBJ / STL of the scene, or COMPAS JSON | Python exports; frontend downloads | Nothing | +| **Review / Annotations** | Pins and notes on objects, stored in Python, shared by everyone viewing | Both | Workspace-aware plugins (proposal 1) | + +### Plugins for COMPAS packages + +| Plugin | Package | What it would add | +|---|---|---| +| **Timber** | `compas_timber` | Load model, process joinery, element tree / graph / table, open element, export BTLX - the [case study](#case-study-timber_model_viewer) | +| **Robots** | `compas_robots` | A robot model with joint sliders, configuration snapshots, end-effector frame | +| **Fab** | `compas_fab` | Plan motions on the backend, play trajectories on a timeline, show collisions (PR #2, compas_fab trajectory integration, started this) | +| **Slicer** | `compas_slicer` | A layer slider, toolpath preview coloured by speed or height, print-order playback | +| **Model** | `compas_model` | Element tree, interaction graph, element properties - a general base Timber could build on | +| **Eve** | `compas_eve` | Subscribe to pub/sub topics and show live data: sensors, a robot's current pose, a remote script's output | +| **Brep** | `compas_occ` / `compas_brep` | Booleans, fillets and offsets on selected objects, computed in Python - "CAD actions" as object actions next to Draw | +| **Analysis** | `compas_fea2`, results in general | Colour maps on meshes, a legend, deformed shapes with a scale slider | +| **Form finding** | `compas_fd`, `compas_ags` | Live form finding: drag supports or change force densities, Python re-solves, the viewer updates | +| **IFC** | `compas_ifc` | Load IFC, browse the spatial tree, show element properties | + +### Where to start + +- **Measure** - small and frontend-only; shows that tools beyond drawing work on the + current primitives. +- **Robots, then Fab** - wide interest, existing PR work to reuse, and they drive the + timeline / animation primitives that Slicer and Analysis also need. +- **Timber** - the real multi-workspace app, which shapes proposals 1, 2 and 4. + +Several general tools are building blocks that domain plugins would reuse: Timber +wants the scene tree, while Fab, Slicer and Analysis want the timeline. That argues +for the general way for plugins to talk to each other (see the last open question) +rather than each domain plugin rebuilding its own copies. From 4342e0b12db89f75ec61e94ccc5484aa26a0b0d0 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 25 Sep 2026 11:21:15 +0200 Subject: [PATCH 07/17] fix(viewer): keep metadata fields in order, and the registry unchanged A picked object's metadata travels as a protobuf map, which loses the order of its fields: they showed in the viewer in any order. The message now carries that order as "__order__", for the viewer to show them in. Sending no longer writes "dispatch" into the registered Metadata object itself: the message is a copy. Co-Authored-By: Claude Opus 5.5 --- src/compas_threejs/viewer/inbox.py | 9 ++++-- tests/test_object_picked_metadata.py | 48 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 tests/test_object_picked_metadata.py diff --git a/src/compas_threejs/viewer/inbox.py b/src/compas_threejs/viewer/inbox.py index 0290ca1..8378f53 100644 --- a/src/compas_threejs/viewer/inbox.py +++ b/src/compas_threejs/viewer/inbox.py @@ -142,9 +142,12 @@ def _handle_object_picked(self, message, outbox, workspace_id): console.log(f"[blue]Received object picked message from frontend. Object ID: {object_id}[/blue]") metadata = self.metadata_registry.get(object_id) if metadata: - metadata["dispatch"] = "object_infos" - console.log(f"[blue]Metadata associated with the object: {metadata}[/blue]") - outbox.send_dict(metadata.metadata, workspace_id=workspace_id) + fields = dict(metadata.metadata) + console.log(f"[blue]Metadata associated with the object: {fields}[/blue]") + # The message is a protobuf map, which loses the fields' order: "__order__" + # carries it for the viewer to show them in. + message = {**fields, "dispatch": "object_infos", "__order__": list(fields)} + outbox.send_dict(message, workspace_id=workspace_id) else: outbox.send_dict( { diff --git a/tests/test_object_picked_metadata.py b/tests/test_object_picked_metadata.py new file mode 100644 index 0000000..a86563c --- /dev/null +++ b/tests/test_object_picked_metadata.py @@ -0,0 +1,48 @@ +import unittest + +from compas_threejs.metadata import Metadata +from compas_threejs.viewer.inbox import Inbox + + +class _Outbox: + def __init__(self): + self.messages = [] + + def send_dict(self, message, *, workspace_id="main", remove_key=None, obj_id=None): + self.messages.append(message) + + +class _App: + def __init__(self): + self.outbox = _Outbox() + self.inbox = Inbox(self) + + +class ObjectPickedMetadataTests(unittest.TestCase): + def setUp(self): + self.app = _App() + self.inbox = self.app.inbox + + def pick(self, guid): + self.inbox._handle_object_picked({"guid": guid}, self.app.outbox, "main") + return self.app.outbox.messages[-1] + + def test_sends_the_fields_with_their_order(self): + metadata = Metadata(Type="Beam", Floor="F1", Length="3.48 m") + self.inbox.register_geometry("beam", object(), metadata) + + message = self.pick("beam") + self.assertEqual(message["dispatch"], "object_infos") + # A protobuf map on the way loses the order: it travels along. + self.assertEqual(message["__order__"], ["Type", "Floor", "Length"]) + self.assertEqual(message["Length"], "3.48 m") + # The registered metadata isn't changed by sending it. + self.assertEqual(list(metadata), ["Type", "Floor", "Length"]) + + def test_without_metadata_says_so(self): + message = self.pick("unknown") + self.assertEqual(message, {"dispatch": "object_infos", "No metadata associated with this object.": ""}) + + +if __name__ == "__main__": + unittest.main() From 210aeb4fefb5465fa4276451ac92d3e39a93c12d Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 19 Oct 2026 13:24:34 +0200 Subject: [PATCH 08/17] fix(viewer): send the theme before the background color on start Co-Authored-By: Claude Opus 5.5 --- src/compas_threejs/viewer/app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/compas_threejs/viewer/app.py b/src/compas_threejs/viewer/app.py index b921fc6..64a04bb 100644 --- a/src/compas_threejs/viewer/app.py +++ b/src/compas_threejs/viewer/app.py @@ -369,9 +369,10 @@ def start(self, show=False): self._mount_plugins() self._initialize_server() - # Re-send current main workspace settings now that the server (and its loop) exists - self.main.background_color = self.main.background_color + # Re-send current main workspace settings now that the server (and its loop) exists. + # The theme first: it resets the background to its own, and the set one wins. self.main.dark_mode = self.main.dark_mode + self.main.background_color = self.main.background_color self.main.camera_damping = self.main.camera_damping self.main.send_default_view() From 90371a791304ca3c8526833aecdef71f64af9ceb Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 19 Oct 2026 13:27:35 +0200 Subject: [PATCH 09/17] feat(viewer): show_geometry_edges - outline one object's edges Co-Authored-By: Claude Opus 5.5 --- src/compas_threejs/viewer/app.py | 4 ++ src/compas_threejs/viewer/workspace.py | 30 ++++++++++++-- tests/test_geometry_edges.py | 56 ++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 tests/test_geometry_edges.py diff --git a/src/compas_threejs/viewer/app.py b/src/compas_threejs/viewer/app.py index 64a04bb..0c36260 100644 --- a/src/compas_threejs/viewer/app.py +++ b/src/compas_threejs/viewer/app.py @@ -481,6 +481,10 @@ def show_geometry(self, geometry): """Shows a previously hidden geometry object in the main workspace.""" self.main.show_geometry(geometry) + def show_geometry_edges(self, geometry, show=True): + """Outlines one object's edges in the main workspace. See `Workspace.show_geometry_edges`.""" + self.main.show_geometry_edges(geometry, show) + def add_text(self, text, material=None): """Adds a text object to the main workspace.""" self.main.add_text(text, material) diff --git a/src/compas_threejs/viewer/workspace.py b/src/compas_threejs/viewer/workspace.py index 911676a..e0edc96 100644 --- a/src/compas_threejs/viewer/workspace.py +++ b/src/compas_threejs/viewer/workspace.py @@ -446,6 +446,7 @@ def remove_object(self, geometry): # for whichever it never had. self.app.outbox.forget(("material", obj_id), workspace_id=self.workspace_id) self.app.outbox.forget(("visibility", obj_id), workspace_id=self.workspace_id) + self.app.outbox.forget(("edges", obj_id), workspace_id=self.workspace_id) # Drops the inbox-side registrations too (geometry/metadata/action-callbacks) - see # Inbox.forget_geometry for why that matters on its own, not just as an outbox mirror. self.app.inbox.forget_geometry(str(obj_id)) @@ -472,14 +473,35 @@ def show_geometry(self, geometry): """ self._set_geometry_visibility(geometry, visible=True) - def _set_geometry_visibility(self, geometry, visible): - # If the geometry is a Brep, target its displayed viewmesh instead + def show_geometry_edges(self, geometry, show=True): + """Outlines one object's edges (or stops), whatever `show_edges` - the scene-wide + setting - says. The outline is as see-through as the object. + + Parameters + ---------- + geometry : compas.geometry.Geometry | compas.datastructures.Mesh + The geometry object. + show : bool, optional + Whether to outline it. Default is True. + """ + obj_id = self._displayed(geometry).guid + # Same reasoning as _set_geometry_visibility below - stable per-object key. + self.app.outbox.send_dict( + {"dispatch": "handle_geometry", "type": "set_edges", "guid": str(obj_id), "show": bool(show)}, + workspace_id=self.workspace_id, + obj_id=("edges", obj_id), + ) + + def _displayed(self, geometry): + """The object the frontend shows for `geometry`: a Brep's viewmesh, if it has one.""" if isinstance(geometry, Brep): viewmesh = self.app.inbox.brep_viewmesh_registry.get((self.workspace_id, geometry.guid)) if viewmesh: - geometry = viewmesh + return viewmesh + return geometry - obj_id = geometry.guid + def _set_geometry_visibility(self, geometry, visible): + obj_id = self._displayed(geometry).guid # A geometry's visibility is a single current value, so it's persisted under a key # stable per object - re-toggling it overwrites its own slot instead of piling up a # new entry every time (e.g. every hide/show of a processing feature). diff --git a/tests/test_geometry_edges.py b/tests/test_geometry_edges.py new file mode 100644 index 0000000..1dc6f5d --- /dev/null +++ b/tests/test_geometry_edges.py @@ -0,0 +1,56 @@ +import unittest + +from compas.geometry import Box + +from compas_threejs.viewer.inbox import Inbox +from compas_threejs.viewer.workspace import Workspace + + +class _Outbox: + def __init__(self): + self.sent = {} # persisted key -> message + self.forgotten = [] + + def send_dict(self, message, *, workspace_id="main", remove_key=None, obj_id=None): + self.sent[obj_id] = message + + def send_bytes(self, data, obj_id, *, workspace_id="main"): + pass + + def forget(self, key, *, workspace_id="main"): + self.forgotten.append(key) + self.sent.pop(key, None) + + +class _App: + def __init__(self): + self.outbox = _Outbox() + self.inbox = Inbox(self) + + +class GeometryEdgesTests(unittest.TestCase): + def setUp(self): + self.app = _App() + self.workspace = Workspace(self.app) + + def test_edges_are_one_persisted_state_per_object(self): + box = Box(1) + self.workspace.show_geometry_edges(box) + key = ("edges", box.guid) + self.assertEqual( + self.app.outbox.sent[key], + {"dispatch": "handle_geometry", "type": "set_edges", "guid": str(box.guid), "show": True}, + ) + self.workspace.show_geometry_edges(box, False) + self.assertFalse(self.app.outbox.sent[key]["show"]) + self.assertEqual([k for k in self.app.outbox.sent if k[0] == "edges"], [key]) + + def test_removing_an_object_forgets_its_edges(self): + box = Box(1) + self.workspace.show_geometry_edges(box) + self.workspace.remove_object(box) + self.assertIn(("edges", box.guid), self.app.outbox.forgotten) + + +if __name__ == "__main__": + unittest.main() From eccaf3fc775a7e092fab5fddab5c89f011cdfc3a Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 19 Oct 2026 13:32:33 +0200 Subject: [PATCH 10/17] feat(viewer): Plugin.notify, and plugin panels hidden or disabled by id Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++++++ src/compas_threejs/viewer/plugin.py | 27 ++++++++++++++++++++++++--- tests/test_plugins.py | 14 ++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc9fe1c..f8caf56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Workspace-aware plugins: `@action` handlers get `(data, workspace)`, the `Workspace` of the browser tab that sent the message; inside a handler `Plugin.send` answers that tab, `send(..., workspace=)` picks one, and `Plugin.send_all` reaches every tab. Button state reaches every tab. * `Workspace.plugins`: the subset of `App.plugins` a workspace's tabs show, served at `/plugins.json?workspace=`. `Workspace.url`. * `App.on_new_workspace(callback)` and `Plugin.new_workspace(workspace)`: set up a workspace a browser tab asks for before it exists (e.g. one a plugin opened with `viewer.workspaceUrl`), before the tab loads its plugins. +* Plugin panels: `hide`/`show`/`disable`/`enable` also take the ids of a plugin's docked panels. +* `Plugin.notify(message, kind)`: a short message for the user, at the bottom of the view. +* `App.show_geometry_edges(geometry, show=True)` / `Workspace.show_geometry_edges`: outline one object's edges, whatever the scene-wide `show_edges`. + +### Fixed + +* A picked object's metadata shows its fields in order (sent along as `"__order__"`); sending it no longer writes into the registered `Metadata`. +* `App.start` sends the theme before the background color, so a background set with dark mode on is kept. ### Changed diff --git a/src/compas_threejs/viewer/plugin.py b/src/compas_threejs/viewer/plugin.py index 06773cc..4dbe3fd 100644 --- a/src/compas_threejs/viewer/plugin.py +++ b/src/compas_threejs/viewer/plugin.py @@ -6,6 +6,8 @@ console = Console() +NOTICE_KINDS = ("info", "success", "warning", "error") + # The workspace whose message a plugin is handling: where `Plugin.send` answers. _asking_workspace = ContextVar("asking_workspace", default=None) @@ -37,9 +39,9 @@ class Plugin: The plugin is one object for all workspaces: a workspace shows the plugins in `Workspace.plugins`, by default all of `App.plugins`. - `hide`, `show`, `disable` and `enable` take the ids of button groups or single - buttons, as the frontend names them in `groups`. A subclass may list them in `ids`, - so that a misspelled id raises instead of doing nothing. + `hide`, `show`, `disable` and `enable` take the ids of button groups, single + buttons or panels, as the frontend names them in `groups` and `panels`. A subclass + may list them in `ids`, so that a misspelled id raises instead of doing nothing. Examples -------- @@ -186,6 +188,25 @@ def send_all(self, action_name, data=None, *, key=None): """ self._send(action_name, data, key, None) + def notify(self, message, kind="info", *, workspace=None): + """Shows `message` to the user for a moment, at the bottom of the view. + + `kind` is "info", "success", "warning" or "error". It goes to one workspace, + chosen like `send`'s; `workspace="all"` shows it in every tab. + """ + if kind not in NOTICE_KINDS: + raise ValueError(f"Unknown notice kind {kind!r}; use one of {', '.join(NOTICE_KINDS)}") + if not self.app: + raise RuntimeError(f"Plugin {self.name!r} is not added to an App") + if workspace == "all": + workspace_id = None + else: + if workspace is None: + workspace = _asking_workspace.get() or "main" + workspace_id = workspace if isinstance(workspace, str) else workspace.workspace_id + message = {"dispatch": "notice", "message": str(message), "kind": kind} + self.app.outbox.send_bytes(compas_pb.pb_dump_bts(message), "", persist=False, workspace_id=workspace_id) + def _send(self, action_name, data, key, workspace_id): if not self.app: raise RuntimeError(f"Plugin {self.name!r} is not added to an App") diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 7d63347..66e84ad 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -281,3 +281,17 @@ def created(workspace): app.get_workspace("column") client.get("/plugins.json?workspace=column") assert len(seen) == 2 + + +def test_notify_shows_a_message_in_one_workspace_or_all(static): + app = App() + views = make_plugin(static) + app.plugins = [views] + views.notify("Saved", "success") + data, key, persist, workspace_id = app.outbox._queue[-1][:4] + assert compas_pb.pb_load_bts(data) == {"dispatch": "notice", "message": "Saved", "kind": "success"} + assert (key, persist, workspace_id) == ("", False, "main") + views.notify("Hello", workspace="all") + assert app.outbox._queue[-1][3] is None + with pytest.raises(ValueError): + views.notify("Hm", "loud") From e7e90d1d7c38621b8601b269d3ddfe41f55a12b8 Mon Sep 17 00:00:00 2001 From: Gozzi Eric Date: Mon, 26 Oct 2026 15:46:00 +0100 Subject: [PATCH 11/17] fix(viewer): plugin bundle URLs carry their file's version A browser kept a stale plugin.js after a rebuild: plugin bundles have no Cache-Control. Each path in plugins.json now ends in ?v=, so a rebuilt plugin gets a new URL. Co-Authored-By: Claude Opus 5.5 --- src/compas_threejs/viewer/plugin.py | 15 ++++++++++++--- tests/test_plugins.py | 21 ++++++++++++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/compas_threejs/viewer/plugin.py b/src/compas_threejs/viewer/plugin.py index 4dbe3fd..c44959b 100644 --- a/src/compas_threejs/viewer/plugin.py +++ b/src/compas_threejs/viewer/plugin.py @@ -75,12 +75,21 @@ def static_dir(self): return Path(self.static) if self.static else None def manifest(self): - """The entry `plugins.json` lists for this plugin; paths are relative to the page.""" - entry = {"name": self.name, "module": f"plugins/{self.name}/plugin.js"} + """The entry `plugins.json` lists for this plugin; paths are relative to the page. + + Each path carries the file's modification time (``?v=...``), so a browser loads a + rebuilt plugin instead of the copy it cached. + """ + entry = {"name": self.name, "module": self._versioned("plugin.js")} if (self.static_dir / "plugin.css").exists(): - entry["style"] = f"plugins/{self.name}/plugin.css" + entry["style"] = self._versioned("plugin.css") return entry + def _versioned(self, filename): + path = self.static_dir / filename + version = f"?v={path.stat().st_mtime_ns}" if path.exists() else "" + return f"plugins/{self.name}/{filename}{version}" + def on(self, action_name): """Adds a handler for `action_name` from outside the plugin, as a decorator. diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 66e84ad..d0ae4a6 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,5 +1,6 @@ import asyncio import json +import os import compas_pb import pytest @@ -58,11 +59,21 @@ class Other(Plugin): app._mount_plugins() client = TestClient(app.server.fastapi_app) - assert client.get("/plugins.json").json() == [ - {"name": "views", "module": "plugins/views/plugin.js"}, - {"name": "other", "module": "plugins/other/plugin.js", "style": "plugins/other/plugin.css"}, - ] - assert client.get("/plugins/views/plugin.js").text == "export default { name: 'views' };" + manifest = client.get("/plugins.json").json() + assert [entry["name"] for entry in manifest] == ["views", "other"] + assert manifest[0]["module"].startswith("plugins/views/plugin.js?v=") and "style" not in manifest[0] + assert manifest[1]["module"].startswith("plugins/other/plugin.js?v=") + assert manifest[1]["style"].startswith("plugins/other/plugin.css?v=") + assert client.get(manifest[0]["module"]).text == "export default { name: 'views' };" + + +def test_a_rebuilt_plugin_gets_a_new_url(static): + plugin = make_plugin(static) + before = plugin.manifest()["module"] + path = static / "plugin.js" + stat = path.stat() + os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000_000)) + assert plugin.manifest()["module"] != before def test_plugin_names_must_be_unique(static): From 6fadb577d1410b186baf9c5bea20d2cbf7b7c2fb Mon Sep 17 00:00:00 2001 From: Gozzi Eric Date: Mon, 26 Oct 2026 15:46:06 +0100 Subject: [PATCH 12/17] feat(viewer): Plugin.in_main - a plugin made for other tabs The main workspace leaves out a plugin whose in_main is False (an element app's editor, say) unless its Workspace.plugins says otherwise; other workspaces show all of App.plugins as before. Co-Authored-By: Claude Opus 5.5 --- src/compas_threejs/viewer/plugin.py | 5 ++++- src/compas_threejs/viewer/workspace.py | 5 ++++- tests/test_plugins.py | 13 +++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/compas_threejs/viewer/plugin.py b/src/compas_threejs/viewer/plugin.py index c44959b..3634699 100644 --- a/src/compas_threejs/viewer/plugin.py +++ b/src/compas_threejs/viewer/plugin.py @@ -37,7 +37,8 @@ class Plugin: to every tab. The plugin is one object for all workspaces: a workspace shows the plugins in - `Workspace.plugins`, by default all of `App.plugins`. + `Workspace.plugins`, by default all of `App.plugins` - but the main workspace leaves + out a plugin whose `in_main` is False, one made for other tabs (an element's editor). `hide`, `show`, `disable` and `enable` take the ids of button groups, single buttons or panels, as the frontend names them in `groups` and `panels`. A subclass @@ -56,6 +57,8 @@ class Plugin: name = None static = None ids = None + #: Whether the main workspace shows it by default (`Workspace.plugins`) + in_main = True def __init__(self): if not self.name: diff --git a/src/compas_threejs/viewer/workspace.py b/src/compas_threejs/viewer/workspace.py index e0edc96..a14bbb6 100644 --- a/src/compas_threejs/viewer/workspace.py +++ b/src/compas_threejs/viewer/workspace.py @@ -82,13 +82,16 @@ def __init__(self, app, workspace_id: str = "main"): def plugins(self) -> list: """The plugins this workspace's browser tabs show: a subset of `App.plugins`. - By default all of them. Plugins are shared between workspaces - the same objects, + By default all of them - in the main workspace, those whose `Plugin.in_main` is + True. Plugins are shared between workspaces - the same objects, told which workspace each message comes from. A tab loads the list when it opens. >>> beam = app.get_workspace("beam") >>> beam.plugins = [essentials, timber] """ if self._plugins is None: + if self.workspace_id == "main": + return [plugin for plugin in self.app.plugins if plugin.in_main] return self.app.plugins return [plugin for plugin in self._plugins if plugin.app is self.app] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index d0ae4a6..16e7d73 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -250,6 +250,19 @@ def test_workspaces_show_a_subset_of_the_plugins(static): assert draw.calls == [] and essentials.calls == ["beam"] +def test_the_main_workspace_leaves_out_plugins_not_in_main(static): + app = App() + essentials, editor = make_named(static, "essentials"), make_named(static, "editor") + editor.in_main = False # made for other tabs, e.g. an element's editor + app.plugins = [essentials, editor] + assert app.main.plugins == [essentials] + assert app.get_workspace("beam").plugins == [essentials, editor] + + client = TestClient(app.server.fastapi_app) + assert [entry["name"] for entry in client.get("/plugins.json").json()] == ["essentials"] + assert [entry["name"] for entry in client.get("/plugins.json?workspace=beam").json()] == ["essentials", "editor"] + + def test_workspace_plugins_come_from_the_app(static): app = App() essentials = make_named(static, "essentials") From db5f826e23e08907d9d3c878250a411ff43a1429 Mon Sep 17 00:00:00 2001 From: Gozzi Eric Date: Mon, 26 Oct 2026 15:46:10 +0100 Subject: [PATCH 13/17] feat(viewer): App.on_object_transform - hear gizmo moves The inbox calls each callback with the moved geometry, the move (in world coordinates) and the workspace, once the geometry is transformed. A callback's error is logged, not raised. Co-Authored-By: Claude Opus 5.5 --- src/compas_threejs/viewer/app.py | 9 +++++++++ src/compas_threejs/viewer/inbox.py | 8 +++++++- tests/test_plugins.py | 13 +++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/compas_threejs/viewer/app.py b/src/compas_threejs/viewer/app.py index 0c36260..ad6a39e 100644 --- a/src/compas_threejs/viewer/app.py +++ b/src/compas_threejs/viewer/app.py @@ -105,6 +105,7 @@ def __init__(self, host: str = "127.0.0.1", websocket_port: int = 9001, frontend # Plugins: none by default, so the viewer starts without a toolbar self._plugins = [] self._new_workspace_callbacks = [] + self._transform_callbacks = [] self.server.add_route("/plugins.json", self._plugins_manifest) # Setter Attributes @@ -179,6 +180,14 @@ def on_new_workspace(self, callback: callable) -> callable: self._new_workspace_callbacks.append(callback) return callback + def on_object_transform(self, callback: callable) -> callable: + """Calls ``callback(geometry, transformation, workspace)`` when the user moves an object + in a browser tab (the gizmo), once ``geometry`` is transformed. ``transformation`` is + the move, in world coordinates. Usable as a decorator. + """ + self._transform_callbacks.append(callback) + return callback + def _plugins_manifest(self, workspace: str = "main"): # The page asks for its plugins first of all, so this is where a tab's workspace starts. if workspace not in self.workspaces: diff --git a/src/compas_threejs/viewer/inbox.py b/src/compas_threejs/viewer/inbox.py index 8378f53..1155671 100644 --- a/src/compas_threejs/viewer/inbox.py +++ b/src/compas_threejs/viewer/inbox.py @@ -200,7 +200,13 @@ def _handle_object_transform(self, message, outbox, workspace_id): geometry.transform(transformation) if self.app is not None: - self.app.get_workspace(workspace_id).update_geometry(geometry) + workspace = self.app.get_workspace(workspace_id) + workspace.update_geometry(geometry) + for callback in list(self.app._transform_callbacks): + try: + callback(geometry, transformation, workspace) + except Exception as error: # a script's callback must not break the server + console.log(f"[red]Error in {getattr(callback, '__name__', callback)}: {error}[/red]") def _handle_create_geometry(self, message, outbox, workspace_id): """Creates a new backend geometry object from a frontend "Add Box/Sphere/Point" action. diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 16e7d73..b689b25 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -319,3 +319,16 @@ def test_notify_shows_a_message_in_one_workspace_or_all(static): assert app.outbox._queue[-1][3] is None with pytest.raises(ValueError): views.notify("Hm", "loud") + + +def test_a_gizmo_move_is_told_to_on_object_transform(): + from compas.geometry import Point + + app = App() + point = Point(0, 0, 0) + app.add_geometry(point) + moves = [] + app.on_object_transform(lambda geometry, transformation, workspace: moves.append((geometry, workspace.workspace_id))) + matrix = [[1, 0, 0, 2], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] + receive(app, {"dispatch": "object_transform", "guid": str(point.guid), "matrix": matrix}) + assert moves == [(point, "main")] and point.x == 2 From 0251c33fd29a15f028e19574b174a367ca709fe9 Mon Sep 17 00:00:00 2001 From: Gozzi Eric Date: Thu, 29 Oct 2026 11:19:08 +0100 Subject: [PATCH 14/17] docs: plugins - how they work, both halves explained, and a tutorial A Plugins section: an overview with diagrams (the two halves, how a tab loads its plugins, messages, workspaces); a page each on the Python half (@action, Plugin.on, the message path, where send goes) and the frontend half (loading, mounting, channels, hide/disable, shortcuts, beginInteraction with a measuring tool); and a tutorial building a Notes plugin end to end. Adds the Plugin API reference and Mermaid diagrams. installation.md linked FRONTEND_WORKFLOW.md outside docs/, which failed mkdocs build --strict; it now links to the file on GitHub. Co-Authored-By: Claude Opus 5.5 --- docs/assets/images/plugins/measure.png | Bin 0 -> 31973 bytes docs/assets/images/plugins/notes.png | Bin 0 -> 35114 bytes docs/installation.md | 2 +- docs/plugins/frontend.md | 478 ++++++++++++++++++ docs/plugins/index.md | 251 ++++++++++ docs/plugins/python.md | 324 +++++++++++++ docs/plugins/tutorial.md | 643 +++++++++++++++++++++++++ docs/reference/viewer/plugin.md | 3 + mkdocs.yml | 13 +- 9 files changed, 1712 insertions(+), 2 deletions(-) create mode 100644 docs/assets/images/plugins/measure.png create mode 100644 docs/assets/images/plugins/notes.png create mode 100644 docs/plugins/frontend.md create mode 100644 docs/plugins/index.md create mode 100644 docs/plugins/python.md create mode 100644 docs/plugins/tutorial.md create mode 100644 docs/reference/viewer/plugin.md diff --git a/docs/assets/images/plugins/measure.png b/docs/assets/images/plugins/measure.png new file mode 100644 index 0000000000000000000000000000000000000000..baa8b919492b465b3e1aeac882d05b6823f9dbef GIT binary patch literal 31973 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

sY-2M=* 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 RG#UVF?`qyDylwvKe*yDV6Yu~4 literal 0 HcmV?d00001 diff --git a/docs/installation.md b/docs/installation.md index a44a352..24a4a96 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -29,7 +29,7 @@ The built frontend isn't committed to this repo, so the viewer won't render yet. Run `invoke pre-build` to npm-install the pinned `compas_threejs_ts` release and vendor its build (no extra clone needed), or see below to build against a local frontend checkout instead. -See [FRONTEND_WORKFLOW.md](../FRONTEND_WORKFLOW.md) for details. +See [FRONTEND_WORKFLOW.md](https://github.com/compas-dev/compas_threejs/blob/main/FRONTEND_WORKFLOW.md) for details. ### Typescript Frontend diff --git a/docs/plugins/frontend.md b/docs/plugins/frontend.md new file mode 100644 index 0000000..20b4a0d --- /dev/null +++ b/docs/plugins/frontend.md @@ -0,0 +1,478 @@ +# The Frontend Half, Explained + +This page goes through the TypeScript half of a plugin step by step: what +`definePlugin` returns, how the page loads it, when each component is mounted, how +messages reach `channel.on`, and how Python's hide and disable reach a button. It ends +with an interactive tool built on `beginInteraction`. + +It is the counterpart of [The Python Half, Explained](python.md). The examples use the +`Notes` plugin from the [tutorial](tutorial.md). + +## `definePlugin`: a plain object + +```ts +export default definePlugin({ + name: "notes", + groups: { edit: { clear: ClearButton } }, + setup(viewer) { /* ... */ }, +}); +``` + +`definePlugin` does nothing at run time. This is all of it: + +```ts +export function definePlugin(plugin: ViewerPlugin): ViewerPlugin { + return plugin; +} +``` + +It exists for TypeScript: wrapping the object checks every field against the +`ViewerPlugin` type and completes the field names in your editor. What the viewer needs +is the **module's default export**: an object with a `name`, and optionally `title`, +`groups`, `objectActions`, `panels`, `api` and `setup`. + +So a plugin module is just an ES module: + +```text +plugin.js +├── imports "vue", "three", "@compas-dev/compas-threejs-ts" (left external, see below) +├── your components, compiled +└── export default { name: "notes", groups: {...}, setup() {...} } +``` + +## From module to screen + +When a tab opens, the page loads each plugin listed in `plugins.json`, then creates the +viewer with all of them: + +```mermaid +flowchart TB + A["fetch plugins.json?workspace=…"] --> B + subgraph load["for each entry, in order"] + B["add <link> for plugin.css"] --> C["import(plugin.js)"] + C -- "ok" --> D["keep module.default"] + C -- "throws" --> E["console.error
Unable to load the "notes" plugin
(skipped, the others still load)"] + end + D --> F + subgraph create["createViewer(container, { plugins })"] + F["check names and ids
(a duplicate throws: no viewer at all)"] --> G["setup(viewer) of each plugin,
in order; keep the cleanups"] + G --> H["mount the Vue app:
toolbar, object actions, panels"] + end + H --> I["WebSocket connects:
scene + kept messages arrive"] +``` + +Points to take from it: + +- **A plugin that fails to import is skipped**, with an error in the browser console. + The viewer still opens with the other plugins. +- **A duplicate is fatal**: two plugins with the same `name`, or one plugin using an id + twice across `groups`, buttons, `objectActions` and `panels`, throw before anything is + mounted. +- **`setup` runs before any component exists**, once per page, in `app.plugins` order. + What it returns is called when the viewer is disposed, in reverse order. +- **Messages come after `setup`.** A handler registered in `setup` is in place before + the first message from Python arrives, including the kept (`key=`) ones. + +## Shared libraries: the import map + +The viewer page declares an import map: + +```json +{ + "imports": { + "vue": "./assets/vue.js", + "three": "./assets/three.js", + "@compas-dev/compas-threejs-ts": "./assets/viewer.js" + } +} +``` + +A plugin built with these three left `external` keeps `import ... from "vue"` in its +`plugin.js`, and the browser resolves it to **the page's copy**: + +```mermaid +flowchart LR + page["viewer page"] --> vue["assets/vue.js"] + page --> three["assets/three.js"] + page --> api["assets/viewer.js"] + notes["notes/plugin.js"] -. "import 'vue'" .-> vue + notes -. "import 'three'" .-> three + notes -. "import '@compas-dev/compas-threejs-ts'" .-> api + views["views/plugin.js"] -.-> vue + views -.-> api +``` + +One copy matters because Vue keeps its state per copy. `useViewer()` asks Vue for the +viewer the page provided; a plugin with its own Vue asks a different Vue, finds +nothing, and throws *"useViewer() must be called inside a viewer component"*. The same +goes for `three`: an object made with another copy of Three.js isn't a scene object to +the page's renderer. + +Any other library (icons, a date library, ...) is bundled into `plugin.js` as usual. + +## Components and `useViewer` + +Components reach the viewer through Vue's `provide`/`inject`. The page provides the +`PluginViewer` once for the whole app, and each button slot adds its own state: + +```mermaid +flowchart TB + app["Vue app
provides PluginViewer"] --> toolbar["Toolbar"] + app --> actions["Object actions"] + app --> panels["Panels"] + toolbar --> slot1["ToolbarSlot (notes/clear)
provides { enabled }"] + slot1 --> btn["ClearButton
useViewer() · useChannel() · useShortcuts()"] + panels --> slot2["ToolbarSlot (notes/list)
provides { enabled }"] + slot2 --> panel["NotesPanel
useViewer()"] +``` + +`useViewer`, `useChannel` and `useShortcuts` use `inject`, and Vue only allows that +**while a component is being set up**: at the top level of ` +``` + +Call them once at the top and use the results anywhere. In a plugin's `setup(viewer)` +there is no component: the viewer is the argument, and `viewer.channel(name)` replaces +`useChannel`. + +## When each part is mounted + +A component's state lives as long as it is mounted, and so do the handlers it +registers. The parts of a plugin are mounted at different times: + +| Part | Mounted | Unmounted | +| --- | --- | --- | +| `setup` | runs once, before the viewer mounts | its cleanup runs when the viewer is disposed | +| `groups` buttons | at start | while Python hides the button or its group | +| `objectActions` | while an object is picked | when nothing is picked, or while hidden | +| `panels` | at start | while hidden - *folding* a panel keeps it mounted | + +A disabled part stays mounted, greyed out and inert. + +This decides **where to keep state and where to listen**: + +- **State shared by components, or that must survive a remount** goes in a module, + outside any component - like `notes.ts`: + + ```ts + export const notes = ref([]); // one per page, for every component + ``` + +- **Listening for state Python keeps** (`send(..., key=...)`) goes in `setup`. An object + action listening for it itself would be mounted only after the user picks + something - long after the kept message arrived, and it isn't sent again. +- **Listening for events meant for one component** can stay in the component with + `useChannel`, which unsubscribes when the component unmounts. + +## Messages + +### Sending + +```ts +const channel = useChannel("notes"); +const sent = channel.send("write", { guid, text }); +``` + +`send` serialises `data` as JSON and returns `true` if the message left. While the +WebSocket isn't open, it returns `false` and **the message is dropped**, not queued. +Sending is fire-and-forget: there is no reply to `await`. Python answers, if it does, as +a separate message. + +### Receiving + +```ts +const unsubscribe = viewer.channel("notes").on("notes", (data) => { + notes.value = data as Note[]; +}); +``` + +Handlers are kept per `plugin/action`. When a message from Python arrives, the viewer +calls **every** handler of that pair, in the order they were added, with the decoded +`data`. A message that arrives with no handler for it is **lost**: there is no buffer. + +```mermaid +sequenceDiagram + participant P as Notes (Python) + participant R as Viewer + participant S as setup handler + participant C as component (useChannel) + + P->>R: {dispatch: "plugin", plugin: "notes",
action: "notes", data: [...]} + Note over R: handlers["notes/notes"] + R->>S: handler(data) + R->>C: handler(data), if mounted + Note over S,C: notes.value = data
Vue re-renders what reads it +``` + +| | `viewer.channel(name).on` | `useChannel(name).on` | +| --- | --- | --- | +| Use in | `setup`, or any code | components only | +| Unsubscribe | call the returned function (return it from `setup`) | automatic, when the component unmounts | + +`data` is typed `unknown`: it is whatever Python sent, so cast it to the shape your +Python half sends (`data as Note[]`), or check it. + +## Hide and disable: from Python to a button + +`notes.disable("clear")` in Python reaches the button like this: + +```mermaid +sequenceDiagram + participant P as notes.disable("clear") + participant R as Viewer store + participant T as Toolbar + participant S as ToolbarSlot (clear) + participant B as ClearButton + + P->>R: plugin_buttons {plugin: "notes",
hidden: [], disabled: ["clear"]} + R->>T: pluginButtons.notes changes + Note over T: hidden ids: not rendered
(group or button) + R->>S: enabled = false + Note over S: greyed out, inert:
no clicks, no focus, no popovers + S->>B: injected { enabled: false } + Note over B: ToolbarButton shows disabled
useShortcuts stands down +``` + +The plugin's components don't need to do anything: wrapping each button, action and +panel, the viewer's slot handles it. Two helpers also follow it: + +- `ToolbarButton` renders as disabled. +- `useShortcuts` in that component ignores its keys. + +The message is kept by the server, so a tab opened later starts with the same state. + +## Keyboard shortcuts + +```ts +useShortcuts({ n: clear, "?": help }); +``` + +- Keys are compared with `event.key.toLowerCase()`: `"n"`, `"escape"`, `"?"`, + `"arrowup"`. +- The listener is on the viewer's root element: keys count while the focus is inside + the viewer, not in another part of a page that embeds it. +- A key is ignored with Ctrl, Cmd or Alt held, while typing in an input, a textarea, a + select or editable content, while the component's button is disabled, and while a + plugin holds input with `beginInteraction`. +- A handled key gets `preventDefault()`. +- Shortcuts are bound while the component is mounted. A hidden button has none. +- Nothing arbitrates between plugins: if two bind the same key, both run. Pick keys the + other plugins in your app don't use. + +`ToolbarButton`'s `shortcut` prop only *displays* the key in the tooltip; the binding is +`useShortcuts`. + +## Interactive tools: `beginInteraction` + +A tool that turns clicks in the view into points (drawing, measuring, placing) needs +the clicks *instead of* the viewer: no picking, no other shortcuts. That is +`viewer.three.beginInteraction(handlers)`, which returns a session: + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Active: beginInteraction(handlers)
clears the selection, focuses the canvas,
state.interacting = true + Active --> Active: pointer and key events
go to handlers + Active --> Idle: session.release() + Active --> Idle: another beginInteraction()
→ handlers.onInterrupt() +``` + +While a session is active: + +- `onPointerDown`, `onPointerMove` and `onKeyDown` get the events. +- Picking is off, and every `useShortcuts` stands down. +- **Orbiting keeps working**, so the user can turn the view mid-tool. + +Only one session exists at a time. Starting a second one ends the first and calls its +`onInterrupt`, so a tool always learns that it has ended. + +Here is a complete tool, a ruler that measures between two clicks on the ground plane: + +![The measuring tool](../assets/images/plugins/measure.png) + +```vue title="frontend/MeasureButton.vue" + + + +``` + +What each `viewer.three` call does here: + +`pointerRay(event)` +: The world-space ray under the pointer, or `null` while the canvas has no size. The + world is **Z-up** and in the same units as COMPAS, so points can go to Python + as they are. + +`addOverlay(object)` +: Adds a Three.js object of the plugin's to the scene. Overlays are never picked, and + the returned function removes them. Disposing their geometry and material is up to + you. + +`beginInteraction` / `release` +: As above. `release()` can be called more than once safely. The `onBeforeUnmount` + releases the session if Python hides the button mid-measure. + +Register it like any button: `groups: { tools: { measure: MeasureButton } }`. The +plugin needs `three` and `@types/three` as dev dependencies to compile. At run time, +`three` is the page's copy. + +Other `viewer.three` calls for tools of this kind: + +| Call | For | +| --- | --- | +| `object(guid)` | the Three.js object of a backend geometry | +| `objects()` | every visible backend object, with its guid (snapping) | +| `pauseNavigation()` | stop orbiting during a drag; call the result to resume | +| `addPickGuard(guard)` | veto picks while `guard(event)` returns true | +| `holdObjectUpdates(guid)` | ignore Python's updates of an object while the user drags it | +| `viewportSize()`, `onResize(listener)` | screen-space overlays | +| `camera`, `canvas` | the Three.js camera and the canvas element | + +`viewer.objects.commitTransform(guid, matrix)` tells Python that an object moved, which +is how a gizmo ends a drag. + +## Plugins that use each other + +A plugin can offer functions to others through `api`: + +```ts +// in the Notes plugin +export interface NotesApi { + count(): number; +} + +export default definePlugin({ + name: "notes", + api: { count: () => notes.value.length } satisfies NotesApi, + /* ... */ +}); +``` + +```ts +// in another plugin, at the moment it's needed +const notesApi = viewer.plugin("notes"); +if (notesApi) console.log(notesApi.count()); +``` + +`viewer.plugin(name)` returns `undefined` when this page doesn't load that plugin; the +app or workspace may not include it. Ask for it when you use it, not in `setup`, +since a plugin listed later isn't set up yet. + +For simple shared settings, `viewer.state.snap` (`{ grid, angle }`) is read and written +by any plugin. A drawing plugin sets the snap steps, and the gizmo follows them. + +## Without Python + +The same plugin object works with the viewer used as a TypeScript library, without the +Python server: + +```ts +import { createViewer } from "@compas-dev/compas-threejs-ts"; +import notes from "./notes-plugin"; + +createViewer(document.querySelector("#app")!, { plugins: [notes] }); +``` + +`channel.send` then returns `false` (unless the viewer was given a `send` option) and +nothing arrives on `channel.on`. A plugin meant for both uses should work without its +Python half, or say that it needs it. + +## Checklist + +- [x] `plugin.js` default-exports `definePlugin({ name, ... })`, with the Python `name`. +- [x] `vue`, `three` and `@compas-dev/compas-threejs-ts` are `external` in the build. +- [x] `useViewer`, `useChannel`, `useShortcuts` are called at the top of ` +``` + +`ToolbarButton` looks like the viewer's own buttons, shows the tooltip with the +shortcut, and greys itself out when Python disables it. `useShortcuts` binds lower-case +`event.key`s while the component is mounted; it stands down while the button is +disabled, while the user types in a field, and while another plugin holds input (a +drawing tool, for instance). `useChannel` is the component version of +`viewer.channel`: its `on` handlers are removed with the component. + +### An object action: `NoteAction.vue` + +```vue title="frontend/NoteAction.vue" + + + +``` + +The component doesn't update `notes` itself: it sends the request and waits for +Python's answer. That round trip is fast, and it means there is only one source of +truth. + +### A panel: `NotesPanel.vue` + +```vue title="frontend/NotesPanel.vue" + + + +``` + +`viewer.state` is reactive, so the picked object's note is highlighted as the +selection changes. + +## 5. Build and install + +```bash +npm run build # writes src/compas_threejs_notes/static/plugin.js + plugin.css +npm run typecheck # optional: vue-tsc over the frontend +pip install -e . # the Python half, editable +``` + +Add `src/compas_threejs_notes/static/` to `.gitignore`: it is a build output, rebuilt +before packaging the wheel. + +## 6. Use it + +```python title="examples/notes.py" +from compas.geometry import Box +from compas.geometry import Sphere + +from compas_threejs.viewer import App +from compas_threejs_notes import Notes + +app = App() +notes = Notes() +app.plugins = [notes] + +box = Box(2, 2, 2) +box.name = "Box" +sphere = Sphere(1, point=[4, 0, 0]) +sphere.name = "Sphere" +app.add_geometry(box) +app.add_geometry(sphere) + +notes.write(box, "Check the dimensions") + + +@notes.on("write") +def written(data, workspace): + print(f"{workspace.workspace_id}: {len(notes.notes)} notes") + + +app.start() +``` + +Run it: the *Notes* panel already shows the script's note. Pick an object and choose +*Add note* in its panel; press ++n++ to clear them all. Open the same URL in a second +tab: both show the same notes. + +`notes.on("write")` adds a handler from outside the plugin; it runs after the +plugin's own. Combine plugins as usual - `app.plugins = [Essentials(), Views(), notes]` - +and control them from the script: + +```python +notes.disable("clear") # read-only: the button greys out, N does nothing +notes.hide("list") # no panel +``` + +### The development loop + +```bash +npm run watch # rebuilds plugin.js on every save +``` + +Reload the browser tab to get the new build: the plugin's URL carries the file's +modification time, so the browser doesn't use a stale copy. Changes to the Python half +need the script restarted. + +## 7. Test the Python half + +Python can be tested without a browser: deliver messages as the frontend would, and +read what the plugin sends from the app's outbox. + +```python title="tests/test_notes.py" +import json + +import compas_pb +import pytest +from compas.geometry import Box + +from compas_threejs.viewer import App +from compas_threejs_notes import Notes + + +def receive(app, action, data=None): + """Delivers a message as the frontend's `channel.send(action, data)` would.""" + message = {"dispatch": "plugin", "plugin": "notes", "action": action, "data": data} + app.on_message(json.dumps(message).encode("utf-8")) + + +def last_sent(app): + return compas_pb.pb_load_bts(app.outbox._queue[-1][0]) + + +def test_a_note_from_the_browser_is_stored_and_sent_back(): + app = App() + notes = Notes() + app.plugins = [notes] + box = Box(1) + app.add_geometry(box) + + receive(app, "write", {"guid": str(box.guid), "text": "Too small"}) + + assert notes.notes == [(box, "Too small")] + sent = last_sent(app) + assert sent["action"] == "notes" + assert sent["data"][0]["text"] == "Too small" + + +def test_an_empty_note_deletes_it(): + app = App() + notes = Notes() + app.plugins = [notes] + box = Box(1) + notes.write(box, "Too small") + notes.write(box, " ") + assert notes.notes == [] + + +def test_ids_are_checked(): + notes = Notes() + notes.disable("clear") + assert notes.disabled == {"clear"} + with pytest.raises(ValueError): + notes.hide("clean") +``` + +## 8. Troubleshooting + +| Symptom | Cause | +| --- | --- | +| `FileNotFoundError: Plugin 'notes' has no built frontend` | Run `npm run build`; check `static` points at the build's `outDir`. | +| The section doesn't appear; the browser console says *Unable to load the "notes" plugin* | `plugin.js` failed to import - look at the error below it. Often a missing default export. | +| *useViewer() must be called inside a viewer component* | Vue was bundled into `plugin.js`; keep `vue`, `three` and the viewer package `external`. | +| The viewer doesn't mount at all | Two plugins with the same name, or a duplicate id within one plugin. | +| *Plugin 'notes' has no handler for 'write'* (Python log) | The action names on both sides differ. | +| `TypeError` in a handler with one argument | Handlers take `(data, workspace)`. | +| A plugin class looks wrong (no colours) | `theme.css` is missing, or a class is built from strings Tailwind can't see. | + +## Where to go from here + +- Interactive tools (a gizmo, drawing): `viewer.three` gives the camera, pointer rays, + overlays and `beginInteraction`, which takes over pointer and keyboard input. The + [frontend page](frontend.md#interactive-tools-begininteraction) adds a measuring tool + to Notes; `compas_threejs_essentials` (gizmo) and `compas_threejs_draw` go further. +- A plugin that other plugins use: export an `api` from `definePlugin`; others call + `viewer.plugin("notes")`. +- Tabs of their own: `viewer.workspaceUrl(id)` and `Plugin.new_workspace`. +- The full Python API: [Plugin reference](../reference/viewer/plugin.md). diff --git a/docs/reference/viewer/plugin.md b/docs/reference/viewer/plugin.md new file mode 100644 index 0000000..8c4e4cd --- /dev/null +++ b/docs/reference/viewer/plugin.md @@ -0,0 +1,3 @@ +# ::: compas_threejs.viewer.plugin.Plugin + +# ::: compas_threejs.viewer.plugin.action diff --git a/mkdocs.yml b/mkdocs.yml index 251c0b6..8d41513 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,7 +66,12 @@ markdown_extensions: - pymdownx.inlinehilite - pymdownx.snippets: check_paths: true - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - def_list - toc: permalink: "¤" @@ -130,9 +135,15 @@ nav: - Shortcuts: shortcuts.md - Tutorials: - Basic Geometry: tutorials/basic_geometry.md + - Plugins: + - How Plugins Work: plugins/index.md + - The Python Half, Explained: plugins/python.md + - The Frontend Half, Explained: plugins/frontend.md + - "Tutorial: Build a Plugin": plugins/tutorial.md - API Reference: - Viewer: - App: reference/viewer/viewer.md + - Plugin: reference/viewer/plugin.md - Materials: - Generic: reference/materials/generic_material.md - Material: reference/materials/material.md From 93dfdf0c24d17bf03b4af407e8b5f4b852fe85ea Mon Sep 17 00:00:00 2001 From: Gozzi Eric Date: Thu, 29 Oct 2026 13:46:31 +0100 Subject: [PATCH 15/17] docs: plugin panels can dock top or bottom Co-Authored-By: Claude Opus 5.5 --- docs/plugins/tutorial.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/plugins/tutorial.md b/docs/plugins/tutorial.md index 7a2ad60..e94f1a2 100644 --- a/docs/plugins/tutorial.md +++ b/docs/plugins/tutorial.md @@ -381,7 +381,8 @@ export default definePlugin({ one shaded segment. - `objectActions` appear in the *Actions* panel while an object is picked. - `panels` with a `title` get a frame whose title bar folds them; `side` is `"right"` - by default, or `"left"` under the toolbar. + by default, `"left"` under the toolbar, or `"top"` / `"bottom"` for a row along that + edge of the view. - `setup` runs once, before the viewer mounts. `channel.on` returns an unsubscribe function, which doubles as `setup`'s cleanup. From f4a157e0c3c757f9b8b2cced5ef6219c7df7b6fe Mon Sep 17 00:00:00 2001 From: Gozzi Eric Date: Thu, 29 Oct 2026 13:51:44 +0100 Subject: [PATCH 16/17] fix(viewer): a failing plugin handler no longer disconnects the tab Plugin.handle logs the traceback and shows the asking tab an error notice; the action's other handlers still run. The server's message loop also survives a failing message. Co-Authored-By: Claude Opus 5.5 --- docs/plugins/python.md | 9 +++--- src/compas_threejs/viewer/plugin.py | 18 ++++++++++-- src/compas_threejs/viewer/server.py | 9 +++++- tests/test_plugins.py | 44 +++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/docs/plugins/python.md b/docs/plugins/python.md index 18e4e02..ff04916 100644 --- a/docs/plugins/python.md +++ b/docs/plugins/python.md @@ -198,10 +198,11 @@ Step by step: numbers, booleans, `None`. Geometry travels as guids; turn one back into the COMPAS object with `self.app.find_geometry(guid)`. -!!! warning "An exception in a handler disconnects the tab" - An uncaught exception in a handler ends that tab's WebSocket: the page stays up, but - no longer talks to Python until it is reloaded. Validate what the browser sends, - and catch what you can handle: +!!! note "An exception in a handler" + A handler that raises is logged with its traceback, and the asking tab shows an + error notice: *"Plugin 'notes' failed on 'write': ..."*. The action's other handlers + still run, and the tab stays connected. Still, validate what the browser sends, and + catch what you can handle, to tell the user something better: ```python @action("write") diff --git a/src/compas_threejs/viewer/plugin.py b/src/compas_threejs/viewer/plugin.py index 3634699..3837a92 100644 --- a/src/compas_threejs/viewer/plugin.py +++ b/src/compas_threejs/viewer/plugin.py @@ -108,7 +108,11 @@ def decorate(callback): return decorate def handle(self, action_name, data, workspace): - """Calls the handlers of `action_name` with `data` and the `Workspace` it came from.""" + """Calls the handlers of `action_name` with `data` and the `Workspace` it came from. + + A handler that raises is logged and shown to the asking tab as an error notice; + the other handlers still run, and the tab stays connected. + """ handlers = self._handlers.get(action_name) if not handlers: console.log(f"[yellow]Plugin {self.name!r} has no handler for {action_name!r}[/yellow]") @@ -116,10 +120,20 @@ def handle(self, action_name, data, workspace): token = _asking_workspace.set(workspace) try: for handler in handlers: - handler(data, workspace) + try: + handler(data, workspace) + except Exception as error: + console.print_exception() + self._notify_failure(action_name, error) finally: _asking_workspace.reset(token) + def _notify_failure(self, action_name, error): + try: + self.notify(f"Plugin {self.name!r} failed on {action_name!r}: {error}", "error") + except Exception: + console.print_exception() + @property def hidden(self): """The ids of the hidden groups and buttons.""" diff --git a/src/compas_threejs/viewer/server.py b/src/compas_threejs/viewer/server.py index e74439c..13c5ccc 100644 --- a/src/compas_threejs/viewer/server.py +++ b/src/compas_threejs/viewer/server.py @@ -8,6 +8,9 @@ from fastapi import WebSocket from fastapi import WebSocketDisconnect from fastapi.staticfiles import StaticFiles +from rich.console import Console + +console = Console() class AppServer: @@ -85,7 +88,11 @@ async def _websocket_endpoint(self, websocket: WebSocket): # thread so a long-running handler (e.g. loading a large model) doesn't # block the loop from delivering messages the handler sends in the # meantime (e.g. a start_spinner() call made at the top of the handler). - await asyncio.to_thread(self.app_instance.on_message, data, workspace_id) + try: + await asyncio.to_thread(self.app_instance.on_message, data, workspace_id) + except Exception: + # A failing message must not end the tab's connection. + console.print_exception() elif "bytes" in message: data = message["bytes"] await self.broadcast(data, workspace_id=workspace_id) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index b689b25..4f3463f 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -332,3 +332,47 @@ def test_a_gizmo_move_is_told_to_on_object_transform(): matrix = [[1, 0, 0, 2], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] receive(app, {"dispatch": "object_transform", "guid": str(point.guid), "matrix": matrix}) assert moves == [(point, "main")] and point.x == 2 + + +def test_a_failing_handler_is_shown_to_the_tab_and_the_others_still_run(static): + app = App() + views = make_plugin(static) + app.plugins = [views] + seen = [] + + @views.on("save") + def broken(data, workspace): + raise ValueError("no name") + + @views.on("save") + def after(data, workspace): + seen.append(data) + + receive(app, {"dispatch": "plugin", "plugin": "views", "action": "save", "data": {"name": "A"}}, "tab") + assert views.saved == [{"name": "A"}] and seen == [{"name": "A"}] + notices = [(compas_pb.pb_load_bts(entry[0]), entry[3]) for entry in app.outbox._queue if compas_pb.pb_load_bts(entry[0]).get("dispatch") == "notice"] + assert notices == [({"dispatch": "notice", "message": "Plugin 'views' failed on 'save': no name", "kind": "error"}, "tab")] + + +def test_a_failing_message_keeps_the_tab_connected(): + import threading + + handled = [] + both = threading.Event() + + class Broken: + def on_message(self, data, workspace_id): + handled.append(data) + if len(handled) == 2: + both.set() + if len(handled) == 1: + raise RuntimeError("broken") + + server = AppServer() + server.app_instance = Broken() + with TestClient(server.fastapi_app) as client: + with client.websocket_connect("/ws") as websocket: + websocket.send_text("first") + websocket.send_text("second") + assert both.wait(5) + assert handled == [b"first", b"second"] From 4136de3898b5a3617480c815898f12ec550f29ae Mon Sep 17 00:00:00 2001 From: Gozzi Eric Date: Thu, 29 Oct 2026 13:57:32 +0100 Subject: [PATCH 17/17] docs: a failing plugin is left out or contained, not fatal Co-Authored-By: Claude Opus 5.5 --- docs/plugins/frontend.md | 13 +++++++++---- docs/plugins/tutorial.md | 5 ++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/plugins/frontend.md b/docs/plugins/frontend.md index 20b4a0d..0484160 100644 --- a/docs/plugins/frontend.md +++ b/docs/plugins/frontend.md @@ -55,7 +55,7 @@ flowchart TB end D --> F subgraph create["createViewer(container, { plugins })"] - F["check names and ids
(a duplicate throws: no viewer at all)"] --> G["setup(viewer) of each plugin,
in order; keep the cleanups"] + F["check names and ids
(a duplicate: that plugin is left out)"] --> G["setup(viewer) of each plugin,
in order; keep the cleanups
(setup throws: that plugin is left out)"] G --> H["mount the Vue app:
toolbar, object actions, panels"] end H --> I["WebSocket connects:
scene + kept messages arrive"] @@ -65,9 +65,14 @@ Points to take from it: - **A plugin that fails to import is skipped**, with an error in the browser console. The viewer still opens with the other plugins. -- **A duplicate is fatal**: two plugins with the same `name`, or one plugin using an id - twice across `groups`, buttons, `objectActions` and `panels`, throw before anything is - mounted. +- **A broken plugin is left out, not the viewer.** A second plugin with a taken `name`, + a plugin using an id twice across `groups`, buttons, `objectActions` and `panels`, or + one whose `setup` throws, is not loaded; the others are. Each failure shows an error + notice and goes to `onError` (code `plugin_error`), or the browser console. +- **A failing component or handler stays contained.** A button, object action or panel + that throws while it sets up or draws becomes a small *!* mark; one that throws later + (in a click) stays. A `channel.on` handler that throws doesn't keep the message from + the others. Both show an error notice. - **`setup` runs before any component exists**, once per page, in `app.plugins` order. What it returns is called when the viewer is disposed, in reverse order. - **Messages come after `setup`.** A handler registered in `setup` is in place before diff --git a/docs/plugins/tutorial.md b/docs/plugins/tutorial.md index e94f1a2..398b6ab 100644 --- a/docs/plugins/tutorial.md +++ b/docs/plugins/tutorial.md @@ -627,7 +627,10 @@ def test_ids_are_checked(): | `FileNotFoundError: Plugin 'notes' has no built frontend` | Run `npm run build`; check `static` points at the build's `outDir`. | | The section doesn't appear; the browser console says *Unable to load the "notes" plugin* | `plugin.js` failed to import - look at the error below it. Often a missing default export. | | *useViewer() must be called inside a viewer component* | Vue was bundled into `plugin.js`; keep `vue`, `three` and the viewer package `external`. | -| The viewer doesn't mount at all | Two plugins with the same name, or a duplicate id within one plugin. | +| *The "notes" plugin uses the id "…" twice: it was not loaded* (error notice) | A duplicate id within one plugin, or two plugins with the same name. | +| *The "notes" plugin was not loaded: its setup failed* | `setup` threw - the browser console has the stack. | +| A *!* mark instead of a button or panel | That component threw while it set up or drew. | +| *Plugin 'notes' failed on 'write': …* (error notice) | A Python handler raised - the traceback is in the Python log. | | *Plugin 'notes' has no handler for 'write'* (Python log) | The action names on both sides differ. | | `TypeError` in a handler with one argument | Handlers take `(data, workspace)`. | | A plugin class looks wrong (no colours) | `theme.css` is missing, or a class is built from strings Tailwind can't see. |