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/docs/assets/images/plugins/measure.png b/docs/assets/images/plugins/measure.png new file mode 100644 index 0000000..baa8b91 Binary files /dev/null and b/docs/assets/images/plugins/measure.png differ diff --git a/docs/assets/images/plugins/notes.png b/docs/assets/images/plugins/notes.png new file mode 100644 index 0000000..7360cca Binary files /dev/null and b/docs/assets/images/plugins/notes.png differ 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..0484160 --- /dev/null +++ b/docs/plugins/frontend.md @@ -0,0 +1,483 @@ +# 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: 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"] +``` + +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 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 + 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 "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. | + +## 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 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..ad6a39e 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,12 @@ 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._transform_callbacks = [] + self.server.add_route("/plugins.json", self._plugins_manifest) + # Setter Attributes self._loop_interval = 0.01 self._loop = None @@ -126,6 +133,78 @@ 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 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: + 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 +375,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 +490,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..1155671 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( { @@ -196,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. @@ -283,6 +293,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..3837a92 --- /dev/null +++ b/src/compas_threejs/viewer/plugin.py @@ -0,0 +1,246 @@ +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` - 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 + 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 + #: Whether the main workspace shows it by default (`Workspace.plugins`) + in_main = True + + 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. + + 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"] = 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. + + >>> @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. + + 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]") + return + token = _asking_workspace.set(workspace) + try: + for handler in handlers: + 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.""" + 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..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: @@ -16,6 +19,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 +63,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: @@ -82,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) @@ -103,17 +113,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 +136,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..a14bbb6 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,40 @@ 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 - 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] + + @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 +449,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 +476,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..4f3463f --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,378 @@ +import asyncio +import json +import os + +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) + + 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): + 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_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") + 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") + + +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 + + +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"]