diff --git a/docs/extending-the-viewer.md b/docs/extending-the-viewer.md new file mode 100644 index 0000000..aea6cb5 --- /dev/null +++ b/docs/extending-the-viewer.md @@ -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#" +``` + +`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 + + + +``` + +```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): 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. diff --git a/examples/embedded_custom_tool.html b/examples/embedded_custom_tool.html new file mode 100644 index 0000000..d2f1afd --- /dev/null +++ b/examples/embedded_custom_tool.html @@ -0,0 +1,65 @@ + + + + + + Embedded COMPAS ThreeJS custom tool + + + + + + + +
+ + + diff --git a/examples/embedded_custom_tool.js b/examples/embedded_custom_tool.js new file mode 100644 index 0000000..2aeaaf4 --- /dev/null +++ b/examples/embedded_custom_tool.js @@ -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 }); diff --git a/package.json b/package.json index 9af6a35..dd8a81e 100644 --- a/package.json +++ b/package.json @@ -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": [ @@ -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", diff --git a/scripts/copy-library-types.mjs b/scripts/copy-library-types.mjs index fc26dab..a16b444 100644 --- a/scripts/copy-library-types.mjs +++ b/scripts/copy-library-types.mjs @@ -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"); diff --git a/scripts/prepare.mjs b/scripts/prepare.mjs new file mode 100644 index 0000000..c6975a5 --- /dev/null +++ b/scripts/prepare.mjs @@ -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); diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 3d9e00e..b6a1590 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -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, @@ -47,7 +51,7 @@ try { "--pack-destination", consumerRoot, ], - { cwd: projectRoot }, + { cwd: projectRoot, shell: true }, ), ); const archive = join(consumerRoot, packed[0].filename); @@ -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( diff --git a/src/App.vue b/src/App.vue index 443fb1e..c6a005d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,19 +1,39 @@