diff --git a/CHANGELOG.md b/CHANGELOG.md index 19db84c..f8caf56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,27 @@ 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`. +* `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`. +* `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 +* 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/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. 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..0c36260 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,11 @@ 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._new_workspace_callbacks = [] + self.server.add_route("/plugins.json", self._plugins_manifest) + # Setter Attributes self._loop_interval = 0.01 self._loop = None @@ -126,6 +132,70 @@ 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()`. + + A workspace shows all of them, or the subset in its `Workspace.plugins`. + + >>> 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 + 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 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"): + # 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: + 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,11 +366,13 @@ 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 - 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() @@ -409,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/inbox.py b/src/compas_threejs/viewer/inbox.py index 4502971..8378f53 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) ------------------------- @@ -141,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( { @@ -283,6 +287,19 @@ 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 + 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, dispatched by a fixed action name instead - see `register_action`.""" 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 new file mode 100644 index 0000000..4dbe3fd --- /dev/null +++ b/src/compas_threejs/viewer/plugin.py @@ -0,0 +1,220 @@ +from contextvars import ContextVar +from pathlib import Path + +import compas_pb +from rich.console import Console + +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) + + +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, workspace)`` on the server thread, with the + `Workspace` of the browser tab the message came from. + """ + + 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, groups })` from `@compas-dev/compas-threejs-ts`, with the same + `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, 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 + -------- + >>> app = App() + >>> 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) + 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, workspace): + ... print(data, workspace.workspace_id) + """ + + def decorate(callback): + self._handlers.setdefault(action_name, []).append(callback) + return callback + + return decorate + + 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 + token = _asking_workspace.set(workspace) + try: + for handler in handlers: + handler(data, workspace) + finally: + _asking_workspace.reset(token) + + @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 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 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: + 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, workspace_id=None) + + 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 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") + 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, + 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..e0edc96 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 @@ -414,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)) @@ -440,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() 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() diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000..66e84ad --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,297 @@ +import asyncio +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 +from compas_threejs.viewer.server import AppServer + + +@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, workspace): + self.saved.append(data) + self.send("views", [view["name"] for view in self.saved], key="views") + + return Views() + + +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(): + 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, workspace): + seen.append((data, workspace.workspace_id)) + + receive(app, {"dispatch": "plugin", "plugin": "views", "action": "save", "data": {"name": "A"}}) + assert seen == [({"name": "A"}, "main")] + + +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) + + +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} + # 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): + 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") + + +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 + + +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" + + +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 + + +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")