From e05625229da89ca199f74ea9f487d45b60e4a223 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 09:11:42 +0200 Subject: [PATCH 1/9] feat(viewer): add a plugin extension API for add-ons Adds CompasViewerOptions.plugins and a narrow ViewerExtensionContext so authoring tools can live in separate packages: an overlay layer that is never picked or reset, pointer rays and plane/object raycasts, object bounds, resize/dispose hooks, and beginInteraction() to take over pointer and keyboard input for a session. The scene, camera, renderer and controls stay internal. Co-Authored-By: Claude Opus 5.5 --- scripts/test-package.mjs | 13 +- src/library/index.ts | 21 +- src/library/public.d.ts | 105 ++++++++ src/library/types.ts | 67 +++++ src/viewer/viewer_extensions.ts | 66 +++++ src/viewer/viewer_runtime.ts | 219 ++++++++++++++++- tests/viewer_extensions.test.ts | 416 ++++++++++++++++++++++++++++++++ 7 files changed, 896 insertions(+), 11 deletions(-) create mode 100644 src/viewer/viewer_extensions.ts create mode 100644 tests/viewer_extensions.test.ts diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 3d9e00e..546d91c 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -95,8 +95,17 @@ try { await writeFile( join(consumerRoot, "consumer.ts"), - `import { createViewer, CompasViewerError, type CompasViewerOptions } from "${packageName}";\n` + - `const options: CompasViewerOptions = { mode: "embedded" };\n` + + `import { createViewer, CompasViewerError, type CompasViewerOptions, type ViewerPlugin } from "${packageName}";\n` + + `const plugin: ViewerPlugin = {\n` + + ` id: "consumer",\n` + + ` install(context) {\n` + + ` const session = context.beginInteraction({ onKeyDown: (event) => void event.key });\n` + + ` void context.pointerOnPlane({ clientX: 0, clientY: 0 }, 0)?.z;\n` + + ` void context.objectBounds()[0]?.guid;\n` + + ` return () => session.release();\n` + + ` },\n` + + `};\n` + + `const options: CompasViewerOptions = { mode: "embedded", plugins: [plugin] };\n` + `void createViewer; void CompasViewerError; void options;\n`, ); await writeFile( diff --git a/src/library/index.ts b/src/library/index.ts index 49a1ce2..113d6bc 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -3,6 +3,7 @@ import { createApp, markRaw } from "vue"; import "../style.css"; import App from "../App.vue"; import { useViewerRuntime, viewerRuntimeKey } from "../viewer/viewer_context"; +import { installPlugins } from "../viewer/viewer_extensions"; import { ViewerRuntime } from "../viewer/viewer_runtime"; import { useToolbarControl } from "../viewer/useToolbarControl"; import { CompasViewerError } from "./errors"; @@ -11,7 +12,16 @@ import type { CompasViewer, CompasViewerOptions } from "./types"; export type { CompasViewer, CompasViewerOptions, + InteractionHandlers, + InteractionSession, + ViewerExtensionContext, ViewerMode, + ViewerObjectBounds, + ViewerObjectHit, + ViewerPlugin, + ViewerPoint, + ViewerPointerLike, + ViewerSize, ViewerWebSocketOptions, } from "./types"; export { @@ -50,7 +60,7 @@ export function createViewer( app.mount(container); let disposed = false; - return { + const viewer: CompasViewer = { dispatch(message) { runtime.dispatch(message); }, @@ -67,4 +77,13 @@ export function createViewer( runtime.dispose(); }, }; + + try { + installPlugins(runtime, options.plugins ?? []); + } catch (error) { + // Runs the cleanups of any plugin that did install before rethrowing. + viewer.dispose(); + throw error; + } + return viewer; } diff --git a/src/library/public.d.ts b/src/library/public.d.ts index 5f52c98..84bc11e 100644 --- a/src/library/public.d.ts +++ b/src/library/public.d.ts @@ -1,4 +1,5 @@ import type { Component, ComputedRef } from "vue"; +import type { Object3D, Ray } from "three"; export type ViewerMode = "embedded" | "websocket"; @@ -46,6 +47,8 @@ export interface CompasViewerOptions { extraToolbarModules?: Component[]; send?: (message: unknown) => boolean | void; onError?: (error: CompasViewerError) => void; + /** Add-ons installed once the viewer is mounted, in array order. */ + plugins?: ViewerPlugin[]; } export interface CompasViewer { @@ -89,3 +92,105 @@ export declare function useToolbarControl(id: string): { visible: ComputedRef; enabled: ComputedRef; }; + +/** + * An add-on that extends the viewer through `ViewerExtensionContext` - e.g. an + * authoring tool that draws its own overlay geometry and takes over pointer input + * for the length of a session. Pass it via `CompasViewerOptions.plugins`. + */ +export interface ViewerPlugin { + /** Unique per viewer; a duplicate id throws a `lifecycle_error`. */ + readonly id: string; + /** Called once. The returned function (if any) runs on viewer dispose. */ + install(context: ViewerExtensionContext): void | (() => void); +} + +export interface ViewerPoint { + x: number; + y: number; + z: number; +} + +/** A backend-managed object's world-space axis-aligned bounding box. */ +export interface ViewerObjectBounds { + guid: string; + min: ViewerPoint; + max: ViewerPoint; +} + +export interface ViewerObjectHit { + guid: string; + point: ViewerPoint; + distance: number; +} + +/** Anything carrying viewport pointer coordinates, e.g. a `MouseEvent`. */ +export interface ViewerPointerLike { + clientX: number; + clientY: number; +} + +export interface ViewerSize { + width: number; + height: number; +} + +/** + * What an installed `ViewerPlugin` may do with the viewer. Deliberately narrow: + * the scene, camera, renderer and controls stay internal, so add-ons only depend + * on these purpose-built primitives. + */ +export interface ViewerExtensionContext { + /** The viewer's canvas (for cursor styles, focus). Do not attach listeners + * for input handling - use `beginInteraction` instead. */ + readonly canvas: HTMLCanvasElement; + /** Adds `object` to a viewer-owned overlay layer: rendered, never picked, + * untouched by `reset()` and backend messages. Returns a remover + * (idempotent). Anything still added is removed on dispose. */ + addOverlay(object: Object3D): () => void; + /** World-space ray under the pointer, or null if the canvas has no size. */ + pointerRay(event: ViewerPointerLike): Ray | null; + /** Where the pointer ray meets the horizontal plane z = `elevation`, or null + * if it doesn't (e.g. the ray runs parallel to it). */ + pointerOnPlane( + event: ViewerPointerLike, + elevation: number, + ): ViewerPoint | null; + /** Visible backend-managed objects under the pointer, nearest first. */ + pickObjects(event: ViewerPointerLike): ViewerObjectHit[]; + /** World AABBs of all visible backend-managed objects (a snapshot). */ + objectBounds(): ViewerObjectBounds[]; + /** Canvas size in CSS pixels (e.g. for `LineMaterial.resolution`). */ + viewportSize(): ViewerSize; + /** Called with the new canvas size whenever the viewer resizes. Returns an + * unsubscribe function. */ + onResize(listener: (size: ViewerSize) => void): () => void; + /** + * Takes over pointer/keyboard input. While held: picking is suspended, any + * selection and transform gizmo are cleared, built-in shortcuts don't fire, + * and events go to `handlers`. Orbiting (right drag) keeps working. At most + * one session exists; beginning another interrupts the current one. + */ + beginInteraction(handlers: InteractionHandlers): InteractionSession; + /** Asks for a redraw after changing overlay objects. */ + requestRender(): void; + /** Called when the viewer is disposed, before its renderer is torn down. + * Returns an unsubscribe function. */ + onDispose(listener: () => void): () => void; +} + +export interface InteractionHandlers { + onPointerDown?(event: MouseEvent): void; + onPointerMove?(event: MouseEvent): void; + onKeyDown?(event: KeyboardEvent): void; + /** The viewer ended the session (another `beginInteraction`, or dispose), + * not the add-on's own `release()`. */ + onInterrupt?(): void; +} + +export interface InteractionSession { + /** False once released or interrupted. */ + readonly active: boolean; + /** Restores normal picking/shortcuts. Idempotent. */ + release(): void; +} diff --git a/src/library/types.ts b/src/library/types.ts index da7034b..ea39c7f 100644 --- a/src/library/types.ts +++ b/src/library/types.ts @@ -1,4 +1,5 @@ import type { Component } from "vue"; +import type { Object3D, Ray } from "three"; import type { CompasViewerError } from "./errors"; export type ViewerMode = "embedded" | "websocket"; @@ -24,6 +25,8 @@ export interface CompasViewerOptions { extraToolbarModules?: Component[]; send?: (message: unknown) => boolean | void; onError?: (error: CompasViewerError) => void; + /** Add-ons installed once the viewer is mounted, in array order. */ + plugins?: ViewerPlugin[]; } export interface CompasViewer { @@ -32,3 +35,67 @@ export interface CompasViewer { resize(): void; dispose(): void; } + +export interface ViewerPlugin { + /** Unique per viewer; a duplicate id throws a `lifecycle_error`. */ + readonly id: string; + /** Called once. The returned function (if any) runs on viewer dispose. */ + install(context: ViewerExtensionContext): void | (() => void); +} + +export interface ViewerPoint { + x: number; + y: number; + z: number; +} + +export interface ViewerObjectBounds { + guid: string; + min: ViewerPoint; + max: ViewerPoint; +} + +export interface ViewerObjectHit { + guid: string; + point: ViewerPoint; + distance: number; +} + +export interface ViewerPointerLike { + clientX: number; + clientY: number; +} + +export interface ViewerSize { + width: number; + height: number; +} + +export interface ViewerExtensionContext { + readonly canvas: HTMLCanvasElement; + addOverlay(object: Object3D): () => void; + pointerRay(event: ViewerPointerLike): Ray | null; + pointerOnPlane( + event: ViewerPointerLike, + elevation: number, + ): ViewerPoint | null; + pickObjects(event: ViewerPointerLike): ViewerObjectHit[]; + objectBounds(): ViewerObjectBounds[]; + viewportSize(): ViewerSize; + onResize(listener: (size: ViewerSize) => void): () => void; + beginInteraction(handlers: InteractionHandlers): InteractionSession; + requestRender(): void; + onDispose(listener: () => void): () => void; +} + +export interface InteractionHandlers { + onPointerDown?(event: MouseEvent): void; + onPointerMove?(event: MouseEvent): void; + onKeyDown?(event: KeyboardEvent): void; + onInterrupt?(): void; +} + +export interface InteractionSession { + readonly active: boolean; + release(): void; +} diff --git a/src/viewer/viewer_extensions.ts b/src/viewer/viewer_extensions.ts new file mode 100644 index 0000000..7a3b70f --- /dev/null +++ b/src/viewer/viewer_extensions.ts @@ -0,0 +1,66 @@ +import { asCompasViewerError, CompasViewerError } from "../library/errors"; +import type { ViewerExtensionContext, ViewerPlugin } from "../library/types"; +import type { ViewerRuntime } from "./viewer_runtime"; + +/** + * The narrow, public view of a runtime handed to each plugin. Built from explicit + * delegates rather than passing the runtime itself, so a plugin can't reach the + * scene/camera/renderer/controls even by ignoring its declared type. + */ +export function createExtensionContext( + runtime: ViewerRuntime, +): ViewerExtensionContext { + return Object.freeze({ + canvas: runtime.renderer.domElement, + addOverlay: (object) => runtime.addOverlay(object), + pointerRay: (event) => runtime.pointerRay(event), + pointerOnPlane: (event, elevation) => + runtime.pointerOnPlane(event, elevation), + pickObjects: (event) => runtime.pickObjects(event), + objectBounds: () => runtime.objectBounds(), + viewportSize: () => runtime.viewportSize(), + onResize: (listener) => runtime.addResizeListener(listener), + beginInteraction: (handlers) => runtime.beginInteraction(handlers), + requestRender: () => runtime.requestRender(), + onDispose: (listener) => runtime.addDisposeListener(listener), + } satisfies ViewerExtensionContext); +} + +/** + * Installs `plugins` in order. Ids are all checked before any plugin installs, + * so a duplicate never leaves the viewer half set up. A cleanup returned from + * `install` runs on dispose, in reverse install order. + */ +export function installPlugins( + runtime: ViewerRuntime, + plugins: readonly ViewerPlugin[], +): void { + const ids = new Set(); + for (const plugin of plugins) { + if (ids.has(plugin.id)) { + throw new CompasViewerError( + "lifecycle_error", + `Duplicate viewer plugin id "${plugin.id}"`, + { details: { plugin: plugin.id } }, + ); + } + ids.add(plugin.id); + } + + if (plugins.length === 0) return; + const context = createExtensionContext(runtime); + for (const plugin of plugins) { + let cleanup: void | (() => void); + try { + cleanup = plugin.install(context); + } catch (error) { + throw asCompasViewerError( + error, + "lifecycle_error", + `Viewer plugin "${plugin.id}" failed to install`, + { plugin: plugin.id }, + ); + } + if (typeof cleanup === "function") runtime.addDisposeListener(cleanup); + } +} diff --git a/src/viewer/viewer_runtime.ts b/src/viewer/viewer_runtime.ts index a1f928b..c838a14 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -8,7 +8,16 @@ import { import { lightToThree } from "../conversions/lights"; import { materialToThree } from "../conversions/material"; import { asCompasViewerError, CompasViewerError } from "../library/errors"; -import type { CompasViewerOptions } from "../library/types"; +import type { + CompasViewerOptions, + InteractionHandlers, + InteractionSession, + ViewerObjectBounds, + ViewerObjectHit, + ViewerPoint, + ViewerPointerLike, + ViewerSize, +} from "../library/types"; import { parseViewerCommand, readGeometryGuid, @@ -95,6 +104,11 @@ const VIEW_PRESETS: Record = { back_right: new THREE.Vector3(1, 1, 1), }; +interface ActiveInteraction { + handlers: InteractionHandlers; + active: boolean; +} + export class ViewerRuntime { readonly store: ViewerStore = createViewerStore(); readonly scene = new THREE.Scene(); @@ -120,9 +134,19 @@ export class ViewerRuntime { private readonly transformHelper: THREE.Object3D; private readonly onResize = () => this.resize(); private readonly onPointerDown = (event: MouseEvent) => - this.pickFromPointer(event); + this.handlePointerDown(event); + private readonly onPointerMove = (event: MouseEvent) => + this.interaction?.handlers.onPointerMove?.(event); private readonly onKeyDown = (event: KeyboardEvent) => this.handleKeyDown(event); + // Plugin-owned objects (see addOverlay): rendered, but never picked and never + // touched by reset() or backend messages, which only ever walk `geometries`. + private readonly overlay = new THREE.Group(); + // The plugin session currently holding pointer/keyboard input, if any - see + // beginInteraction. + private interaction: ActiveInteraction | null = null; + private readonly resizeListeners = new Set<(size: ViewerSize) => void>(); + private readonly disposeListeners = new Set<() => void>(); private animationFrame: number | null = null; private attachedContainer: HTMLElement | null = null; private componentId = 0; @@ -190,6 +214,8 @@ export class ViewerRuntime { this.labelRenderer.domElement.style.inset = "0"; this.labelRenderer.domElement.style.pointerEvents = "none"; this.scene.add(this.axesHelper); + this.overlay.name = "compas-viewer-overlay"; + this.scene.add(this.overlay); this.applyTheme("light"); this.resize(); @@ -215,6 +241,7 @@ export class ViewerRuntime { container.append(this.renderer.domElement, this.labelRenderer.domElement); window.addEventListener("resize", this.onResize); this.renderer.domElement.addEventListener("mousedown", this.onPointerDown); + this.renderer.domElement.addEventListener("mousemove", this.onPointerMove); this.root.addEventListener("keydown", this.onKeyDown); if (this.options.defaultLighting) this.addDefaultLighting(); if ((this.options.mode ?? "embedded") === "websocket") { @@ -478,11 +505,141 @@ export class ViewerRuntime { this.camera.updateProjectionMatrix(); this.renderer.setSize(width, height); this.labelRenderer.setSize(width, height); + for (const listener of this.resizeListeners) listener({ width, height }); + } + + /** Adds a plugin-owned object to the overlay layer - see + * `ViewerExtensionContext.addOverlay`. */ + addOverlay(object: THREE.Object3D): () => void { + this.assertUsable(); + this.overlay.add(object); + return () => { + if (object.parent === this.overlay) this.overlay.remove(object); + }; + } + + pointerRay(event: ViewerPointerLike): THREE.Ray | null { + const pointer = this.pointerNdc(event); + if (!pointer) return null; + this.raycaster.setFromCamera(pointer, this.camera); + return this.raycaster.ray.clone(); + } + + pointerOnPlane( + event: ViewerPointerLike, + elevation: number, + ): ViewerPoint | null { + const ray = this.pointerRay(event); + if (!ray) return null; + const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -elevation); + const hit = ray.intersectPlane(plane, new THREE.Vector3()); + return hit ? this.vectorData(hit) : null; + } + + /** The nearest hit per visible backend-managed object, nearest first. */ + pickObjects(event: ViewerPointerLike): ViewerObjectHit[] { + const pointer = this.pointerNdc(event); + if (!pointer) return []; + this.raycaster.layers.set(0); + this.raycaster.setFromCamera(pointer, this.camera); + const visible = Array.from(this.geometries.values()).filter( + (object) => object.visible, + ); + const hits: ViewerObjectHit[] = []; + const seen = new Set(); + for (const intersection of this.raycaster.intersectObjects(visible, true)) { + const guid = this.findGeometryGuid(intersection.object); + if (!guid || seen.has(guid)) continue; + seen.add(guid); + hits.push({ + guid, + point: this.vectorData(intersection.point), + distance: intersection.distance, + }); + } + return hits; + } + + objectBounds(): ViewerObjectBounds[] { + const bounds: ViewerObjectBounds[] = []; + const box = new THREE.Box3(); + for (const object of this.geometries.values()) { + if (!object.visible) continue; + const guid = this.externalGeometryGuids.get(object); + if (!guid) continue; + box.setFromObject(object); + if (box.isEmpty()) continue; + bounds.push({ + guid, + min: this.vectorData(box.min), + max: this.vectorData(box.max), + }); + } + return bounds; + } + + viewportSize(): ViewerSize { + return this.getDimensions(); + } + + addResizeListener(listener: (size: ViewerSize) => void): () => void { + this.resizeListeners.add(listener); + return () => this.resizeListeners.delete(listener); + } + + addDisposeListener(listener: () => void): () => void { + this.assertUsable(); + this.disposeListeners.add(listener); + return () => this.disposeListeners.delete(listener); + } + + /** + * Hands pointer/keyboard input to a plugin until the returned session is + * released - see `ViewerExtensionContext.beginInteraction`. Clears any current + * pick (and so detaches the transform gizmo) first; `handlePointerDown`/ + * `handleKeyDown` then route events to `handlers` instead of picking and the + * built-in shortcuts. OrbitControls listen on the canvas themselves, so + * orbiting keeps working throughout. + */ + beginInteraction(handlers: InteractionHandlers): InteractionSession { + this.assertUsable(); + this.interruptInteraction(); + this.clearPickedObject(); + const entry: ActiveInteraction = { handlers, active: true }; + this.interaction = entry; + return { + get active() { + return entry.active; + }, + release: () => { + if (!entry.active) return; + entry.active = false; + if (this.interaction === entry) this.interaction = null; + }, + }; } + /** The viewer already re-renders every animation frame, so this is a no-op + * today - it exists so plugins keep working if rendering becomes on-demand. */ + requestRender(): void {} + dispose(): void { if (this.disposed) return; this.disposed = true; + this.runPluginCallback( + () => this.interruptInteraction(), + "A viewer plugin failed to handle an interrupted interaction", + ); + for (const listener of Array.from(this.disposeListeners).reverse()) { + this.runPluginCallback(listener, "A viewer plugin failed to clean up"); + } + this.disposeListeners.clear(); + this.resizeListeners.clear(); + // Removed one by one rather than dropping the whole group, so each object + // gets its own "removed" event - CSS2DObject relies on it to take its DOM + // element off the page. + for (const child of [...this.overlay.children]) this.overlay.remove(child); + this.scene.remove(this.overlay); this.connection.dispose(); window.removeEventListener("resize", this.onResize); this.root.removeEventListener("keydown", this.onKeyDown); @@ -490,6 +647,10 @@ export class ViewerRuntime { "mousedown", this.onPointerDown, ); + this.renderer.domElement.removeEventListener( + "mousemove", + this.onPointerMove, + ); if (this.animationFrame !== null) cancelAnimationFrame(this.animationFrame); this.animationFrame = null; this.clearPickedObject(); @@ -815,6 +976,45 @@ export class ViewerRuntime { Object.assign(overrides, data.overrides); } + private handlePointerDown(event: MouseEvent): void { + const interaction = this.interaction; + if (!interaction) { + this.pickFromPointer(event); + return; + } + this.renderer.domElement.focus({ preventScroll: true }); + interaction.handlers.onPointerDown?.(event); + } + + private interruptInteraction(): void { + const current = this.interaction; + if (!current) return; + current.active = false; + this.interaction = null; + current.handlers.onInterrupt?.(); + } + + private runPluginCallback(callback: () => void, message: string): void { + try { + callback(); + } catch (error) { + this.reportAsyncError( + asCompasViewerError(error, "lifecycle_error", message), + ); + } + } + + /** Normalized device coordinates of a pointer position over the canvas, or + * null while the canvas has no size. */ + private pointerNdc(event: ViewerPointerLike): THREE.Vector2 | null { + const bounds = this.renderer.domElement.getBoundingClientRect(); + if (!bounds.width || !bounds.height) return null; + return new THREE.Vector2( + ((event.clientX - bounds.left) / bounds.width) * 2 - 1, + -((event.clientY - bounds.top) / bounds.height) * 2 + 1, + ); + } + private pickFromPointer(event: MouseEvent): void { this.renderer.domElement.focus({ preventScroll: true }); if ( @@ -825,12 +1025,8 @@ export class ViewerRuntime { ) { return; } - const bounds = this.renderer.domElement.getBoundingClientRect(); - if (!bounds.width || !bounds.height) return; - const pointer = new THREE.Vector2( - ((event.clientX - bounds.left) / bounds.width) * 2 - 1, - -((event.clientY - bounds.top) / bounds.height) * 2 + 1, - ); + const pointer = this.pointerNdc(event); + if (!pointer) return; this.raycaster.layers.set(0); this.raycaster.setFromCamera(pointer, this.camera); const visible = Array.from(this.geometries.values()).filter( @@ -927,6 +1123,13 @@ export class ViewerRuntime { } private handleKeyDown(event: KeyboardEvent): void { + // A plugin session gets every key, so e.g. Escape cancels its session + // rather than just clearing a stale pick. + const interaction = this.interaction; + if (interaction) { + interaction.handlers.onKeyDown?.(event); + return; + } if (event.altKey || event.ctrlKey || event.metaKey) return; if (event.key === "Escape") { this.clearPickedObject(); diff --git a/tests/viewer_extensions.test.ts b/tests/viewer_extensions.test.ts new file mode 100644 index 0000000..461b870 --- /dev/null +++ b/tests/viewer_extensions.test.ts @@ -0,0 +1,416 @@ +/** @vitest-environment happy-dom */ + +import { Box, pbDumpBytes } from "@gramaziokohler/compas-pb-ts"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("three", async () => { + const actual = await vi.importActual("three"); + + class WebGLRenderer { + readonly domElement = document.createElement("canvas"); + readonly shadowMap = { enabled: false, type: 0 }; + toneMapping = 0; + toneMappingExposure = 1; + outputColorSpace = ""; + + setPixelRatio(): void {} + setSize(width: number, height: number): void { + this.domElement.width = width; + this.domElement.height = height; + } + render(): void {} + dispose(): void {} + } + + return { ...actual, WebGLRenderer }; +}); + +import { + CompasViewerError, + createViewer, + type CompasViewer, + type CompasViewerOptions, + type ViewerExtensionContext, + type ViewerPlugin, +} from "../src/library"; +import * as THREE from "three"; + +const viewers: CompasViewer[] = []; + +// The viewer's default camera sits at (8, -15, 15) looking at the origin, so the +// center of a stubbed 800x600 canvas casts a ray straight through (0, 0, 0). +const CENTER = { clientX: 400, clientY: 300 }; + +function boxBytes(guid: string): Uint8Array { + return pbDumpBytes( + new Box({ + data: { + guid, + name: "Box", + frame: { + guid: "frame-guid", + name: "Frame", + point: { guid: "", name: "", x: 0, y: 0, z: 0 }, + xaxis: { guid: "", name: "", x: 1, y: 0, z: 0 }, + yaxis: { guid: "", name: "", x: 0, y: 1, z: 0 }, + }, + xsize: 1, + ysize: 2, + zsize: 3, + }, + }), + ); +} + +/** Creates an embedded viewer with one plugin and returns the context it got. */ +function viewerWithContext(options: CompasViewerOptions = {}): { + viewer: CompasViewer; + context: ViewerExtensionContext; + container: HTMLElement; +} { + const container = document.createElement("div"); + document.body.append(container); + let captured: ViewerExtensionContext | null = null; + const viewer = createViewer(container, { + mode: "embedded", + showToolbar: false, + ...options, + plugins: [ + { + id: "probe", + install(context) { + captured = context; + }, + }, + ...(options.plugins ?? []), + ], + }); + viewers.push(viewer); + const context = captured as ViewerExtensionContext | null; + if (!context) throw new Error("probe plugin was not installed"); + vi.spyOn(context.canvas, "getBoundingClientRect").mockReturnValue({ + left: 0, + top: 0, + width: 800, + height: 600, + } as DOMRect); + return { viewer, context, container }; +} + +function mouse(type: string, button = 0): MouseEvent { + return new MouseEvent(type, { ...CENTER, button, bubbles: true }); +} + +function key(value: string): KeyboardEvent { + return new KeyboardEvent("keydown", { key: value, bubbles: true }); +} + +afterEach(() => { + viewers.splice(0).forEach((viewer) => viewer.dispose()); + document.body.replaceChildren(); +}); + +describe("viewer plugins", () => { + it("installs plugins in order with a narrow, frozen context", () => { + const order: string[] = []; + const plugin = (id: string): ViewerPlugin => ({ + id, + install: () => void order.push(id), + }); + const { context } = viewerWithContext({ + plugins: [plugin("a"), plugin("b")], + }); + + expect(order).toEqual(["a", "b"]); + expect(Object.isFrozen(context)).toBe(true); + expect(context.canvas).toBeInstanceOf(HTMLCanvasElement); + for (const internal of ["scene", "camera", "renderer", "controls"]) { + expect(internal in context).toBe(false); + } + }); + + it("rejects duplicate ids before installing anything", () => { + const install = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + + expect(() => + createViewer(container, { + mode: "embedded", + plugins: [ + { id: "same", install }, + { id: "same", install }, + ], + }), + ).toThrowError( + expect.objectContaining({ + code: "lifecycle_error", + details: { plugin: "same" }, + }), + ); + expect(install).not.toHaveBeenCalled(); + expect(container.childElementCount).toBe(0); + }); + + it("disposes the viewer, cleaning up earlier plugins, when one fails to install", () => { + const cleanup = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + + let thrown: unknown; + try { + createViewer(container, { + mode: "embedded", + plugins: [ + { id: "ok", install: () => cleanup }, + { + id: "broken", + install() { + throw new Error("boom"); + }, + }, + ], + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(CompasViewerError); + expect(thrown).toMatchObject({ + code: "lifecycle_error", + details: { plugin: "broken" }, + }); + expect((thrown as Error).cause).toEqual(new Error("boom")); + expect(cleanup).toHaveBeenCalledOnce(); + expect(container.childElementCount).toBe(0); + }); + + it("runs cleanups in reverse order on dispose and reports their errors", () => { + const order: string[] = []; + const onError = vi.fn(); + const { viewer } = viewerWithContext({ + onError, + plugins: [ + { id: "first", install: () => () => void order.push("first") }, + { + id: "failing", + install: () => () => { + order.push("failing"); + throw new Error("cleanup failed"); + }, + }, + { id: "last", install: () => () => void order.push("last") }, + ], + }); + + viewer.dispose(); + + expect(order).toEqual(["last", "failing", "first"]); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "lifecycle_error" }), + ); + }); +}); + +describe("ViewerExtensionContext", () => { + it("keeps overlays out of picking, reset and backend-managed objects", () => { + const { viewer, context } = viewerWithContext(); + const overlay = new THREE.Mesh( + new THREE.BoxGeometry(100, 100, 100), + new THREE.MeshBasicMaterial(), + ); + const remove = context.addOverlay(overlay); + + expect(overlay.parent?.parent).toBeInstanceOf(THREE.Scene); + // A huge overlay box surrounds the camera ray, yet only backend objects pick. + expect(context.pickObjects(CENTER)).toEqual([]); + expect(context.objectBounds()).toEqual([]); + + viewer.reset(); + expect(overlay.parent).not.toBeNull(); + + remove(); + remove(); + expect(overlay.parent).toBeNull(); + }); + + it("removes remaining overlays on dispose, each with its own removed event", () => { + const { viewer, context } = viewerWithContext(); + const nested = new THREE.Group(); + const leftover = new THREE.Object3D(); + const removed = vi.fn(); + leftover.addEventListener("removed", removed); + context.addOverlay(nested); + context.addOverlay(leftover); + + viewer.dispose(); + + expect(removed).toHaveBeenCalledOnce(); + expect(leftover.parent).toBeNull(); + expect(nested.parent).toBeNull(); + expect(() => context.addOverlay(new THREE.Object3D())).toThrowError( + expect.objectContaining({ code: "lifecycle_error" }), + ); + }); + + it("casts pointer rays onto horizontal planes", () => { + const { context } = viewerWithContext(); + + const ray = context.pointerRay(CENTER); + expect(ray?.origin.distanceTo(new THREE.Vector3(8, -15, 15))).toBeCloseTo( + 0, + ); + + const ground = context.pointerOnPlane(CENTER, 0); + expect(ground?.x).toBeCloseTo(0); + expect(ground?.y).toBeCloseTo(0); + expect(ground?.z).toBeCloseTo(0); + + // Halfway up the camera's height, the ray is halfway to the origin. + const raised = context.pointerOnPlane(CENTER, 7.5); + expect(raised?.x).toBeCloseTo(4); + expect(raised?.y).toBeCloseTo(-7.5); + expect(raised?.z).toBeCloseTo(7.5); + + vi.mocked(context.canvas.getBoundingClientRect).mockReturnValue({ + left: 0, + top: 0, + width: 0, + height: 0, + } as DOMRect); + expect(context.pointerRay(CENTER)).toBeNull(); + expect(context.pointerOnPlane(CENTER, 0)).toBeNull(); + expect(context.pickObjects(CENTER)).toEqual([]); + }); + + it("picks and bounds visible backend objects by guid", () => { + const { viewer, context } = viewerWithContext(); + viewer.dispatch(boxBytes("box-guid")); + + const [hit, ...rest] = context.pickObjects(CENTER); + expect(rest).toEqual([]); + expect(hit?.guid).toBe("box-guid"); + expect(hit?.distance).toBeGreaterThan(0); + + const [bounds] = context.objectBounds(); + expect(bounds?.guid).toBe("box-guid"); + expect(bounds?.min.x).toBeCloseTo(-0.5); + expect(bounds?.min.y).toBeCloseTo(-1); + expect(bounds?.min.z).toBeCloseTo(-1.5); + expect(bounds?.max.x).toBeCloseTo(0.5); + expect(bounds?.max.y).toBeCloseTo(1); + expect(bounds?.max.z).toBeCloseTo(1.5); + }); + + it("reports viewport size and notifies resize listeners until unsubscribed", () => { + const { viewer, context } = viewerWithContext(); + const listener = vi.fn(); + const unsubscribe = context.onResize(listener); + + viewer.resize(); + expect(listener).toHaveBeenCalledWith(context.viewportSize()); + + unsubscribe(); + viewer.resize(); + expect(listener).toHaveBeenCalledOnce(); + }); +}); + +describe("beginInteraction", () => { + it("routes input to the session instead of picking and shortcuts, until released", () => { + const send = vi.fn(); + const { viewer, context } = viewerWithContext({ send }); + viewer.dispatch(boxBytes("box-guid")); + const canvas = context.canvas; + + // Ordinary picking works before any session. + canvas.dispatchEvent(mouse("mousedown")); + expect(send).toHaveBeenCalledWith({ + dispatch: "object_picked", + guid: "box-guid", + }); + send.mockClear(); + + const handlers = { + onPointerDown: vi.fn(), + onPointerMove: vi.fn(), + onKeyDown: vi.fn(), + }; + const session = context.beginInteraction(handlers); + expect(session.active).toBe(true); + + canvas.dispatchEvent(mouse("mousedown")); + canvas.dispatchEvent(mouse("mousemove")); + canvas.dispatchEvent(key("Escape")); + canvas.dispatchEvent(key("p")); + expect(handlers.onPointerDown).toHaveBeenCalledOnce(); + expect(handlers.onPointerMove).toHaveBeenCalledOnce(); + expect(handlers.onKeyDown.mock.calls.map(([event]) => event.key)).toEqual([ + "Escape", + "p", + ]); + expect(send).not.toHaveBeenCalled(); + + session.release(); + session.release(); + expect(session.active).toBe(false); + + canvas.dispatchEvent(mouse("mousedown")); + canvas.dispatchEvent(mouse("mousemove")); + expect(handlers.onPointerDown).toHaveBeenCalledOnce(); + expect(handlers.onPointerMove).toHaveBeenCalledOnce(); + expect(send).toHaveBeenCalledWith({ + dispatch: "object_picked", + guid: "box-guid", + }); + }); + + it("interrupts the previous session when another begins", () => { + const { context } = viewerWithContext(); + const first = { onInterrupt: vi.fn(), onPointerDown: vi.fn() }; + const second = { onInterrupt: vi.fn(), onPointerDown: vi.fn() }; + + const firstSession = context.beginInteraction(first); + const secondSession = context.beginInteraction(second); + + expect(first.onInterrupt).toHaveBeenCalledOnce(); + expect(firstSession.active).toBe(false); + expect(secondSession.active).toBe(true); + + // Releasing the stale session must not end the current one. + firstSession.release(); + context.canvas.dispatchEvent(mouse("mousedown")); + expect(second.onPointerDown).toHaveBeenCalledOnce(); + expect(first.onPointerDown).not.toHaveBeenCalled(); + + secondSession.release(); + expect(second.onInterrupt).not.toHaveBeenCalled(); + }); + + it("interrupts the active session on dispose before cleanups run", () => { + const order: string[] = []; + let context: ViewerExtensionContext | null = null; + const { viewer } = viewerWithContext({ + plugins: [ + { + id: "session-owner", + install(ctx) { + context = ctx; + return () => void order.push("cleanup"); + }, + }, + ], + }); + const session = context!.beginInteraction({ + onInterrupt: () => void order.push("interrupt"), + }); + + viewer.dispose(); + + expect(order).toEqual(["interrupt", "cleanup"]); + expect(session.active).toBe(false); + expect(() => context!.beginInteraction({})).toThrowError( + expect.objectContaining({ code: "lifecycle_error" }), + ); + }); +}); From 69ca6e4fc416b12db3bf446c532ef19883779fc0 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 09:11:43 +0200 Subject: [PATCH 2/9] docs(examples): add an embedded plugin example and README section Co-Authored-By: Claude Opus 5.5 --- README.md | 36 ++++++ examples/embedded_extension_plugin.html | 162 ++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 examples/embedded_extension_plugin.html diff --git a/README.md b/README.md index 8941ace..41e3c45 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,42 @@ the supported geometry objects they contain. In `websocket` mode the standalone app reads `ws_host`, `ws_port`, and `workspace` from the URL, preserving the Python package integration. +### Plugins + +Add-ons such as authoring tools extend the viewer through `plugins` rather than +through the core package. Each plugin gets a narrow `ViewerExtensionContext` +when the viewer mounts. It can add its own objects to an overlay layer that is +never picked or reset, raycast the pointer onto a horizontal plane or against +backend objects, read object bounds, and take over pointer and keyboard input +for the length of a session: + +```ts +import type { ViewerPlugin } from "@compas-dev/compas-threejs-ts"; + +const plugin: ViewerPlugin = { + id: "my-tool", + install(context) { + const session = context.beginInteraction({ + onPointerDown(event) { + const point = context.pointerOnPlane(event, 0); + // ... + }, + onKeyDown(event) { + if (event.key === "Escape") session.release(); + }, + }); + return () => session.release(); // runs on viewer.dispose() + }, +}; + +createViewer(container, { plugins: [plugin] }); +``` + +While a session is held, ordinary picking, the transform gizmo and the built-in +keyboard shortcuts are suspended; orbiting keeps working. The scene, camera, +renderer and controls are deliberately not part of this API. See +`examples/embedded_extension_plugin.html` for a complete example. + For a broader visual smoke test, open `examples/embedded_kitchen_sink.html`. It uses the same public embedded API to display every geometry and helper type included in the 1.0 support matrix in a diff --git a/examples/embedded_extension_plugin.html b/examples/embedded_extension_plugin.html new file mode 100644 index 0000000..bbe9ce2 --- /dev/null +++ b/examples/embedded_extension_plugin.html @@ -0,0 +1,162 @@ + + + + + + Embedded COMPAS ThreeJS plugin + + + + + + +
+ + + + + From 5c1b2f95e120e5df1346f115863497d877ac05cc Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 09:22:30 +0200 Subject: [PATCH 3/9] fix(viewer): focus the canvas when a plugin interaction begins A session started from a button outside the viewer left focus on that button, so keystrokes such as Escape never reached the session's onKeyDown until the user clicked the canvas. Co-Authored-By: Claude Opus 5.5 --- src/library/public.d.ts | 9 +++++---- src/viewer/viewer_runtime.ts | 5 ++++- tests/viewer_extensions.test.ts | 8 +++++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/library/public.d.ts b/src/library/public.d.ts index 84bc11e..b5643e3 100644 --- a/src/library/public.d.ts +++ b/src/library/public.d.ts @@ -166,10 +166,11 @@ export interface ViewerExtensionContext { * unsubscribe function. */ onResize(listener: (size: ViewerSize) => void): () => void; /** - * Takes over pointer/keyboard input. While held: picking is suspended, any - * selection and transform gizmo are cleared, built-in shortcuts don't fire, - * and events go to `handlers`. Orbiting (right drag) keeps working. At most - * one session exists; beginning another interrupts the current one. + * Takes over pointer/keyboard input and focuses the canvas. While held: + * picking is suspended, any selection and transform gizmo are cleared, + * built-in shortcuts don't fire, and events go to `handlers`. Orbiting (right + * drag) keeps working. At most one session exists; beginning another + * interrupts the current one. */ beginInteraction(handlers: InteractionHandlers): InteractionSession; /** Asks for a redraw after changing overlay objects. */ diff --git a/src/viewer/viewer_runtime.ts b/src/viewer/viewer_runtime.ts index c838a14..346bd9f 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -599,12 +599,15 @@ export class ViewerRuntime { * pick (and so detaches the transform gizmo) first; `handlePointerDown`/ * `handleKeyDown` then route events to `handlers` instead of picking and the * built-in shortcuts. OrbitControls listen on the canvas themselves, so - * orbiting keeps working throughout. + * orbiting keeps working throughout. Focuses the canvas, since a session is + * often started from a button outside the viewer, whose focus would otherwise + * keep keystrokes (e.g. Escape) from ever reaching `handleKeyDown`. */ beginInteraction(handlers: InteractionHandlers): InteractionSession { this.assertUsable(); this.interruptInteraction(); this.clearPickedObject(); + this.renderer.domElement.focus({ preventScroll: true }); const entry: ActiveInteraction = { handlers, active: true }; this.interaction = entry; return { diff --git a/tests/viewer_extensions.test.ts b/tests/viewer_extensions.test.ts index 461b870..1b7236c 100644 --- a/tests/viewer_extensions.test.ts +++ b/tests/viewer_extensions.test.ts @@ -336,12 +336,18 @@ describe("beginInteraction", () => { onPointerMove: vi.fn(), onKeyDown: vi.fn(), }; + // Started from a control outside the viewer, which holds focus. + const outside = document.createElement("button"); + document.body.append(outside); + outside.focus(); const session = context.beginInteraction(handlers); expect(session.active).toBe(true); + expect(document.activeElement).toBe(canvas); canvas.dispatchEvent(mouse("mousedown")); canvas.dispatchEvent(mouse("mousemove")); - canvas.dispatchEvent(key("Escape")); + // Keys typed after focus moved reach the session via the viewer root. + document.activeElement!.dispatchEvent(key("Escape")); canvas.dispatchEvent(key("p")); expect(handlers.onPointerDown).toHaveBeenCalledOnce(); expect(handlers.onPointerMove).toHaveBeenCalledOnce(); From 3d418b19ecc02db1f0a51dfd2bd97c384f132265 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 10:47:38 +0200 Subject: [PATCH 4/9] feat(viewer): let plugins message the backend, follow the selection and edit materials Adds send(), selection(), onSelectionChange(), getMaterial() and setMaterial() to ViewerExtensionContext, so add-ons can create backend objects (create_geometry) and host the material editor. Co-Authored-By: Claude Opus 5.5 --- scripts/test-package.mjs | 6 +++- src/library/index.ts | 1 + src/library/public.d.ts | 26 ++++++++++++++++ src/library/types.ts | 11 +++++++ src/viewer/viewer_extensions.ts | 19 ++++++++++++ tests/viewer_extensions.test.ts | 53 +++++++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 1 deletion(-) diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 546d91c..88f72f1 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -102,7 +102,11 @@ try { ` const session = context.beginInteraction({ onKeyDown: (event) => void event.key });\n` + ` void context.pointerOnPlane({ clientX: 0, clientY: 0 }, 0)?.z;\n` + ` void context.objectBounds()[0]?.guid;\n` + - ` return () => session.release();\n` + + ` const stop = context.onSelectionChange((guid) => {\n` + + ` if (guid) void context.getMaterial(guid)?.color;\n` + + ` });\n` + + ` context.send({ dispatch: "create_geometry", type: "point", point: [0, 0, 0] });\n` + + ` return () => { stop(); session.release(); };\n` + ` },\n` + `};\n` + `const options: CompasViewerOptions = { mode: "embedded", plugins: [plugin] };\n` + diff --git a/src/library/index.ts b/src/library/index.ts index 113d6bc..337698d 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -15,6 +15,7 @@ export type { InteractionHandlers, InteractionSession, ViewerExtensionContext, + ViewerMaterial, ViewerMode, ViewerObjectBounds, ViewerObjectHit, diff --git a/src/library/public.d.ts b/src/library/public.d.ts index b5643e3..d919f6d 100644 --- a/src/library/public.d.ts +++ b/src/library/public.d.ts @@ -178,6 +178,32 @@ export interface ViewerExtensionContext { /** Called when the viewer is disposed, before its renderer is torn down. * Returns an unsubscribe function. */ onDispose(listener: () => void): () => void; + /** + * Sends a JSON message to the backend - over the WebSocket in `websocket` mode, + * or to `CompasViewerOptions.send` in `embedded` mode - exactly like the + * viewer's own messages (`{ dispatch: "create_geometry", ... }` and so on). + * Returns whether it was handed to a transport. + */ + send(message: Record): boolean; + /** Guid of the currently picked backend object, or null. */ + selection(): string | null; + /** Called with the new guid (or null) whenever the pick changes. Returns an + * unsubscribe function. */ + onSelectionChange(listener: (guid: string | null) => void): () => void; + /** The standard material of the object at `guid`, or null if it has none + * (or a non-standard one, e.g. a point's). */ + getMaterial(guid: string): ViewerMaterial | null; + /** Edits the object's standard material: applied locally at once, and sent + * to the backend as `{ dispatch: "material_edit", guid, ...fields }`. */ + setMaterial(guid: string, fields: Partial): void; +} + +/** The editable fields of an object's standard material. */ +export interface ViewerMaterial { + /** `#rrggbb` hex color. */ + color: string; + metalness: number; + roughness: number; } export interface InteractionHandlers { diff --git a/src/library/types.ts b/src/library/types.ts index ea39c7f..7573ebb 100644 --- a/src/library/types.ts +++ b/src/library/types.ts @@ -86,6 +86,17 @@ export interface ViewerExtensionContext { beginInteraction(handlers: InteractionHandlers): InteractionSession; requestRender(): void; onDispose(listener: () => void): () => void; + send(message: Record): boolean; + selection(): string | null; + onSelectionChange(listener: (guid: string | null) => void): () => void; + getMaterial(guid: string): ViewerMaterial | null; + setMaterial(guid: string, fields: Partial): void; +} + +export interface ViewerMaterial { + color: string; + metalness: number; + roughness: number; } export interface InteractionHandlers { diff --git a/src/viewer/viewer_extensions.ts b/src/viewer/viewer_extensions.ts index 7a3b70f..3ebe6c6 100644 --- a/src/viewer/viewer_extensions.ts +++ b/src/viewer/viewer_extensions.ts @@ -1,3 +1,5 @@ +import { watch } from "vue"; + import { asCompasViewerError, CompasViewerError } from "../library/errors"; import type { ViewerExtensionContext, ViewerPlugin } from "../library/types"; import type { ViewerRuntime } from "./viewer_runtime"; @@ -23,6 +25,23 @@ export function createExtensionContext( beginInteraction: (handlers) => runtime.beginInteraction(handlers), requestRender: () => runtime.requestRender(), onDispose: (listener) => runtime.addDisposeListener(listener), + send: (message) => runtime.sendData(message), + selection: () => runtime.store.pickedObjectGuid.value, + onSelectionChange(listener) { + // Synchronous, so a plugin sees the change in the same tick as the pick. + const stop = watch( + () => runtime.store.pickedObjectGuid.value, + (guid) => listener(guid), + { flush: "sync" }, + ); + const removeDispose = runtime.addDisposeListener(stop); + return () => { + stop(); + removeDispose(); + }; + }, + getMaterial: (guid) => runtime.getMaterialSnapshot(guid), + setMaterial: (guid, fields) => runtime.setMaterial(guid, fields), } satisfies ViewerExtensionContext); } diff --git a/tests/viewer_extensions.test.ts b/tests/viewer_extensions.test.ts index 1b7236c..62b9765 100644 --- a/tests/viewer_extensions.test.ts +++ b/tests/viewer_extensions.test.ts @@ -316,6 +316,59 @@ describe("ViewerExtensionContext", () => { }); }); +describe("backend, selection and material access", () => { + it("sends plugin messages through the viewer's transport", () => { + const send = vi.fn(); + const { context } = viewerWithContext({ send }); + + const message = { + dispatch: "create_geometry", + type: "point", + point: [1, 2, 3], + }; + expect(context.send(message)).toBe(true); + + expect(send).toHaveBeenCalledWith(message); + }); + + it("reports the picked object and notifies until unsubscribed", () => { + const { viewer, context } = viewerWithContext(); + viewer.dispatch(boxBytes("box-guid")); + const listener = vi.fn(); + const unsubscribe = context.onSelectionChange(listener); + expect(context.selection()).toBeNull(); + + context.canvas.dispatchEvent(mouse("mousedown")); + expect(context.selection()).toBe("box-guid"); + expect(listener).toHaveBeenLastCalledWith("box-guid"); + + // Beginning an interaction clears the pick. + context.beginInteraction({}).release(); + expect(listener).toHaveBeenLastCalledWith(null); + + unsubscribe(); + context.canvas.dispatchEvent(mouse("mousedown")); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("reads and edits an object's material, telling the backend", () => { + const send = vi.fn(); + const { viewer, context } = viewerWithContext({ send }); + viewer.dispatch(boxBytes("box-guid")); + + // A box the backend sent without a material has nothing to read. + expect(context.getMaterial("box-guid")).toBeNull(); + + context.setMaterial("box-guid", { color: "#ff0000", roughness: 0.2 }); + expect(send).toHaveBeenCalledWith({ + dispatch: "material_edit", + guid: "box-guid", + color: "#ff0000", + roughness: 0.2, + }); + }); +}); + describe("beginInteraction", () => { it("routes input to the session instead of picking and shortcuts, until released", () => { const send = vi.fn(); From 2448049603cb2fa2635cf2229e19d6d0fec3db94 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 11:12:28 +0200 Subject: [PATCH 5/9] fix(viewer): keep material edits on a picked object after deselecting it A picked object wears the pick highlight, with its own material parked until deselection. A material update for it replaced the highlight but left the stale parked material, so deselecting put the old color back until the object was rebuilt (e.g. by moving it). Updates now replace the parked material, and a local edit shows the real material at once. Co-Authored-By: Claude Opus 5.5 --- src/viewer/viewer_runtime.ts | 30 ++++++++++++++++++- tests/viewer_lifecycle.test.ts | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/viewer/viewer_runtime.ts b/src/viewer/viewer_runtime.ts index 346bd9f..a00afdd 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -377,6 +377,10 @@ export class ViewerRuntime { 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; + // Show the edit right away instead of leaving it under the highlight. + if (this.geometries.get(guid) === this.pickedObject) { + this.revealPickedMaterial(); + } } this.sendData({ dispatch: "material_edit", guid, ...fields }); } @@ -805,7 +809,16 @@ export class ViewerRuntime { for (const [objectGuid, materialGuid] of this.geometryMaterials) { if (materialGuid !== guid) continue; const object = this.geometries.get(objectGuid); - if (object) this.assignMaterial(object, material); + if (!object) continue; + // A picked object wears the highlight, with its own material parked in + // `pickedMaterial`. Swap the new material in for the parked one - not for + // the highlight - so deselecting restores it rather than a stale one. + const picked = object === this.pickedObject; + if (picked) this.revealPickedMaterial(); + this.assignMaterial(object, material); + if (picked) { + this.pickedMaterial = (object as RenderableObject).material ?? null; + } } this.materials.set(guid, { material, @@ -1058,6 +1071,21 @@ export class ViewerRuntime { this.store.pickedObjectGuid.value = guid ?? null; } + /** + * Shows the picked object's own material instead of the pick highlight, for + * while that material is being edited. The object stays picked, and + * `clearPickedObject` restores the same material. + */ + private revealPickedMaterial(): void { + if ( + this.pickedObject && + this.pickedMaterial && + "material" in this.pickedObject + ) { + (this.pickedObject as RenderableObject).material = this.pickedMaterial; + } + } + private clearPickedObject(): void { if ( this.pickedObject && diff --git a/tests/viewer_lifecycle.test.ts b/tests/viewer_lifecycle.test.ts index 445a9ae..100755e 100644 --- a/tests/viewer_lifecycle.test.ts +++ b/tests/viewer_lifecycle.test.ts @@ -290,6 +290,61 @@ describe("createViewer", () => { expect(registeredDispose).toHaveBeenCalledOnce(); }); + it("keeps a material edit on a picked object after it is deselected", () => { + const container = document.createElement("div"); + document.body.append(container); + const runtime = new ViewerRuntime(container, { mode: "embedded" }); + runtime.attach(container); + vi.spyOn( + runtime.renderer.domElement, + "getBoundingClientRect", + ).mockReturnValue({ left: 0, top: 0, width: 800, height: 600 } as DOMRect); + // A backend object without a material of its own, like `add_geometry(box)`. + runtime.dispatch(boxBytes("plain-box")); + const box = runtime.geometries.get("plain-box") as THREE.Mesh; + const internals = runtime as unknown as { + dispatchObject(object: unknown): void; + pickFromPointer(event: MouseEvent): void; + clearPickedObject(): void; + }; + internals.pickFromPointer( + new MouseEvent("mousedown", { clientX: 400, clientY: 300, button: 0 }), + ); + expect(runtime.store.pickedObjectGuid.value).toBe("plain-box"); + + // The backend's echo of a material edit arrives while the box is picked. + internals.dispatchObject({ + dispatch: "material", + type: "standard_material", + guid: "edited-material", + geometry_guid: "plain-box", + color: "#00ff00", + metalness: 0, + roughness: 1, + emissive: "#000000", + emissive_intensity: 0, + flat_shading: false, + wireframe: false, + transparent: false, + opacity: 1, + }); + const shown = () => + `#${(box.material as THREE.MeshStandardMaterial).color.getHexString()}`; + expect(shown()).toBe("#00ff00"); + + internals.clearPickedObject(); + expect(shown()).toBe("#00ff00"); + + // A local edit shows at once too, instead of hiding under the highlight. + internals.pickFromPointer( + new MouseEvent("mousedown", { clientX: 400, clientY: 300, button: 0 }), + ); + runtime.setMaterial("plain-box", { color: "#0000ff" }); + expect(shown()).toBe("#0000ff"); + + runtime.dispose(); + }); + it("renders geometry without an external GUID", () => { const container = document.createElement("div"); document.body.append(container); From 0182a27ba72a05777221bfa022b0a5a5a87cdb96 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 11:31:54 +0200 Subject: [PATCH 6/9] feat(viewer): let plugins snap transform gizmo edits to a grid and angle Adds setTransformSnap({ grid, angle }) to ViewerExtensionContext. Translate and scale snap while dragging, and on release the object's bounding-box faces that moved land exactly on the grid (the correction from feature/bidirectional-sync's grid snap). Rotate snaps to the angle step. Co-Authored-By: Claude Opus 5.5 --- src/library/index.ts | 1 + src/library/public.d.ts | 14 +++++ src/library/types.ts | 6 ++ src/viewer/viewer_extensions.ts | 1 + src/viewer/viewer_runtime.ts | 78 ++++++++++++++++++++++++++ tests/viewer_lifecycle.test.ts | 97 +++++++++++++++++++++++++++++++++ 6 files changed, 197 insertions(+) diff --git a/src/library/index.ts b/src/library/index.ts index 337698d..371f2aa 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -16,6 +16,7 @@ export type { InteractionSession, ViewerExtensionContext, ViewerMaterial, + ViewerTransformSnap, ViewerMode, ViewerObjectBounds, ViewerObjectHit, diff --git a/src/library/public.d.ts b/src/library/public.d.ts index d919f6d..7d67605 100644 --- a/src/library/public.d.ts +++ b/src/library/public.d.ts @@ -196,6 +196,20 @@ export interface ViewerExtensionContext { /** Edits the object's standard material: applied locally at once, and sent * to the backend as `{ dispatch: "material_edit", guid, ...fields }`. */ setMaterial(guid: string, fields: Partial): void; + /** + * Snaps edits made with the transform gizmo. `grid` (world units): translate + * and scale snap while dragging, and on release the object's bounding-box + * faces that moved land exactly on the grid. `angle` (radians): rotate snaps + * to that step. null turns either off. Off by default. + */ + setTransformSnap(snap: ViewerTransformSnap): void; +} + +export interface ViewerTransformSnap { + /** Grid step in world units, or null for no snapping. */ + grid: number | null; + /** Rotation step in radians, or null for no snapping. */ + angle: number | null; } /** The editable fields of an object's standard material. */ diff --git a/src/library/types.ts b/src/library/types.ts index 7573ebb..cb92cba 100644 --- a/src/library/types.ts +++ b/src/library/types.ts @@ -91,6 +91,12 @@ export interface ViewerExtensionContext { onSelectionChange(listener: (guid: string | null) => void): () => void; getMaterial(guid: string): ViewerMaterial | null; setMaterial(guid: string, fields: Partial): void; + setTransformSnap(snap: ViewerTransformSnap): void; +} + +export interface ViewerTransformSnap { + grid: number | null; + angle: number | null; } export interface ViewerMaterial { diff --git a/src/viewer/viewer_extensions.ts b/src/viewer/viewer_extensions.ts index 3ebe6c6..d239ee5 100644 --- a/src/viewer/viewer_extensions.ts +++ b/src/viewer/viewer_extensions.ts @@ -42,6 +42,7 @@ export function createExtensionContext( }, getMaterial: (guid) => runtime.getMaterialSnapshot(guid), setMaterial: (guid, fields) => runtime.setMaterial(guid, fields), + setTransformSnap: (snap) => runtime.setTransformSnap(snap), } satisfies ViewerExtensionContext); } diff --git a/src/viewer/viewer_runtime.ts b/src/viewer/viewer_runtime.ts index a00afdd..66a6806 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -155,6 +155,10 @@ export class ViewerRuntime { private pickedMaterial: THREE.Material | THREE.Material[] | null = null; private readonly hiddenGuids = new Set(); private dragStartMatrix: THREE.Matrix4 | null = null; + // World bounds of the dragged object at drag start, for snapDragToGrid. + private dragStartBox: THREE.Box3 | null = null; + // Grid step gizmo edits snap to, or null - see setTransformSnap. + private transformSnapGrid: number | null = null; private readonly highlightMaterial = new THREE.MeshStandardMaterial({ color: "orange", emissive: "yellow", @@ -203,9 +207,13 @@ export class ViewerRuntime { // sits at its absolute world placement, not at the origin. this.dragStartMatrix = this.transformControls.object?.matrix.clone() ?? null; + this.dragStartBox = this.transformControls.object + ? new THREE.Box3().setFromObject(this.transformControls.object) + : null; } }); this.transformControls.addEventListener("mouseUp", () => { + this.snapDragToGrid(); this.sendObjectTransform(); }); this.scene.add(this.transformHelper); @@ -626,6 +634,23 @@ export class ViewerRuntime { }; } + /** + * Snaps gizmo edits - see `ViewerExtensionContext.setTransformSnap`. + * `TransformControls.setTranslationSnap` gives live feedback while dragging, + * but it snaps the object's origin (a box's center), which puts a box whose + * size is an odd number of grid steps half a step off the grid. Its + * `setScaleSnap` snaps the relative scale factor, which has nothing to do + * with world size, so it's never used. `snapDragToGrid` makes the exact + * correction on release. + */ + setTransformSnap(snap: { grid: number | null; angle: number | null }): void { + const grid = snap.grid !== null && snap.grid > 0 ? snap.grid : null; + const angle = snap.angle !== null && snap.angle > 0 ? snap.angle : null; + this.transformSnapGrid = grid; + this.transformControls.setTranslationSnap(grid); + this.transformControls.setRotationSnap(angle); + } + /** The viewer already re-renders every animation frame, so this is a no-op * today - it exists so plugins keep working if rendering becomes on-demand. */ requestRender(): void {} @@ -1071,6 +1096,59 @@ export class ViewerRuntime { this.store.pickedObjectGuid.value = guid ?? null; } + /** + * Once a translate or scale drag ends, moves the object's world bounding-box + * faces that moved onto the transform-snap grid, before the transform is + * sent to the backend. Scale snaps each moved face to the nearest grid line. + * Translate snaps the distance moved instead, since a box whose faces were on + * the grid stays on it after moving whole grid steps, whatever its size. + * Axes that didn't move keep their exact pre-drag values. Applied once on + * release, because TransformControls recomputes the object from the drag + * start on every pointer move and would undo an in-drag correction. + */ + private snapDragToGrid(): void { + const size = this.transformSnapGrid; + const object = this.transformControls.object; + const start = this.dragStartBox; + const mode = this.transformControls.mode; + this.dragStartBox = null; + if (size === null || !object || !start || mode === "rotate") return; + + const EPS = 1e-6; + const snap = (value: number) => Math.round(value / size) * size; + const current = new THREE.Box3().setFromObject(object); + const snappedMin = start.min.clone(); + const snappedMax = start.max.clone(); + for (const axis of ["x", "y", "z"] as const) { + const minMoved = Math.abs(current.min[axis] - start.min[axis]) > EPS; + const maxMoved = Math.abs(current.max[axis] - start.max[axis]) > EPS; + if (!minMoved && !maxMoved) continue; + if (mode === "scale") { + if (minMoved) snappedMin[axis] = snap(current.min[axis]); + if (maxMoved) snappedMax[axis] = snap(current.max[axis]); + } else { + const delta = snap(current.min[axis] - start.min[axis]); + snappedMin[axis] = start.min[axis] + delta; + snappedMax[axis] = start.max[axis] + delta; + } + } + + const currentSize = current.getSize(new THREE.Vector3()); + const snappedSize = new THREE.Vector3().subVectors(snappedMax, snappedMin); + const currentCenter = current.getCenter(new THREE.Vector3()); + const snappedCenter = snappedMin + .clone() + .add(snappedMax) + .multiplyScalar(0.5); + for (const axis of ["x", "y", "z"] as const) { + if (currentSize[axis] > EPS) { + object.scale[axis] *= snappedSize[axis] / currentSize[axis]; + } + object.position[axis] += snappedCenter[axis] - currentCenter[axis]; + } + object.updateMatrixWorld(true); + } + /** * Shows the picked object's own material instead of the pick highlight, for * while that material is being edited. The object stays picked, and diff --git a/tests/viewer_lifecycle.test.ts b/tests/viewer_lifecycle.test.ts index 100755e..aed74d9 100644 --- a/tests/viewer_lifecycle.test.ts +++ b/tests/viewer_lifecycle.test.ts @@ -345,6 +345,103 @@ describe("createViewer", () => { runtime.dispose(); }); + describe("setTransformSnap", () => { + function setup() { + const container = document.createElement("div"); + document.body.append(container); + const send = vi.fn(); + const runtime = new ViewerRuntime(container, { mode: "embedded", send }); + runtime.attach(container); + // Box of size 1 x 2 x 3 centered on the origin. + runtime.dispatch(boxBytes("snap-box")); + const box = runtime.geometries.get("snap-box")!; + const internals = runtime as unknown as { + transformControls: THREE.EventDispatcher & { + attach(object: THREE.Object3D): void; + setMode(mode: string): void; + translationSnap: number | null; + rotationSnap: number | null; + }; + }; + const controls = internals.transformControls; + controls.attach(box); + function drag(mode: string, edit: () => void): void { + controls.setMode(mode); + controls.dispatchEvent({ + type: "dragging-changed", + value: true, + } as never); + edit(); + box.updateMatrixWorld(true); + controls.dispatchEvent({ + type: "dragging-changed", + value: false, + } as never); + controls.dispatchEvent({ type: "mouseUp" } as never); + } + return { runtime, box, controls, drag, send }; + } + + it("sets the gizmo's translation and rotation snap", () => { + const { runtime, controls } = setup(); + runtime.setTransformSnap({ grid: 0.5, angle: Math.PI / 12 }); + expect(controls.translationSnap).toBe(0.5); + expect(controls.rotationSnap).toBeCloseTo(Math.PI / 12); + + runtime.setTransformSnap({ grid: null, angle: null }); + expect(controls.translationSnap).toBeNull(); + expect(controls.rotationSnap).toBeNull(); + runtime.dispose(); + }); + + it("snaps the distance moved on release, leaving other axes alone", () => { + const { runtime, box, drag, send } = setup(); + runtime.setTransformSnap({ grid: 0.5, angle: null }); + + drag("translate", () => { + box.position.x += 0.37; + box.position.y += 0.0001; + }); + + expect(box.position.x).toBeCloseTo(0.5, 9); + expect(box.position.y).toBeCloseTo(0, 9); + expect(box.position.z).toBeCloseTo(0, 9); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + dispatch: "object_transform", + guid: "snap-box", + }), + ); + runtime.dispose(); + }); + + it("snaps each moved face to the grid when scaling", () => { + const { runtime, box, drag } = setup(); + runtime.setTransformSnap({ grid: 1, angle: null }); + + // x faces move from -0.5/0.5 to -0.85/0.85: they snap to -1 and 1. + drag("scale", () => { + box.scale.x = 1.7; + }); + + const bounds = new THREE.Box3().setFromObject(box); + expect(bounds.min.x).toBeCloseTo(-1, 9); + expect(bounds.max.x).toBeCloseTo(1, 9); + expect(bounds.min.y).toBeCloseTo(-1, 9); + expect(bounds.max.z).toBeCloseTo(1.5, 9); + runtime.dispose(); + }); + + it("leaves drags alone while snapping is off", () => { + const { box, drag, runtime } = setup(); + drag("translate", () => { + box.position.x += 0.37; + }); + expect(box.position.x).toBeCloseTo(0.37, 9); + runtime.dispose(); + }); + }); + it("renders geometry without an external GUID", () => { const container = document.createElement("div"); document.body.append(container); From 212d19a6dbf0f83c5c6afa0d51d411495f72a1d1 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 11:32:16 +0200 Subject: [PATCH 7/9] test(package): cover setTransformSnap in the consumer typecheck Co-Authored-By: Claude Opus 5.5 --- scripts/test-package.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 88f72f1..038a6f0 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -106,6 +106,7 @@ try { ` if (guid) void context.getMaterial(guid)?.color;\n` + ` });\n` + ` context.send({ dispatch: "create_geometry", type: "point", point: [0, 0, 0] });\n` + + ` context.setTransformSnap({ grid: 0.5, angle: Math.PI / 12 });\n` + ` return () => { stop(); session.release(); };\n` + ` },\n` + `};\n` + From 7fe886ba51f1e94be263a9f6322ebb856de073e8 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 13:07:12 +0200 Subject: [PATCH 8/9] feat(viewer): display COMPAS Polygon and Arc Polygon renders as a filled face triangulated in its own plane (so concave shapes fill correctly), facing up when horizontal, with its outline. Arc renders as a line sampled between its start and end angles. Both move from "deferred" to "included" in the support matrix. Co-Authored-By: Claude Opus 5.5 --- docs/support-matrix.md | 18 +++--- src/conversions/converter.ts | 14 ++--- src/conversions/geometry.ts | 110 ++++++++++++++++++++++++++++++----- tests/converter.test.ts | 91 ++++++++++++++++++++++++++++- 4 files changed, 200 insertions(+), 33 deletions(-) diff --git a/docs/support-matrix.md b/docs/support-matrix.md index 7e20b82..3860c7c 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -5,15 +5,15 @@ objects fail with `unsupported_message` and do not modify the scene. ## Geometry -| Status | Objects | Notes | -| --------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| Included | Box, Capsule, Circle, Cone, Cylinder, Line, Point, Pointcloud, Polyline, Sphere, Torus | Native Three.js representations | -| Included | Mesh, Polyhedron | Polygonal faces use fan triangulation; complex faces should be triangulated upstream | -| Included helpers | Frame, Plane, Vector | Frame and Vector are visual helpers; Plane is displayed as a finite surface | -| Python mesh path | Brep | Python sends its view mesh while retaining the Brep identity for callbacks | -| Deferred | Arc, Bezier, Ellipse, Hyperbola, Parabola, Polygon, Graph | Planned after 1.0 | -| Not top-level objects | MeshFaceList, PolyhedronFace | Internal protobuf helper types | -| Non-renderable data | Projection, Quaternion, Reflection, Rotation, Scale, Shear, Transformation, Translation | Deliberately rejected as scene geometry | +| Status | Objects | Notes | +| --------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Included | Arc, Box, Capsule, Circle, Cone, Cylinder, Line, Point, Pointcloud, Polygon, Polyline, Sphere, Torus | Native Three.js representations | +| Included | Mesh, Polyhedron | Polygonal faces use fan triangulation; complex faces should be triangulated upstream | +| Included helpers | Frame, Plane, Vector | Frame and Vector are visual helpers; Plane is displayed as a finite surface | +| Python mesh path | Brep | Python sends its view mesh while retaining the Brep identity for callbacks | +| Deferred | Bezier, Ellipse, Hyperbola, Parabola, Graph | Planned after 1.0 | +| Not top-level objects | MeshFaceList, PolyhedronFace | Internal protobuf helper types | +| Non-renderable data | Projection, Quaternion, Reflection, Rotation, Scale, Shear, Transformation, Translation | Deliberately rejected as scene geometry | Circle is currently displayed as a filled disc. Native converters are retained; objects are not converted to meshes unless their integration explicitly does so, diff --git a/src/conversions/converter.ts b/src/conversions/converter.ts index 4650e6c..5905ae9 100644 --- a/src/conversions/converter.ts +++ b/src/conversions/converter.ts @@ -37,15 +37,7 @@ import * as THREE from "three"; import * as GEOCONV from "./geometry"; import * as DATASTRUCTCONV from "./datastructures"; -const UNIMPLEMENTED_RENDERABLES = [ - Arc, - Bezier, - Ellipse, - Graph, - Hyperbola, - Parabola, - Polygon, -]; +const UNIMPLEMENTED_RENDERABLES = [Bezier, Ellipse, Graph, Hyperbola, Parabola]; const NON_RENDERABLES = [ Projection, Quaternion, @@ -91,6 +83,8 @@ export function convertToThreeJSGeometry(object: unknown): THREE.Object3D { } switch (true) { + case object instanceof Arc: + return GEOCONV.arcToThreeJS(object); case object instanceof Box: return GEOCONV.boxToThreeJS(object); case object instanceof Capsule: @@ -111,6 +105,8 @@ export function convertToThreeJSGeometry(object: unknown): THREE.Object3D { return GEOCONV.pointToThreeJS(object); case object instanceof Pointcloud: return GEOCONV.pointcloudToThreeJS(object); + case object instanceof Polygon: + return GEOCONV.polygonToThreeJS(object); case object instanceof Polyline: return GEOCONV.polylineToThreeJS(object); case object instanceof Sphere: diff --git a/src/conversions/geometry.ts b/src/conversions/geometry.ts index abdfb83..fd0d6c8 100644 --- a/src/conversions/geometry.ts +++ b/src/conversions/geometry.ts @@ -178,17 +178,53 @@ function positionsFromPoints(points: readonly Point[]): Float32Array { } /** - * Convert a COMPAS Arc to a THREE.js object. + * Convert a COMPAS Arc to a THREE.Line. * - * NOTE: This function is currently unimplemented and will throw. The intended - * implementation should sample the arc (or use THREE.ArcCurve) and return a - * visible representation (e.g. a THREE.Line or a thin THREE.Mesh). + * Samples the arc between its start and end angle, measured in the plane of + * its circle's frame from the frame's x-axis, like COMPAS does. * * @param arc - COMPAS Arc protobuf object - * @returns A THREE object representing the arc (Line or Mesh) + * @param segments - number of segments for a full circle; an arc uses its share + * @returns A THREE.Line along the arc */ -export function arcToThreeJS(_arc: Arc) { - throw new Error("Method not implemented."); +export function arcToThreeJS(arc: Arc, segments = 64): THREE.Line { + const circle = arc.circle!; + const frame = circle.frame!; + const origin = new THREE.Vector3( + frame.point!.x, + frame.point!.y, + frame.point!.z, + ); + const xaxis = new THREE.Vector3( + frame.xaxis!.x, + frame.xaxis!.y, + frame.xaxis!.z, + ).normalize(); + const yaxis = new THREE.Vector3( + frame.yaxis!.x, + frame.yaxis!.y, + frame.yaxis!.z, + ).normalize(); + const sweep = arc.endAngle - arc.startAngle; + const count = Math.max( + 2, + Math.ceil((Math.abs(sweep) / (2 * Math.PI)) * segments), + ); + const points: THREE.Vector3[] = []; + for (let i = 0; i <= count; i++) { + const angle = arc.startAngle + (sweep * i) / count; + points.push( + origin + .clone() + .addScaledVector(xaxis, circle.radius * Math.cos(angle)) + .addScaledVector(yaxis, circle.radius * Math.sin(angle)), + ); + } + const geometry = new THREE.BufferGeometry().setFromPoints(points); + return new THREE.Line( + geometry, + new THREE.LineBasicMaterial({ color: 0x000000 }), + ); } /** @@ -478,18 +514,64 @@ export function pointcloudToThreeJS(pointcloud: Pointcloud): THREE.Points { } /** - * Convert a COMPAS Polygon to a THREE.Mesh. + * Convert a COMPAS Polygon to a filled THREE.Mesh with its outline as a child + * THREE.LineLoop. * - * NOTE: This function is currently unimplemented and will throw. The intended - * implementation should triangulate the polygon (possibly using a fan - * triangulation or a proper earcut library) and return a mesh with a - * BufferGeometry. + * The face is triangulated with three.js's earcut (`ShapeUtils`) in the + * polygon's own plane, found with Newell's method, so concave polygons fill + * correctly. A horizontal polygon's face always points up, whichever way its + * points run, so it's visible from above. * * @param polygon - COMPAS Polygon protobuf object * @returns THREE.Mesh representing the filled polygon */ -export function polygonToThreeJS(_polygon: Polygon): THREE.Mesh { - throw new Error("Not implemented"); +export function polygonToThreeJS(polygon: Polygon): THREE.Mesh { + let points = polygon.points.map((p) => new THREE.Vector3(p.x, p.y, p.z)); + const first = points[0]; + const last = points[points.length - 1]; + // A closing point repeating the first one isn't a vertex of its own. + if (points.length > 3 && first && last && first.distanceTo(last) < 1e-9) { + points = points.slice(0, -1); + } + + const normal = new THREE.Vector3(); + points.forEach((current, i) => { + const next = points[(i + 1) % points.length]!; + normal.x += (current.y - next.y) * (current.z + next.z); + normal.y += (current.z - next.z) * (current.x + next.x); + normal.z += (current.x - next.x) * (current.y + next.y); + }); + if (normal.lengthSq() < 1e-18) normal.set(0, 0, 1); + normal.normalize(); + if (normal.z < -1e-6) { + points = [...points].reverse(); + normal.negate(); + } + + // Any in-plane basis works for triangulating. + const u = new THREE.Vector3() + .crossVectors( + Math.abs(normal.z) < 0.9 + ? new THREE.Vector3(0, 0, 1) + : new THREE.Vector3(1, 0, 0), + normal, + ) + .normalize(); + const v = new THREE.Vector3().crossVectors(normal, u); + const contour = points.map((p) => new THREE.Vector2(p.dot(u), p.dot(v))); + const triangles = THREE.ShapeUtils.triangulateShape(contour, []); + + const geometry = new THREE.BufferGeometry().setFromPoints(points); + geometry.setIndex(triangles.flat()); + geometry.computeVertexNormals(); + const mesh = new THREE.Mesh(geometry); + + const outline = new THREE.LineLoop( + new THREE.BufferGeometry().setFromPoints(points), + new THREE.LineBasicMaterial({ color: 0x000000 }), + ); + mesh.add(outline); + return mesh; } /** diff --git a/tests/converter.test.ts b/tests/converter.test.ts index c49cce0..58fdae7 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -1,4 +1,10 @@ -import { Box, Graph, Quaternion } from "@gramaziokohler/compas-pb-ts"; +import { + Arc, + Box, + Graph, + Polygon, + Quaternion, +} from "@gramaziokohler/compas-pb-ts"; import * as THREE from "three"; import { describe, expect, it } from "vitest"; @@ -32,6 +38,89 @@ describe("convertToThreeJSGeometry", () => { expect(converted.position.toArray()).toEqual([4, 5, 6]); }); + it("fills a concave polygon, facing up, with its outline", () => { + // An L shape of area 3, drawn clockwise seen from above, with a closing + // point repeating the first. + const corners = [ + [0, 0], + [0, 2], + [1, 2], + [1, 1], + [2, 1], + [2, 0], + [0, 0], + ]; + const polygon = new Polygon({ + data: { + guid: "polygon-guid", + name: "Polygon", + points: corners.flatMap(([x, y]) => [x!, y!, 0]), + }, + }); + + const mesh = convertToThreeJSGeometry(polygon) as THREE.Mesh; + + expect(mesh).toBeInstanceOf(THREE.Mesh); + // Six corners - the closing point is dropped - and four triangles. + expect(mesh.geometry.getAttribute("position").count).toBe(6); + expect(mesh.geometry.getIndex()!.count).toBe(12); + const normal = mesh.geometry.getAttribute("normal"); + expect(normal.getZ(0)).toBeCloseTo(1, 6); + const area = (() => { + const index = mesh.geometry.getIndex()!; + const position = mesh.geometry.getAttribute("position"); + const at = (i: number) => + new THREE.Vector3().fromBufferAttribute(position, index.getX(i)); + let total = 0; + for (let i = 0; i < index.count; i += 3) { + total += new THREE.Triangle(at(i), at(i + 1), at(i + 2)).getArea(); + } + return total; + })(); + expect(area).toBeCloseTo(3, 6); + expect(mesh.children[0]).toBeInstanceOf(THREE.LineLoop); + }); + + it("samples an arc between its start and end angles", () => { + const arc = new Arc({ + data: { + guid: "arc-guid", + name: "Arc", + circle: { + guid: "", + name: "Circle", + radius: 2, + frame: { + guid: "", + name: "Frame", + point: { guid: "", name: "", x: 1, y: 1, z: 0 }, + xaxis: { guid: "", name: "", x: 1, y: 0, z: 0 }, + yaxis: { guid: "", name: "", x: 0, y: 1, z: 0 }, + }, + }, + // compas-pb-ts 2.0.0 rejects an angle of exactly 0 as "missing". + startAngle: Math.PI / 2, + endAngle: Math.PI, + }, + }); + + const line = convertToThreeJSGeometry(arc) as THREE.Line; + + expect(line).toBeInstanceOf(THREE.Line); + const position = line.geometry.getAttribute("position"); + const start = new THREE.Vector3().fromBufferAttribute(position, 0); + const end = new THREE.Vector3().fromBufferAttribute( + position, + position.count - 1, + ); + expect(start.toArray().map((v) => +v.toFixed(6))).toEqual([1, 3, 0]); + expect(end.toArray().map((v) => +v.toFixed(6))).toEqual([-1, 1, 0]); + for (let i = 0; i < position.count; i++) { + const point = new THREE.Vector3().fromBufferAttribute(position, i); + expect(point.distanceTo(new THREE.Vector3(1, 1, 0))).toBeCloseTo(2, 5); + } + }); + it("rejects mathematical data instead of adding it to a scene", () => { const quaternion = new Quaternion({ data: { From 7799de5693122247a2f915b83e3196bf221a0077 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 13:11:00 +0200 Subject: [PATCH 9/9] feat(viewer): let plugins read the world-space vertices of visible objects Adds objectVertices() to ViewerExtensionContext: each visible backend object's vertices with its kind - points, line (in drawing order) or mesh (deduplicated, at most 2000) - so add-ons can snap to real endpoints and corners rather than bounding boxes. Also clarifies that keyboard shortcuts on the canvas are fine; only pointer takeover goes through beginInteraction. Co-Authored-By: Claude Opus 5.5 --- scripts/test-package.mjs | 1 + src/library/index.ts | 1 + src/library/public.d.ts | 18 +++++++++-- src/library/types.ts | 7 ++++ src/viewer/viewer_extensions.ts | 1 + src/viewer/viewer_runtime.ts | 57 +++++++++++++++++++++++++++++++++ tests/viewer_extensions.test.ts | 49 +++++++++++++++++++++++++++- 7 files changed, 131 insertions(+), 3 deletions(-) diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 038a6f0..0166619 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -102,6 +102,7 @@ try { ` const session = context.beginInteraction({ onKeyDown: (event) => void event.key });\n` + ` void context.pointerOnPlane({ clientX: 0, clientY: 0 }, 0)?.z;\n` + ` void context.objectBounds()[0]?.guid;\n` + + ` void context.objectVertices()[0]?.kind;\n` + ` const stop = context.onSelectionChange((guid) => {\n` + ` if (guid) void context.getMaterial(guid)?.color;\n` + ` });\n` + diff --git a/src/library/index.ts b/src/library/index.ts index 371f2aa..c8c35d3 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -20,6 +20,7 @@ export type { ViewerMode, ViewerObjectBounds, ViewerObjectHit, + ViewerObjectVertices, ViewerPlugin, ViewerPoint, ViewerPointerLike, diff --git a/src/library/public.d.ts b/src/library/public.d.ts index 7d67605..a105f5a 100644 --- a/src/library/public.d.ts +++ b/src/library/public.d.ts @@ -118,6 +118,16 @@ export interface ViewerObjectBounds { max: ViewerPoint; } +/** A backend-managed object's world-space vertices. */ +export interface ViewerObjectVertices { + guid: string; + /** "points" for points and point clouds; "line" for lines, polylines and + * arcs, with `vertices` in drawing order; "mesh" for surfaces and solids, + * with each vertex once (at most 2000). */ + kind: "points" | "line" | "mesh"; + vertices: ViewerPoint[]; +} + export interface ViewerObjectHit { guid: string; point: ViewerPoint; @@ -141,8 +151,9 @@ export interface ViewerSize { * on these purpose-built primitives. */ export interface ViewerExtensionContext { - /** The viewer's canvas (for cursor styles, focus). Do not attach listeners - * for input handling - use `beginInteraction` instead. */ + /** The viewer's canvas (for cursor styles, focus, or keyboard shortcuts + * while it has focus). To take over pointer input, use `beginInteraction` + * rather than listeners here, so picking and the gizmo stand down. */ readonly canvas: HTMLCanvasElement; /** Adds `object` to a viewer-owned overlay layer: rendered, never picked, * untouched by `reset()` and backend messages. Returns a remover @@ -160,6 +171,9 @@ export interface ViewerExtensionContext { pickObjects(event: ViewerPointerLike): ViewerObjectHit[]; /** World AABBs of all visible backend-managed objects (a snapshot). */ objectBounds(): ViewerObjectBounds[]; + /** World-space vertices of all visible backend-managed objects (a + * snapshot), e.g. for snapping to them. */ + objectVertices(): ViewerObjectVertices[]; /** Canvas size in CSS pixels (e.g. for `LineMaterial.resolution`). */ viewportSize(): ViewerSize; /** Called with the new canvas size whenever the viewer resizes. Returns an diff --git a/src/library/types.ts b/src/library/types.ts index cb92cba..b161baf 100644 --- a/src/library/types.ts +++ b/src/library/types.ts @@ -55,6 +55,12 @@ export interface ViewerObjectBounds { max: ViewerPoint; } +export interface ViewerObjectVertices { + guid: string; + kind: "points" | "line" | "mesh"; + vertices: ViewerPoint[]; +} + export interface ViewerObjectHit { guid: string; point: ViewerPoint; @@ -81,6 +87,7 @@ export interface ViewerExtensionContext { ): ViewerPoint | null; pickObjects(event: ViewerPointerLike): ViewerObjectHit[]; objectBounds(): ViewerObjectBounds[]; + objectVertices(): ViewerObjectVertices[]; viewportSize(): ViewerSize; onResize(listener: (size: ViewerSize) => void): () => void; beginInteraction(handlers: InteractionHandlers): InteractionSession; diff --git a/src/viewer/viewer_extensions.ts b/src/viewer/viewer_extensions.ts index d239ee5..179214f 100644 --- a/src/viewer/viewer_extensions.ts +++ b/src/viewer/viewer_extensions.ts @@ -20,6 +20,7 @@ export function createExtensionContext( runtime.pointerOnPlane(event, elevation), pickObjects: (event) => runtime.pickObjects(event), objectBounds: () => runtime.objectBounds(), + objectVertices: () => runtime.objectVertices(), viewportSize: () => runtime.viewportSize(), onResize: (listener) => runtime.addResizeListener(listener), beginInteraction: (handlers) => runtime.beginInteraction(handlers), diff --git a/src/viewer/viewer_runtime.ts b/src/viewer/viewer_runtime.ts index 66a6806..cff6f72 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -14,6 +14,7 @@ import type { InteractionSession, ViewerObjectBounds, ViewerObjectHit, + ViewerObjectVertices, ViewerPoint, ViewerPointerLike, ViewerSize, @@ -104,6 +105,10 @@ const VIEW_PRESETS: Record = { back_right: new THREE.Vector3(1, 1, 1), }; +/** Mesh vertices `objectVertices` reports per object, at most - enough to snap + * to, without walking a dense mesh on every tool start. */ +const MAX_OBJECT_VERTICES = 2000; + interface ActiveInteraction { handlers: InteractionHandlers; active: boolean; @@ -590,6 +595,58 @@ export class ViewerRuntime { return bounds; } + /** + * World-space vertices of each visible backend object - see + * `ViewerExtensionContext.objectVertices`. Edge overlays (`show_edges`) and a + * polygon's outline child only repeat the object's own vertices, so they're + * skipped; mesh vertices are deduplicated and capped per object. + */ + objectVertices(): ViewerObjectVertices[] { + const result: ViewerObjectVertices[] = []; + for (const object of this.geometries.values()) { + if (!object.visible) continue; + const guid = this.externalGeometryGuids.get(object); + if (!guid) continue; + object.updateMatrixWorld(true); + const kind = + object instanceof THREE.Points + ? "points" + : object instanceof THREE.Line + ? "line" + : "mesh"; + const vertices: ViewerPoint[] = []; + const seen = new Set(); + const point = new THREE.Vector3(); + const collect = (node: THREE.Object3D): void => { + const position = (node as RenderableObject).geometry?.getAttribute( + "position", + ); + if (!position) return; + for (let i = 0; i < position.count; i++) { + if (kind === "mesh" && vertices.length >= MAX_OBJECT_VERTICES) return; + point.fromBufferAttribute(position, i).applyMatrix4(node.matrixWorld); + if (kind !== "line") { + const key = point + .toArray() + .map((v) => v.toFixed(6)) + .join(","); + if (seen.has(key)) continue; + seen.add(key); + } + vertices.push(this.vectorData(point)); + } + }; + collect(object); + if (kind === "mesh") { + object.traverse((child) => { + if (child !== object && child instanceof THREE.Mesh) collect(child); + }); + } + result.push({ guid, kind, vertices }); + } + return result; + } + viewportSize(): ViewerSize { return this.getDimensions(); } diff --git a/tests/viewer_extensions.test.ts b/tests/viewer_extensions.test.ts index 62b9765..e4236e4 100644 --- a/tests/viewer_extensions.test.ts +++ b/tests/viewer_extensions.test.ts @@ -1,6 +1,6 @@ /** @vitest-environment happy-dom */ -import { Box, pbDumpBytes } from "@gramaziokohler/compas-pb-ts"; +import { Box, Line, pbDumpBytes } from "@gramaziokohler/compas-pb-ts"; import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("three", async () => { @@ -302,6 +302,53 @@ describe("ViewerExtensionContext", () => { expect(bounds?.max.z).toBeCloseTo(1.5); }); + it("reports each visible object's world vertices by kind", () => { + const { viewer, context } = viewerWithContext(); + viewer.dispatch(boxBytes("box-guid")); + const point = (x: number, y: number, z: number) => ({ + guid: "", + name: "", + x, + y, + z, + }); + viewer.dispatch( + pbDumpBytes( + new Line({ + data: { + guid: "line-guid", + name: "Line", + start: point(5, 0, 0), + end: point(7, 1, 0), + }, + }), + ), + ); + + const byGuid = Object.fromEntries( + context.objectVertices().map((entry) => [entry.guid, entry]), + ); + + // The 1 x 2 x 3 box: its 8 corners, each once. + expect(byGuid["box-guid"]!.kind).toBe("mesh"); + const corners = byGuid["box-guid"]!.vertices; + expect(corners).toHaveLength(8); + for (const corner of corners) { + expect(Math.abs(corner.x)).toBeCloseTo(0.5, 9); + expect(Math.abs(corner.y)).toBeCloseTo(1, 9); + expect(Math.abs(corner.z)).toBeCloseTo(1.5, 9); + } + // The line: its endpoints, in order. + expect(byGuid["line-guid"]).toEqual({ + guid: "line-guid", + kind: "line", + vertices: [ + { x: 5, y: 0, z: 0 }, + { x: 7, y: 1, z: 0 }, + ], + }); + }); + it("reports viewport size and notifies resize listeners until unsubscribed", () => { const { viewer, context } = viewerWithContext(); const listener = vi.fn();