Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
133 changes: 133 additions & 0 deletions docs/extending-the-viewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Extending the embeddable viewer

`@compas-dev/compas-threejs-ts` ships an embeddable API (`src/library/index.ts`) built around
`createViewer(container, options)`. Consumers add their own UI - custom toolbar buttons, panels,
whatever - by passing options into that call, not by forking this repo's app shell
(`App.vue`/`Toolbar.vue`/`Sidebar.vue`). `timber_model_viewer/frontend-src` is a real, working
example of this pattern - read alongside this guide.

## Installing the package

```sh
npm install @compas-dev/compas-threejs-ts three vue
```

`three` and `vue` are peer/regular dependencies you provide yourself. Once installed:

```ts
import "@compas-dev/compas-threejs-ts/style.css";
import { createViewer } from "@compas-dev/compas-threejs-ts";

const container = document.getElementById("app")!;
createViewer(container, { mode: "websocket" });
```

While the extension API below is still evolving, point your `package.json` at a branch instead
of a published version:

```json
"@compas-dev/compas-threejs-ts": "github:compas-dev/compas_threejs_ts#<branch>"
```

`npm install` builds the package automatically on install (via its `prepare` script) - no manual
build step in the dependency's checkout required. To pick up new commits on that branch:

```sh
npm update @compas-dev/compas-threejs-ts
```

Once your customization has stabilized, prefer switching to a published semver range
(`^1.x`) - see [`releasing.md`](./releasing.md). Registry installs are reproducible from the
lockfile alone; branch installs re-resolve to whatever the branch currently points at.

## Adding a custom toolbar tool

Pass `toolbarTools` - an array of `{ id, component, order? }` - to `createViewer`. Each
`component` is mounted directly inside the toolbar, after the built-in tool groups:

```ts
// tools/MyTool.vue
```

```vue
<template>
<Button variant="secondary" size="icon" @click="handleClick">Hi</Button>
</template>

<script setup lang="ts">
import { Button } from "@compas-dev/compas-threejs-ts/ui";
import { useViewerMessaging } from "@compas-dev/compas-threejs-ts";

const { sendData } = useViewerMessaging();

function handleClick() {
sendData({ dispatch: "other_action", action: "my_action" });
}
</script>
```

```ts
// main.ts
import { createViewer } from "@compas-dev/compas-threejs-ts";
import MyTool from "./tools/MyTool.vue";

createViewer(container, {
mode: "websocket",
toolbarTools: [{ id: "my-tool", component: MyTool, order: 10 }],
});
```

- `order` controls left-to-right placement among _your_ tools (lower first, default `0`); the
built-in groups (transform, add-object, view, display) always render first, ahead of any
`toolbarTools`.
- Group several related buttons under one entry by wrapping them in a single component (see
`timber_model_viewer/frontend-src/src/tools/CompasTimberGroup.vue`, which bundles five buttons
behind one `ToolDefinition`).

## Talking to your backend

`useViewerMessaging()` is the public, minimal messaging surface - deliberately not the full
internal viewer runtime, so tools depend on a small stable contract instead of internals that
are free to change:

```ts
interface ViewerMessaging {
send(message: unknown): boolean; // sent as-is if already a string/binary, else JSON.stringify'd
sendData(message: Record<string, unknown>): boolean; // always JSON
}
```

Use `sendData` for structured messages, and `send` when you've already built the raw payload
yourself (e.g. splicing a large uploaded JSON file straight into a message envelope without an
extra parse/stringify round trip - see `LoadTimberModel.vue`).

## UI kit

`@compas-dev/compas-threejs-ts/ui` re-exports the subset of the internal component kit that's
stable for building tools with: `Button`, `Kbd`, `KbdGroup`, `Tooltip`, `TooltipContent`,
`TooltipProvider`, `TooltipTrigger`. Use these instead of writing your own so custom tools look
consistent with the built-in toolbar.

## What's public vs. internal

Only what's exported from `@compas-dev/compas-threejs-ts` and `@compas-dev/compas-threejs-ts/ui`
is a stable contract (`src/library/public.d.ts` / `src/library/ui.d.ts` are the hand-maintained
source of truth for what ships - keep them in sync with `src/library/types.ts` / `src/library/
ui.ts` when changing the public surface). Everything else under `src/` - `components/`,
`viewer/`, `composables/`, `communications/`, `conversions/`, `store/` - is free to change
between versions; don't import from `@/...` paths across the package boundary.

