Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
64e0412
feat(viewer): add toolbar plugins with a Python half
ericgozzi Oct 13, 2026
aecdabb
feat(viewer): hide, show, disable and enable plugin buttons from Python
ericgozzi Oct 13, 2026
a56f056
feat(viewer): Plugin.attached() and App.find_geometry() for plugin state
ericgozzi Oct 13, 2026
3e263f3
feat(viewer)!: workspace-aware plugins, and plugins per workspace
ericgozzi Oct 15, 2026
b312163
feat(viewer): App.on_new_workspace() and Plugin.new_workspace()
ericgozzi Oct 19, 2026
a5518d3
docs: plugin proposals for multi-workspace apps
ericgozzi Oct 19, 2026
4342e0b
fix(viewer): keep metadata fields in order, and the registry unchanged
ericgozzi Sep 25, 2026
210aeb4
fix(viewer): send the theme before the background color on start
ericgozzi Oct 19, 2026
90371a7
feat(viewer): show_geometry_edges - outline one object's edges
ericgozzi Oct 19, 2026
eccaf3f
feat(viewer): Plugin.notify, and plugin panels hidden or disabled by id
ericgozzi Oct 19, 2026
e7e90d1
fix(viewer): plugin bundle URLs carry their file's version
ericgozzi Oct 26, 2026
6fadb57
feat(viewer): Plugin.in_main - a plugin made for other tabs
ericgozzi Oct 26, 2026
db5f826
feat(viewer): App.on_object_transform - hear gizmo moves
ericgozzi Oct 26, 2026
0251c33
docs: plugins - how they work, both halves explained, and a tutorial
ericgozzi Oct 29, 2026
93dfdf0
docs: plugin panels can dock top or bottom
ericgozzi Oct 29, 2026
f4a157e
fix(viewer): a failing plugin handler no longer disconnects the tab
ericgozzi Oct 29, 2026
4136de3
docs: a failing plugin is left out or contained, not fatal
ericgozzi Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>`. `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


Expand Down
212 changes: 212 additions & 0 deletions PLUGIN_PROPOSALS.md
Original file line number Diff line number Diff line change
@@ -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=<guid>`.
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=<id>`; 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=<id>`), 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.
Binary file added docs/assets/images/plugins/measure.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/images/plugins/notes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading