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 @@ diff --git a/src/components/tools/objects/AddObjectGroup.vue b/src/components/tools/objects/AddObjectGroup.vue new file mode 100644 index 0000000..aa1ef92 --- /dev/null +++ b/src/components/tools/objects/AddObjectGroup.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/components/tools/objects/MaterialButton.vue b/src/components/tools/objects/MaterialButton.vue new file mode 100644 index 0000000..b74652e --- /dev/null +++ b/src/components/tools/objects/MaterialButton.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/src/components/tools/objects/index.ts b/src/components/tools/objects/index.ts new file mode 100644 index 0000000..8f6c3c1 --- /dev/null +++ b/src/components/tools/objects/index.ts @@ -0,0 +1,3 @@ +export { default as AddObjectButton } from "./AddObjectButton.vue"; +export { default as MaterialButton } from "./MaterialButton.vue"; +export { default as AddObjectGroup } from "./AddObjectGroup.vue"; diff --git a/src/library/index.ts b/src/library/index.ts index c8f7a87..669ff82 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -2,7 +2,14 @@ import { createApp, markRaw } from "vue"; import "../style.css"; import App from "../App.vue"; -import { viewerRuntimeKey } from "../viewer/viewer_context"; +import { + objectActionsPlacementKey, + openbarPlacementKey, + toolbarPlacementKey, + toolbarToolsKey, + useViewerMessaging, + viewerRuntimeKey, +} from "../viewer/viewer_context"; import { ViewerRuntime } from "../viewer/viewer_runtime"; import { CompasViewerError } from "./errors"; import type { CompasViewer, CompasViewerOptions } from "./types"; @@ -10,6 +17,11 @@ import type { CompasViewer, CompasViewerOptions } from "./types"; export type { CompasViewer, CompasViewerOptions, + ObjectActionsPlacement, + OpenbarPlacement, + ToolbarPlacement, + ToolDefinition, + ViewerMessaging, ViewerMode, ViewerWebSocketOptions, } from "./types"; @@ -18,6 +30,7 @@ export { type CompasViewerErrorOptions, } from "./errors"; export { CompasViewerError }; +export { useViewerMessaging }; export function createViewer( container: HTMLElement, @@ -39,6 +52,13 @@ export function createViewer( showToolbar: options.showToolbar ?? true, }); app.provide(viewerRuntimeKey, runtime); + app.provide(toolbarToolsKey, options.toolbarTools ?? []); + app.provide(toolbarPlacementKey, options.toolbarPlacement ?? "corner"); + app.provide(openbarPlacementKey, options.openbarPlacement ?? "corner"); + app.provide( + objectActionsPlacementKey, + options.objectActionsPlacement ?? "corner", + ); app.mount(container); let disposed = false; diff --git a/src/library/public.d.ts b/src/library/public.d.ts index e8f5c89..316eea5 100644 --- a/src/library/public.d.ts +++ b/src/library/public.d.ts @@ -1,3 +1,5 @@ +import type { Component } from "vue"; + export type ViewerMode = "embedded" | "websocket"; export type CompasViewerErrorCode = @@ -30,11 +32,51 @@ export interface ViewerWebSocketOptions { secure?: boolean; } +export interface ToolDefinition { + id: string; + component: Component; + order?: number; +} + +/** + * `"corner"` (default): the built-in floating toolbar/sidebar panel, docked to the + * top-left corner - unchanged from previous versions. + * `"docked-top"`: the toolbar renders as a full-width bar docked to the top of the + * viewer, with its tool groups laid out in a row instead of stacked in a column. + */ +export type ToolbarPlacement = "corner" | "docked-top"; + +/** + * `"corner"` (default): nested in the same floating panel as the toolbar, next to it - + * unchanged from previous versions. Only applies when `toolbarPlacement` is also + * `"corner"`; otherwise Openbar renders standalone regardless of this setting. + * `"docked-left"`: a standalone panel docked to the left edge, spanning the full height + * below the toolbar (independent of the toolbar's own placement). + */ +export type OpenbarPlacement = "corner" | "docked-left"; + +/** + * `"corner"` (default): nested in the same floating panel as the object-info/metadata + * panel, top-right - unchanged from previous versions. + * `"docked-top"`: a standalone, always-mounted bar docked directly under the toolbar + * (or at the top of the viewer if the toolbar isn't also docked-top). + */ +export type ObjectActionsPlacement = "corner" | "docked-top"; + +export interface ViewerMessaging { + send(message: unknown): boolean; + sendData(message: Record): boolean; +} + export interface CompasViewerOptions { mode?: ViewerMode; websocket?: ViewerWebSocketOptions; defaultLighting?: boolean; showToolbar?: boolean; + toolbarTools?: ToolDefinition[]; + toolbarPlacement?: ToolbarPlacement; + openbarPlacement?: OpenbarPlacement; + objectActionsPlacement?: ObjectActionsPlacement; send?: (message: unknown) => boolean | void; onError?: (error: CompasViewerError) => void; } @@ -50,3 +92,5 @@ export declare function createViewer( container: HTMLElement, options?: CompasViewerOptions, ): CompasViewer; + +export declare function useViewerMessaging(): ViewerMessaging; diff --git a/src/library/types.ts b/src/library/types.ts index b3e61a6..83f610e 100644 --- a/src/library/types.ts +++ b/src/library/types.ts @@ -1,3 +1,4 @@ +import type { Component } from "vue"; import type { CompasViewerError } from "./errors"; export type ViewerMode = "embedded" | "websocket"; @@ -9,11 +10,51 @@ export interface ViewerWebSocketOptions { secure?: boolean; } +export interface ToolDefinition { + id: string; + component: Component; + order?: number; +} + +/** + * `"corner"` (default): the built-in floating toolbar/sidebar panel, docked to the + * top-left corner - unchanged from previous versions. + * `"docked-top"`: the toolbar renders as a full-width bar docked to the top of the + * viewer, with its tool groups laid out in a row instead of stacked in a column. + */ +export type ToolbarPlacement = "corner" | "docked-top"; + +/** + * `"corner"` (default): nested in the same floating panel as the toolbar, next to it - + * unchanged from previous versions. Only applies when `toolbarPlacement` is also + * `"corner"`; otherwise Openbar renders standalone regardless of this setting. + * `"docked-left"`: a standalone panel docked to the left edge, spanning the full height + * below the toolbar (independent of the toolbar's own placement). + */ +export type OpenbarPlacement = "corner" | "docked-left"; + +/** + * `"corner"` (default): nested in the same floating panel as the object-info/metadata + * panel, top-right - unchanged from previous versions. + * `"docked-top"`: a standalone, always-mounted bar docked directly under the toolbar + * (or at the top of the viewer if the toolbar isn't also docked-top). + */ +export type ObjectActionsPlacement = "corner" | "docked-top"; + +export interface ViewerMessaging { + send(message: unknown): boolean; + sendData(message: Record): boolean; +} + export interface CompasViewerOptions { mode?: ViewerMode; websocket?: ViewerWebSocketOptions; defaultLighting?: boolean; showToolbar?: boolean; + toolbarTools?: ToolDefinition[]; + toolbarPlacement?: ToolbarPlacement; + openbarPlacement?: OpenbarPlacement; + objectActionsPlacement?: ObjectActionsPlacement; send?: (message: unknown) => boolean | void; onError?: (error: CompasViewerError) => void; } diff --git a/src/library/ui.d.ts b/src/library/ui.d.ts new file mode 100644 index 0000000..923f5e0 --- /dev/null +++ b/src/library/ui.d.ts @@ -0,0 +1,9 @@ +import type { Component } from "vue"; + +export declare const Button: Component; +export declare const Kbd: Component; +export declare const KbdGroup: Component; +export declare const Tooltip: Component; +export declare const TooltipContent: Component; +export declare const TooltipProvider: Component; +export declare const TooltipTrigger: Component; diff --git a/src/library/ui.ts b/src/library/ui.ts new file mode 100644 index 0000000..b1b0250 --- /dev/null +++ b/src/library/ui.ts @@ -0,0 +1,8 @@ +export { Button } from "../components/ui/button"; +export { Kbd, KbdGroup } from "../components/ui/kbd"; +export { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../components/ui/tooltip"; diff --git a/src/style.css b/src/style.css index 3697976..84999aa 100644 --- a/src/style.css +++ b/src/style.css @@ -90,6 +90,38 @@ inset, -3px -3px 2px 2px color-mix(in oklab, var(--background) 70%, transparent) inset; + --section-title-background: color-mix( + in oklab, + var(--background) 25%, + transparent + ); + --section-title-shadow: + 1px 1px 3px 0px color-mix(in oklab, var(--foreground) 35%, transparent) + inset, + -1px -1px 3px 0px color-mix(in oklab, var(--background) 70%, transparent) + inset; + --section-title-blur: 10px; + /* Controls how tall every docked-top bar (toolbar, object-actions, ...) is: adjust + this one value and both the padding and the shared height floor they use follow + automatically, so they stay matched. 38px = the 36px icon button plus a 1px top + + 1px bottom border (kept even when the border color is set to transparent). */ + --docked-bar-padding: 12px; + --docked-bar-height: calc(var(--docked-bar-padding) * 2 + 38px); + /* Independently overridable background/border for the toolbar and object-actions + panels - default to the same values `.theme` itself uses, so redeclaring one of + these doesn't affect the other panels (or Openbar) still relying on `.theme`. */ + --toolbar-background: linear-gradient( + 135deg, + var(--theme-bg-start) 0%, + var(--theme-bg-end) 100% + ); + --toolbar-border-color: var(--theme-border-color); + --object-actions-background: linear-gradient( + 135deg, + var(--theme-bg-start) 0%, + var(--theme-bg-end) 100% + ); + --object-actions-border-color: var(--theme-border-color); font-family: Inter, sans-serif; font-feature-settings: "liga" 1, diff --git a/src/viewer/BIDIRECTIONAL_SYNC.md b/src/viewer/BIDIRECTIONAL_SYNC.md new file mode 100644 index 0000000..c8add16 --- /dev/null +++ b/src/viewer/BIDIRECTIONAL_SYNC.md @@ -0,0 +1,142 @@ +# Bidirectional sync — context for a future agent + +This documents the frontend half of making the viewer bidirectional: dragging an object, +adding a new one, and editing its material all send messages back to the backend, which +mutates the corresponding _live_ Python object rather than the frontend just displaying +whatever the backend last pushed. Before this work, outbound traffic was limited to UI +callbacks (`ui_callback`, `object_picked`, `object_action_callback`) — see +`ViewerRuntime.handleUiAction`/`handleObjectAction` in `viewer_runtime.ts` for that +existing pattern, which the new code follows. + +The paired backend implementation lives in the sibling `compas_threejs` repo, at +`src/compas_threejs/viewer/BIDIRECTIONAL_SYNC.md` — read that alongside this file, +especially for the exact message shapes each handler expects. Both repos carry this work +on a branch called `feature/bidirectional-sync`, branched off `main` in each. + +Everything below lives in `ViewerRuntime` (`viewer_runtime.ts`) unless noted otherwise. +All outbound sends go through the existing `sendData()` → `ViewerConnection.send()` path, +same as every pre-existing callback. + +## `object_transform` — the transform gizmo + +The gizmo (`TransformControls`) already existed, wired to picking, before this work — it +just didn't send anything. Two things were added: + +1. In the constructor, the existing `"dragging-changed"` listener now also captures + `this.dragStartMatrix = this.transformControls.object?.matrix.clone()` when a drag + _starts_ (`event.value === true`). +2. A new `"mouseUp"` listener (fires once, when the drag ends — unlike `"objectChange"`, + which fires every frame) calls `sendObjectTransform()`. + +`sendObjectTransform()` computes `delta = object.matrix.clone().multiply(dragStartMatrix.clone().invert())` +and sends it as `{dispatch: "object_transform", guid, matrix}`, where `matrix` is a +**row-major 4x4 nested list**. `THREE.Matrix4.elements` is column-major internally, so the +conversion explicitly transposes — see the comment at the transpose site if you touch +this, it's the kind of thing that silently breaks (this exact class of bug — wrong matrix +convention — is what caused `Remote`'s old camera/background messages to be dead code +before an earlier refactor, per the backend's `CONTEXTE.md`). + +**Why a delta, and why `dragStartMatrix` matters — read this before changing the math.** +Geometry conversion (`buildTransformationFromFrame` + `Object3D.applyMatrix4` in +`conversions/geometry.ts`) does **not** bake an object's frame into its vertex buffer. +`Object3D.applyMatrix4()` premultiplies the matrix into `object.matrix` and then +_decomposes_ it into `position`/`quaternion`/`scale`. So a freshly-converted mesh already +sits at its real, absolute world placement — it is not at identity. Two real bugs came +from getting this wrong, in order: + +- **Bug 1 — sent the absolute matrix as if it were a delta.** The original + implementation assumed `object.matrix` started at identity, so it sent the post-drag + matrix directly. The backend applied it via `geometry.transform(T)`, which composes `T` + _on top of_ the object's current state — so the object landed somewhere else entirely + (looked "inverted" or like it teleported). Fixed by capturing `dragStartMatrix` and + sending `M_after * M_before^-1` instead — see the backend doc's `Transformation` + section for why this composes correctly. +- **Bug 2 — a continuously self-animating object (e.g. a spinning torus with an + `App.loop` callback) fought its own drag.** The backend's loop calls `update_geometry` + many times a second regardless of what the frontend is doing. Every one of those + broadcasts was rebuilding the mesh mid-drag at the backend's last-known (not-yet-moved) + position, undoing the user's drag in real time — by `mouseUp`, the net movement was + ~zero, looking like the object "snapped back." Fixed in `manageGeometry` (see below). + +`manageGeometry` now has an early-return guard: if the incoming update's guid is the +object currently attached to `transformControls` **and** `transformControls.dragging` is +true, the update is dropped entirely rather than rebuilding the mesh out from under the +user. The next update after the drag ends — either the echo of the just-sent +`object_transform`, or the animation's next tick — resyncs normally. + +Separately, `manageGeometry` also carries gizmo attachment and highlight material over to +a freshly-rebuilt mesh when the _currently picked_ object's guid gets an update (e.g. the +echo of your own edit, or an unrelated animation tick while merely selected-but-not- +dragging) — otherwise every echo would silently detach the gizmo. + +**Known limitation, not solved**: the backend applies the delta on top of whatever its +live object's state is _at message-processing time_, which — for a continuously-animating +object — may have moved further since `dragStartMatrix` was captured (the drag can take a +second or more; the backend keeps animating the whole time). The result can carry a small +amount of "extra" motion corresponding to that elapsed animation. This is different from +(and much more minor than) Bug 2 above — it's an accepted characteristic of editing a live +object, not a bug to chase. + +## `create_geometry` — "Add object" toolbar button + +`ViewerRuntime.createGeometry(type, params)` sends +`{dispatch: "create_geometry", type, point: [x,y,z], params}`, where `point` is the +camera's current orbit target (`this.controls.target`) so new objects spawn in view +instead of at a fixed, possibly-buried world origin. + +UI: `src/components/tools/objects/AddObjectButton.vue` — a toolbar `Popover` (pattern +copied from `SavedViewsButton.vue`) with a shape-type `Select` and per-type `NumberField` +params. On "Add", it calls `createGeometry` and closes. + +**No new receive-side code was needed.** The created object comes back as an ordinary +`add_geometry` broadcast — the existing `manageGeometry`/`dispatch()` path renders it +exactly like anything a script adds. This symmetry (reusing the backend's existing +`add_geometry` outbound path) is why this was a small feature: the frontend only had to +learn to _send_ one new message, not _receive_ one. + +**Placement UX was deliberately kept simple**: spawn at a sensible default, then let the +user drag it into place with the (already-existing, already-fixed) gizmo — not a +click/drag-to-draw-in-3D-space sketch tool. That would need a new interaction state +machine (raycasting against a ground plane, live preview mesh, per-shape-type gesture +logic) and was explicitly scoped out as a much larger follow-up. + +## `material_edit` — toolbar color/metalness/roughness + +Two new `ViewerRuntime` methods: + +- `getMaterialSnapshot(guid)` — reads `this.geometryMaterials.get(guid)` → + `this.materials.get(materialGuid)`, returns `{color, metalness, roughness} | null`. + Returns `null` if the object has no material yet, or its `materialType` isn't + `"standard_material"` — this is the gate that keeps material editing scoped to + `compas_threejs.materials.Material`-backed objects; `PointMaterial`/`LineMaterial`/ + `PhysicalMaterial` have unrelated property sets (e.g. a point's material has `size`, not + metalness/roughness) and aren't editable through this control. +- `setMaterial(guid, {color?, metalness?, roughness?})` — mutates the local + `THREE.MeshStandardMaterial` **in place** first (instant visual feedback, no round-trip + wait), then sends `{dispatch: "material_edit", guid, ...fields}`. + +UI: `src/components/tools/objects/MaterialButton.vue`, folded into the same toolbar +group as `AddObjectButton`. Disabled unless something is picked. Color swatch + two +`Slider` controls (0–1, step 0.05) for metalness/roughness, each firing `setMaterial` on +every change — edits stream continuously as you drag, matching how this app's existing +dynamic `Slider`/`NumberField` UI components already behave (see `Openbar.vue`), and +deliberately _not_ the "send once on release" pattern `object_transform` uses — materials +aren't touched by any per-frame animation loop, so there's no equivalent of Bug 2 above to +worry about here. + +**New reactive store field**: `ViewerStore.pickedObjectGuid` (`viewer_store.ts`). Nothing +previously exposed "what's currently picked" to Vue — `pickedObject` was a private plain +TS field on `ViewerRuntime`. Set in `pickFromPointer` (on pick), cleared in +`clearPickedObject` (on deselect/Escape/pick-miss). `MaterialButton.vue`'s enable/disable +state and target guid both come from this. + +## Verifying changes here + +No test suite exists in this repo. Verification during this work: `vue-tsc` +(`npm run build`, which runs `vue-tsc --noEmit` across all tsconfigs before bundling) for +type safety, then manual end-to-end checks against a real running backend `App` — start +an example, pick/drag/add/recolor objects in the browser, and separately confirm the +backend's Python-side object state via ad hoc scripts (see the backend doc). After any +change here, the frontend must be rebuilt (`npm run build`) and the `dist/` output copied +into `compas_threejs/src/compas_threejs/viewer/frontend/` before it's reachable from a +real browser session — the backend serves its own bundled copy, not this repo live. diff --git a/src/viewer/viewer_context.ts b/src/viewer/viewer_context.ts index 20d073d..e749f60 100644 --- a/src/viewer/viewer_context.ts +++ b/src/viewer/viewer_context.ts @@ -1,12 +1,34 @@ import type { InjectionKey } from "vue"; import { inject } from "vue"; +import type { + ObjectActionsPlacement, + OpenbarPlacement, + ToolbarPlacement, + ToolDefinition, + ViewerMessaging, +} from "../library/types"; import type { ViewerRuntime } from "./viewer_runtime"; export const viewerRuntimeKey: InjectionKey = Symbol( "compas-viewer-runtime", ); +export const toolbarToolsKey: InjectionKey = Symbol( + "compas-viewer-toolbar-tools", +); + +export const toolbarPlacementKey: InjectionKey = Symbol( + "compas-viewer-toolbar-placement", +); + +export const openbarPlacementKey: InjectionKey = Symbol( + "compas-viewer-openbar-placement", +); + +export const objectActionsPlacementKey: InjectionKey = + Symbol("compas-viewer-object-actions-placement"); + export function useViewerRuntime(): ViewerRuntime { const runtime = inject(viewerRuntimeKey); if (!runtime) { @@ -14,3 +36,32 @@ export function useViewerRuntime(): ViewerRuntime { } return runtime; } + +export function useToolbarTools(): ToolDefinition[] { + return inject(toolbarToolsKey, []); +} + +export function useToolbarPlacement(): ToolbarPlacement { + return inject(toolbarPlacementKey, "corner"); +} + +export function useOpenbarPlacement(): OpenbarPlacement { + return inject(openbarPlacementKey, "corner"); +} + +export function useObjectActionsPlacement(): ObjectActionsPlacement { + return inject(objectActionsPlacementKey, "corner"); +} + +/** + * Public, minimal messaging surface for custom tools - deliberately narrower than + * ViewerRuntime so consumers depend on a small stable contract instead of internals + * that are free to change. + */ +export function useViewerMessaging(): ViewerMessaging { + const runtime = useViewerRuntime(); + return { + send: (message) => runtime.send(message), + sendData: (message) => runtime.sendData(message), + }; +} diff --git a/src/viewer/viewer_runtime.ts b/src/viewer/viewer_runtime.ts index c7bc5ef..9f2c80c 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -126,6 +126,7 @@ export class ViewerRuntime { private disposed = false; private pickedObject: THREE.Object3D | null = null; private pickedMaterial: THREE.Material | THREE.Material[] | null = null; + private dragStartMatrix: THREE.Matrix4 | null = null; private readonly hiddenGuids = new Set(); private readonly highlightMaterial = new THREE.MeshStandardMaterial({ color: "orange", @@ -166,6 +167,19 @@ export class ViewerRuntime { this.transformHelper = this.transformControls.getHelper(); this.transformControls.addEventListener("dragging-changed", (event) => { this.controls.enabled = !event.value; + if (event.value) { + // Capture the object's world matrix as it stood right before this drag, so the + // delta sent to the backend on release is relative to it - NOT relative to + // identity. Conversion bakes each object's frame into its own position/quaternion + // (via THREE.Object3D.applyMatrix4, which decomposes into position/quaternion/ + // scale rather than baking into vertex data), so a freshly-built object already + // sits at its absolute world placement, not at the origin. + this.dragStartMatrix = + this.transformControls.object?.matrix.clone() ?? null; + } + }); + this.transformControls.addEventListener("mouseUp", () => { + this.sendObjectTransform(); }); this.scene.add(this.transformHelper); @@ -276,6 +290,67 @@ export class ViewerRuntime { }); } + /** + * Asks the backend to create a new geometry object of `type` (e.g. "box", "sphere", + * "point") with the given numeric `params`, spawned at the camera's current orbit + * target so it appears in view. The backend constructs the real COMPAS object and + * broadcasts it back via the existing add_geometry path - it arrives here exactly + * like any object added by a running script, so no new receive-side handling is + * needed. Pick it up with the transform gizmo afterwards to position it precisely. + */ + createGeometry(type: string, params: Record): void { + const point = this.vectorData(this.controls.target); + this.sendData({ + dispatch: "create_geometry", + type, + point: [point.x, point.y, point.z], + params, + }); + } + + /** + * Reads the current color/metalness/roughness of the object at `guid`, for + * pre-filling the material editor when it opens. Returns null if the object has no + * material yet, or its material isn't a "standard_material" (e.g. a Point's + * PointMaterial has an entirely different property set) - editing those is out of + * scope for this control. + */ + getMaterialSnapshot( + guid: string, + ): { color: string; metalness: number; roughness: number } | null { + const materialGuid = this.geometryMaterials.get(guid); + if (!materialGuid) return null; + const entry = this.materials.get(materialGuid); + if (!entry || entry.materialType !== "standard_material") return null; + const material = entry.material as THREE.MeshStandardMaterial; + return { + color: `#${material.color.getHexString()}`, + metalness: material.metalness, + roughness: material.roughness, + }; + } + + /** + * Applies a material edit both locally (instant visual feedback on the live + * THREE.Material - no need to wait for the backend round trip) and sends it to the + * backend so the corresponding live Material Python instance is updated the same way, + * e.g. via `examples/objects_action.py`'s "Make it blue" action. + */ + setMaterial( + guid: string, + fields: { color?: string; metalness?: number; roughness?: number }, + ): void { + const materialGuid = this.geometryMaterials.get(guid); + const entry = materialGuid ? this.materials.get(materialGuid) : undefined; + if (entry && entry.materialType === "standard_material") { + const material = entry.material as THREE.MeshStandardMaterial; + if (fields.color !== undefined) material.color.set(fields.color); + if (fields.metalness !== undefined) material.metalness = fields.metalness; + if (fields.roughness !== undefined) material.roughness = fields.roughness; + } + this.sendData({ dispatch: "material_edit", guid, ...fields }); + } + hideObjectInfo(): void { this.store.objectBarData.isVisible = false; } @@ -491,10 +566,31 @@ export class ViewerRuntime { } private manageGeometry(object: CommandRecord): void { - const converted = convertToThreeJSGeometry(object); const externalGuid = resolveExternalGeometryGuid(object); + const draggingTarget = externalGuid + ? this.geometries.get(externalGuid) + : undefined; + if ( + draggingTarget && + this.transformControls.dragging && + draggingTarget === this.transformControls.object + ) { + // The user is actively dragging this exact object with the gizmo - drop this + // incoming update instead of rebuilding it out from under them. This matters a lot + // for a continuously self-animating object (e.g. a spinning torus with an `App.loop` + // callback): its backend loop keeps calling update_geometry many times a second, + // and every one of those would otherwise swap in a freshly-converted mesh sitting at + // the backend's last-known (not-yet-moved) position, fighting the drag to a + // standstill so it looks like the object "snaps back" on release. The next update + // after the drag ends - the echo of our own object_transform, or the animation's + // next tick - resyncs to the real backend state. + return; + } + const converted = convertToThreeJSGeometry(object); const sceneKey = externalGuid ?? converted.uuid; const existing = this.geometries.get(sceneKey); + const wasSelected = + existing !== undefined && existing === this.pickedObject; if (existing) { this.scene.remove(existing); this.disposeObject(existing); @@ -513,6 +609,18 @@ export class ViewerRuntime { edges.layers.set(1); converted.add(edges); } + // If the replaced object was selected (e.g. this update is the echo of a gizmo edit + // the user just made), carry the selection - highlight material and gizmo attachment + // - over to the newly-built object instead of silently losing it. + if (wasSelected) { + this.pickedObject = converted; + if ("material" in converted) { + const renderable = converted as RenderableObject; + this.pickedMaterial = renderable.material ?? null; + renderable.material = this.highlightMaterial; + } + this.transformControls.attach(converted); + } } private manageMaterial(data: MaterialCommand): void { @@ -716,6 +824,7 @@ export class ViewerRuntime { } this.transformControls.attach(picked); const guid = this.findGeometryGuid(picked); + this.store.pickedObjectGuid.value = guid ?? null; if (guid) { this.store.selectedObjectGuid.value = guid; this.sendData({ dispatch: "object_picked", guid }); @@ -733,6 +842,7 @@ export class ViewerRuntime { this.pickedObject = null; this.pickedMaterial = null; this.transformControls.detach(); + this.store.pickedObjectGuid.value = null; this.store.objectBarData.data = null; this.store.objectActionsState.splice(0); this.store.selectedObjectGuid.value = null; @@ -751,6 +861,43 @@ export class ViewerRuntime { return undefined; } + /** + * Sends the object currently attached to the transform gizmo back to the backend as a + * delta transform, once dragging ends. Geometry conversion (`applyMatrix4` in + * `conversions/geometry.ts`) decomposes each object's frame into its own + * position/quaternion/scale (that's what `Object3D.applyMatrix4` does - it does NOT + * bake into vertex data), so `object.matrix` is already the object's absolute world + * placement both before and after a drag, not a delta relative to identity. What the + * backend needs is the delta between the placement captured at drag-start + * (`dragStartMatrix`, set in the `dragging-changed` listener above) and the placement + * after the drag - sending the absolute matrix instead would have the backend compose + * it on top of the object's current state a second time, landing it somewhere else + * entirely (this was the cause of a "moves to another location" bug). + */ + private sendObjectTransform(): void { + const object = this.transformControls.object; + const startMatrix = this.dragStartMatrix; + this.dragStartMatrix = null; + if (!object || !startMatrix) return; + + const delta = object.matrix.clone().multiply(startMatrix.clone().invert()); + if (delta.equals(new THREE.Matrix4())) return; + + const guid = this.findGeometryGuid(object); + if (!guid) return; + + // THREE.Matrix4.elements is column-major; transpose into a row-major 4x4 nested + // list, matching `compas.geometry.Transformation.from_matrix`'s expected shape. + const e = delta.elements; + const matrix = [ + [e[0], e[4], e[8], e[12]], + [e[1], e[5], e[9], e[13]], + [e[2], e[6], e[10], e[14]], + [e[3], e[7], e[11], e[15]], + ]; + this.sendData({ dispatch: "object_transform", guid, matrix }); + } + private handleKeyDown(event: KeyboardEvent): void { if (event.altKey || event.ctrlKey || event.metaKey) return; if (event.key === "Escape") { diff --git a/src/viewer/viewer_store.ts b/src/viewer/viewer_store.ts index bd1384d..a79c9d5 100644 --- a/src/viewer/viewer_store.ts +++ b/src/viewer/viewer_store.ts @@ -82,6 +82,7 @@ export interface ViewerStore { sidebarComponents: DynamicComponent[]; pickerEnabled: { value: boolean }; pickerMode: { value: "translate" | "rotate" | "scale" }; + pickedObjectGuid: { value: string | null }; blockPicker: { value: boolean }; showEdges: { value: boolean }; theme: { value: "light" | "dark" }; @@ -104,6 +105,7 @@ export function createViewerStore(): ViewerStore { sidebarComponents: reactive([]), pickerEnabled: reactive({ value: true }), pickerMode: reactive({ value: "translate" as const }), + pickedObjectGuid: reactive({ value: null as string | null }), blockPicker: reactive({ value: false }), showEdges: reactive({ value: false }), theme: reactive({ value: "light" as const }), diff --git a/tests/browser/custom_tool.spec.ts b/tests/browser/custom_tool.spec.ts new file mode 100644 index 0000000..15646f7 --- /dev/null +++ b/tests/browser/custom_tool.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; + +test("renders a custom toolbar tool and routes its messages through `send`", async ({ + page, +}) => { + const errors: string[] = []; + const externalRequests: string[] = []; + + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("pageerror", (error) => errors.push(error.message)); + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.hostname !== "127.0.0.1") externalRequests.push(request.url()); + }); + + await page.goto("/examples/embedded_custom_tool.html"); + await expect(page.locator("body")).toHaveAttribute( + "data-example-ready", + "true", + ); + await expect(page.locator("canvas")).toHaveCount(1); + + const pingButton = page.getByTestId("ping-tool-button"); + await expect(pingButton).toBeVisible(); + await pingButton.click(); + await pingButton.click(); + + const outgoing = await page.evaluate(() => { + const customTool = ( + window as typeof window & { + __compasCustomTool?: { outgoingMessages: unknown[] }; + } + ).__compasCustomTool; + return customTool?.outgoingMessages ?? []; + }); + + expect(outgoing).toEqual([ + { dispatch: "other_action", action: "ping", count: 1 }, + { dispatch: "other_action", action: "ping", count: 2 }, + ]); + expect(externalRequests).toEqual([]); + expect(errors).toEqual([]); +}); diff --git a/tests/library_import.test.ts b/tests/library_import.test.ts index 573b291..16808d4 100644 --- a/tests/library_import.test.ts +++ b/tests/library_import.test.ts @@ -18,4 +18,25 @@ describe("public library entry", () => { }), ); }); + + it("exposes useViewerMessaging as a function", async () => { + const library = await import("../src/library"); + expect(library.useViewerMessaging).toBeTypeOf("function"); + }); + + it("exposes the documented ui kit re-export surface", async () => { + const ui = await import("../src/library/ui"); + expect(Object.keys(ui).sort()).toEqual([ + "Button", + "Kbd", + "KbdGroup", + "Tooltip", + "TooltipContent", + "TooltipProvider", + "TooltipTrigger", + ]); + for (const component of Object.values(ui)) { + expect(component).toBeTruthy(); + } + }); }); diff --git a/tests/viewer_lifecycle.test.ts b/tests/viewer_lifecycle.test.ts index e6874c5..5faf60b 100644 --- a/tests/viewer_lifecycle.test.ts +++ b/tests/viewer_lifecycle.test.ts @@ -2,11 +2,12 @@ import { Box, + Dictionary, Frame, pbDumpBytes, Quaternion, } from "@gramaziokohler/compas-pb-ts"; -import { nextTick } from "vue"; +import { defineComponent, h, nextTick } from "vue"; import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("three", async () => { @@ -31,12 +32,35 @@ vi.mock("three", async () => { return { ...actual, WebGLRenderer }; }); -import { CompasViewerError, createViewer } from "../src/library"; +import { + CompasViewerError, + createViewer, + useViewerMessaging, +} from "../src/library"; +import type { ToolDefinition } from "../src/library/types"; import { ViewerRuntime } from "../src/viewer/viewer_runtime"; import * as THREE from "three"; const viewers: Array<{ dispose(): void }> = []; +function definePingTool(toolId: string) { + return defineComponent({ + name: `PingTool-${toolId}`, + setup() { + const { sendData } = useViewerMessaging(); + function handleClick() { + sendData({ dispatch: "other_action", action: "ping", tool: toolId }); + } + return () => + h( + "button", + { class: "ping-tool", "data-tool-id": toolId, onClick: handleClick }, + toolId, + ); + }, + }); +} + function boxBytes(guid: string): Uint8Array { return pbDumpBytes( new Box({ @@ -72,6 +96,26 @@ function frameBytes(guid: string): Uint8Array { ); } +function uiButtonBytes(guid: string): Uint8Array { + const commandValue = (value: unknown) => + typeof value === "number" ? { doubleValue: value } : { value }; + return pbDumpBytes( + new Dictionary({ + data: { + items: Object.fromEntries( + Object.entries({ + dispatch: "ui", + type: "button", + guid, + text: "Test", + variant: "secondary", + }).map(([key, value]) => [key, commandValue(value)]), + ), + }, + }), + ); +} + afterEach(() => { viewers.splice(0).forEach((viewer) => viewer.dispose()); vi.unstubAllGlobals(); @@ -312,3 +356,172 @@ describe("createViewer", () => { runtime.dispose(); }); }); + +describe("toolbar extension API", () => { + it("mounts toolbarTools after the built-in groups, sorted by order", () => { + const container = document.createElement("div"); + document.body.append(container); + const tools: ToolDefinition[] = [ + { id: "later", component: definePingTool("later"), order: 20 }, + { id: "earlier", component: definePingTool("earlier"), order: 5 }, + { id: "unordered", component: definePingTool("unordered") }, + ]; + + const viewer = createViewer(container, { + mode: "embedded", + toolbarTools: tools, + }); + viewers.push(viewer); + + const toolbar = container.querySelector(".toolbar"); + expect(toolbar).not.toBeNull(); + + const builtInGroups = toolbar!.querySelectorAll(".toolbar-group"); + expect(builtInGroups).toHaveLength(4); + + const toolIds = Array.from(toolbar!.querySelectorAll(".ping-tool")).map( + (button) => button.getAttribute("data-tool-id"), + ); + expect(toolIds).toEqual(["unordered", "earlier", "later"]); + + const lastBuiltInGroup = builtInGroups[builtInGroups.length - 1]!; + const firstToolButton = toolbar!.querySelector(".ping-tool")!; + expect( + lastBuiltInGroup.compareDocumentPosition(firstToolButton) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("routes useViewerMessaging().sendData through the `send` option", () => { + const container = document.createElement("div"); + document.body.append(container); + const outgoing: unknown[] = []; + + const viewer = createViewer(container, { + mode: "embedded", + toolbarTools: [{ id: "ping", component: definePingTool("ping") }], + send: (message) => { + outgoing.push(message); + return true; + }, + }); + viewers.push(viewer); + + const button = container.querySelector(".ping-tool"); + expect(button).not.toBeNull(); + button!.click(); + button!.click(); + + expect(outgoing).toEqual([ + { dispatch: "other_action", action: "ping", tool: "ping" }, + { dispatch: "other_action", action: "ping", tool: "ping" }, + ]); + }); + + it("renders only the built-in groups when toolbarTools is omitted", () => { + const container = document.createElement("div"); + document.body.append(container); + + const viewer = createViewer(container, { mode: "embedded" }); + viewers.push(viewer); + + expect(container.querySelectorAll(".toolbar-group")).toHaveLength(4); + expect(container.querySelectorAll(".ping-tool")).toHaveLength(0); + }); + + it("defaults every panel to the corner placement", async () => { + const container = document.createElement("div"); + document.body.append(container); + + const viewer = createViewer(container, { mode: "embedded" }); + viewers.push(viewer); + + expect(container.querySelector(".dock-top")).toBeNull(); + expect(container.querySelector(".app-container.docked-top")).toBeNull(); + // Toolbar and (once mounted) ObjectActions/Openbar all nest in their + // legacy corner-mode containers, exactly as before this option existed. + expect(container.querySelector("#sidebar .toolbar")).not.toBeNull(); + expect(container.querySelector(".toolbar.docked-top")).toBeNull(); + expect( + container.querySelector("#right-sidebar .object-actions"), + ).not.toBeNull(); + expect(container.querySelector(".object-actions.docked-top")).toBeNull(); + + viewer.dispatch(uiButtonBytes("ui-button")); + await nextTick(); + expect(container.querySelector("#sidebar #openbar")).not.toBeNull(); + expect(container.querySelector("#openbar.docked-left")).toBeNull(); + }); + + it("docks the toolbar as a full-width bar, out of #sidebar entirely", () => { + const container = document.createElement("div"); + document.body.append(container); + + const viewer = createViewer(container, { + mode: "embedded", + toolbarPlacement: "docked-top", + }); + viewers.push(viewer); + + expect(container.querySelector(".app-container.docked-top")).not.toBeNull(); + expect( + container.querySelector(".dock-top > .toolbar.docked-top"), + ).not.toBeNull(); + expect(container.querySelector("#sidebar .toolbar")).toBeNull(); + }); + + it("docks Openbar to the left, independent of the toolbar's own placement", async () => { + const container = document.createElement("div"); + document.body.append(container); + + const viewer = createViewer(container, { + mode: "embedded", + toolbarPlacement: "docked-top", + openbarPlacement: "docked-left", + }); + viewers.push(viewer); + + viewer.dispatch(uiButtonBytes("ui-button")); + await nextTick(); + + expect( + container.querySelector(".workspace > #openbar.docked-left"), + ).not.toBeNull(); + expect(container.querySelector("#sidebar #openbar")).toBeNull(); + }); + + it("docks ObjectActions under the toolbar, out of #right-sidebar entirely", () => { + const container = document.createElement("div"); + document.body.append(container); + + const viewer = createViewer(container, { + mode: "embedded", + objectActionsPlacement: "docked-top", + }); + viewers.push(viewer); + + expect(container.querySelector(".app-container.docked-top")).not.toBeNull(); + expect( + container.querySelector(".dock-top > .object-actions.docked-top"), + ).not.toBeNull(); + expect( + container.querySelector("#right-sidebar .object-actions"), + ).toBeNull(); + }); + + it("keeps a docked-top ObjectActions visible even with no selection", () => { + const container = document.createElement("div"); + document.body.append(container); + + const viewer = createViewer(container, { + mode: "embedded", + objectActionsPlacement: "docked-top", + }); + viewers.push(viewer); + + const actionsPanel = container.querySelector(".object-actions"); + expect(actionsPanel).not.toBeNull(); + expect(actionsPanel!.classList.contains("is-empty")).toBe(true); + expect(getComputedStyle(actionsPanel!).display).not.toBe("none"); + }); +}); diff --git a/tsconfig.core.json b/tsconfig.core.json index 152ef8f..d1b973c 100644 --- a/tsconfig.core.json +++ b/tsconfig.core.json @@ -15,6 +15,7 @@ "src/library/errors.ts", "src/library/types.ts", "src/library/public.d.ts", + "src/library/ui.d.ts", "src/viewer/**/*.ts" ] } diff --git a/vite.config.library.ts b/vite.config.library.ts index 1587980..1514259 100644 --- a/vite.config.library.ts +++ b/vite.config.library.ts @@ -15,9 +15,11 @@ export default defineConfig({ emptyOutDir: true, sourcemap: true, lib: { - entry: path.resolve(import.meta.dirname, "src/library/index.ts"), + entry: { + index: path.resolve(import.meta.dirname, "src/library/index.ts"), + ui: path.resolve(import.meta.dirname, "src/library/ui.ts"), + }, formats: ["es"], - fileName: "index", cssFileName: "style", }, rollupOptions: {