## Adding a new extension point

If `toolbarTools` isn't enough for what you're building (e.g. a docked side panel rather than a
toolbar button), extend the pattern rather than forking the app shell:

1. Add the option to `CompasViewerOptions` in `src/library/types.ts`, and mirror it in
`src/library/public.d.ts`.
2. Add an injection key + `useX()` composable in `src/viewer/viewer_context.ts`.
3. `app.provide()` it in `src/library/index.ts`'s `createViewer`.
4. Consume it generically in the relevant layout component (e.g. `Sidebar.vue`) - default to
today's built-in behavior when the option is omitted, so existing consumers (including this
repo's own standalone app, `src/main.ts`) are unaffected.
5. Run `npm run check` before opening a PR - it covers lint, typecheck (including the strict
`tsconfig.core.json` pass over the public-facing files), tests, and both build targets.
65 changes: 65 additions & 0 deletions examples/embedded_custom_tool.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embedded COMPAS ThreeJS custom tool</title>
<link rel="stylesheet" href="../dist-lib/style.css" />
<style>
html,
body,
#viewer {
width: 100%;
height: 100%;
margin: 0;
}

.example-title {
position: fixed;
z-index: 10;
top: 1rem;
left: 1rem;
max-width: 24rem;
padding: 0.75rem 1rem;
color: #172033;
background: rgb(255 255 255 / 88%);
border: 1px solid rgb(23 32 51 / 12%);
border-radius: 0.75rem;
box-shadow: 0 0.5rem 2rem rgb(23 32 51 / 12%);
font:
500 0.875rem/1.45 Inter,
sans-serif;
pointer-events: none;
}

.example-title strong {
display: block;
margin-bottom: 0.2rem;
font-size: 1rem;
}
</style>
<script type="importmap">
{
"imports": {
"vue": "../node_modules/vue/dist/vue.esm-browser.js",
"three": "../node_modules/three/build/three.module.js",
"three/": "../node_modules/three/",
"@gramaziokohler/compas-pb-ts": "../node_modules/@gramaziokohler/compas-pb-ts/dist/index.js",
"@bufbuild/protobuf/wire": "../node_modules/@bufbuild/protobuf/dist/esm/wire/index.js"
}
}
</script>
</head>

<body>
<aside class="example-title">
<strong>Extending the toolbar</strong>
A custom tool passed in via <code>toolbarTools</code>, built from the
public <code>/ui</code> kit and <code>useViewerMessaging()</code> - see
docs/extending-the-viewer.md. Click the ping button to send a message
through the viewer's <code>send</code> option.
</aside>
<div id="viewer" aria-label="3D viewer with a custom toolbar tool"></div>
<script type="module" src="./embedded_custom_tool.js"></script>
</body>
</html>
100 changes: 100 additions & 0 deletions examples/embedded_custom_tool.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { defineComponent, h, ref } from "vue";
import { Box, pbDumpBytes } from "@gramaziokohler/compas-pb-ts";

import { createViewer, useViewerMessaging } from "../dist-lib/index.js";
import {
Button,
Kbd,
KbdGroup,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../dist-lib/ui.js";

// A toolbar tool authored exactly the way a consumer would: a Vue component
// built from the public ui kit (`@compas-dev/compas-threejs-ts/ui`) and
// `useViewerMessaging()`, with no access to viewer internals. It's passed
// into `toolbarTools` below rather than forking Toolbar.vue.
const PingTool = defineComponent({
name: "PingTool",
setup() {
const pingCount = ref(0);
const { sendData } = useViewerMessaging();

function handleClick() {
pingCount.value += 1;
sendData({
dispatch: "other_action",
action: "ping",
count: pingCount.value,
});
}

return () =>
h(TooltipProvider, { delayDuration: 600 }, () =>
h(Tooltip, null, () => [
h(TooltipTrigger, null, () =>
h(
Button,
{
variant: "secondary",
size: "icon",
"data-testid": "ping-tool-button",
onClick: handleClick,
},
() => "Hi",
),
),
h(TooltipContent, { side: "bottom" }, () => [
h("p", null, `Sent ${pingCount.value} ping(s)`),
h(KbdGroup, null, () => [h(Kbd, null, () => "click")]),
]),
]),
);
},
});

const container = document.querySelector("#viewer");
if (!(container instanceof HTMLElement)) {
throw new Error("Viewer container was not found");
}

// Messages sent by tools via `useViewerMessaging()` are routed through this
// `send` option instead of a real backend connection - the same hook a
// consumer would use to wire up their own transport.
const outgoingMessages = [];

const viewer = createViewer(container, {
mode: "embedded",
defaultLighting: true,
showToolbar: true,
toolbarTools: [{ id: "ping-tool", component: PingTool, order: 10 }],
send(message) {
outgoingMessages.push(message);
return true;
},
onError(error) {
console.error(error.code, error.message, error.details);
},
});

const box = new Box({
data: {
guid: crypto.randomUUID(),
name: "Box",
frame: {
point: { x: 0, y: 0, z: 0 },
xaxis: { x: 1, y: 0, z: 0 },
yaxis: { x: 0, y: 1, z: 0 },
},
xsize: 3,
ysize: 3,
zsize: 1,
},
});
viewer.dispatch(pbDumpBytes(box));

document.body.dataset.exampleReady = "true";
window.__compasCustomTool = { viewer, outgoingMessages };
window.addEventListener("pagehide", () => viewer.dispose(), { once: true });
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
"types": "./dist-lib/index.d.ts",
"import": "./dist-lib/index.js"
},
"./ui": {
"types": "./dist-lib/ui.d.ts",
"import": "./dist-lib/ui.js"
},
"./style.css": "./dist-lib/style.css"
},
"files": [
Expand All @@ -55,7 +59,7 @@
"test:package": "node scripts/test-package.mjs",
"check": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build:app && npm run build:library",
"audit:prod": "npm audit --omit=dev --audit-level=high",
"prepare": "git config core.hooksPath .githooks || true",
"prepare": "node scripts/prepare.mjs",
"prepublishOnly": "npm run check && npm run audit:prod",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix",
Expand Down
1 change: 1 addition & 0 deletions scripts/copy-library-types.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { copyFileSync } from "node:fs";

copyFileSync("src/library/public.d.ts", "dist-lib/index.d.ts");
copyFileSync("src/library/ui.d.ts", "dist-lib/ui.d.ts");
32 changes: 32 additions & 0 deletions scripts/prepare.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { spawnSync } from "node:child_process";

// Best-effort: sets up the commit-msg hook for local contributor checkouts.
// Failing here (e.g. no .git directory, such as inside a package tarball)
// must never block the build below - npm's own git-dependency install flow
// depends on this script's exit code reflecting only the build. Captured
// (not inherited) for the same reason as the build step below.
spawnSync("git", ["config", "core.hooksPath", ".githooks"], {
stdio: "pipe",
shell: true,
});

// Captured rather than inherited: some npm versions still run `prepare`
// during `npm pack --ignore-scripts` (that flag reliably skips it in newer
// npm, but not consistently across versions - confirmed by CI using an
// older bundled npm than this repo's pinned packageManager). When that
// happens, anything this script prints to stdout gets interleaved into
// `npm pack --json`'s own stdout and breaks JSON.parse for whoever's
// consuming it (scripts/test-package.mjs). Only surface output - on
// stderr, never stdout - if the build actually fails.
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const build = spawnSync(npmCommand, ["run", "build:library"], {
stdio: "pipe",
shell: true,
encoding: "utf8",
});
if (build.error) throw build.error;
if (build.status !== 0) {
process.stderr.write(build.stdout ?? "");
process.stderr.write(build.stderr ?? "");
}
process.exit(build.status ?? 1);
8 changes: 6 additions & 2 deletions scripts/test-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ function run(command, args, options = {}) {
}

try {
// shell: true - required on Windows to spawn a .cmd shim (npm.cmd) at all;
// Node's spawnSync can't exec one directly without going through a shell.
// Safe here specifically because these args are internally generated
// (this repo's own paths and fixed flag strings), never user input.
const packed = JSON.parse(
run(
npmCommand,
Expand All @@ -47,7 +51,7 @@ try {
"--pack-destination",
consumerRoot,
],
{ cwd: projectRoot },
{ cwd: projectRoot, shell: true },
),
);
const archive = join(consumerRoot, packed[0].filename);
Expand All @@ -60,7 +64,7 @@ try {
type: "module",
}),
);
run(npmCommand, ["install", "--ignore-scripts", archive]);
run(npmCommand, ["install", "--ignore-scripts", archive], { shell: true });

const packageName = "@compas-dev/compas-threejs-ts";
const installed = JSON.parse(
Expand Down
Loading
Loading