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/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/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 + + + + + + +
+ + + + + diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 3d9e00e..0166619 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -95,8 +95,23 @@ 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` + + ` void context.objectVertices()[0]?.kind;\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` + + ` context.setTransformSnap({ grid: 0.5, angle: Math.PI / 12 });\n` + + ` return () => { stop(); 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/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/src/library/index.ts b/src/library/index.ts index 49a1ce2..c8c35d3 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,19 @@ import type { CompasViewer, CompasViewerOptions } from "./types"; export type { CompasViewer, CompasViewerOptions, + InteractionHandlers, + InteractionSession, + ViewerExtensionContext, + ViewerMaterial, + ViewerTransformSnap, ViewerMode, + ViewerObjectBounds, + ViewerObjectHit, + ViewerObjectVertices, + ViewerPlugin, + ViewerPoint, + ViewerPointerLike, + ViewerSize, ViewerWebSocketOptions, } from "./types"; export { @@ -50,7 +63,7 @@ export function createViewer( app.mount(container); let disposed = false; - return { + const viewer: CompasViewer = { dispatch(message) { runtime.dispatch(message); }, @@ -67,4 +80,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..a105f5a 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,160 @@ 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; +} + +/** 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; + 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, 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 + * (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[]; + /** 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 + * unsubscribe function. */ + onResize(listener: (size: ViewerSize) => void): () => void; + /** + * 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. */ + requestRender(): void; + /** 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; + /** + * 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. */ +export interface ViewerMaterial { + /** `#rrggbb` hex color. */ + color: string; + metalness: number; + roughness: number; +} + +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..b161baf 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,91 @@ 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 ViewerObjectVertices { + guid: string; + kind: "points" | "line" | "mesh"; + vertices: 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[]; + objectVertices(): ViewerObjectVertices[]; + viewportSize(): ViewerSize; + onResize(listener: (size: ViewerSize) => void): () => void; + 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; + setTransformSnap(snap: ViewerTransformSnap): void; +} + +export interface ViewerTransformSnap { + grid: number | null; + angle: number | null; +} + +export interface ViewerMaterial { + color: string; + metalness: number; + roughness: number; +} + +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..179214f --- /dev/null +++ b/src/viewer/viewer_extensions.ts @@ -0,0 +1,87 @@ +import { watch } from "vue"; + +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(), + objectVertices: () => runtime.objectVertices(), + viewportSize: () => runtime.viewportSize(), + onResize: (listener) => runtime.addResizeListener(listener), + 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), + setTransformSnap: (snap) => runtime.setTransformSnap(snap), + } 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..cff6f72 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -8,7 +8,17 @@ 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, + ViewerObjectVertices, + ViewerPoint, + ViewerPointerLike, + ViewerSize, +} from "../library/types"; import { parseViewerCommand, readGeometryGuid, @@ -95,6 +105,15 @@ 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; +} + export class ViewerRuntime { readonly store: ViewerStore = createViewerStore(); readonly scene = new THREE.Scene(); @@ -120,9 +139,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; @@ -131,6 +160,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", @@ -179,9 +212,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); @@ -190,6 +227,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 +254,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") { @@ -350,6 +390,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 }); } @@ -478,11 +522,213 @@ 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; + } + + /** + * 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(); + } + + 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. 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 { + get active() { + return entry.active; + }, + release: () => { + if (!entry.active) return; + entry.active = false; + if (this.interaction === entry) this.interaction = null; + }, + }; + } + + /** + * 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 {} + 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 +736,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(); @@ -641,7 +891,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, @@ -815,6 +1074,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 +1123,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( @@ -859,6 +1153,74 @@ 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 + * `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 && @@ -927,6 +1289,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/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: { diff --git a/tests/viewer_extensions.test.ts b/tests/viewer_extensions.test.ts new file mode 100644 index 0000000..e4236e4 --- /dev/null +++ b/tests/viewer_extensions.test.ts @@ -0,0 +1,522 @@ +/** @vitest-environment happy-dom */ + +import { Box, Line, 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 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(); + const unsubscribe = context.onResize(listener); + + viewer.resize(); + expect(listener).toHaveBeenCalledWith(context.viewportSize()); + + unsubscribe(); + viewer.resize(); + expect(listener).toHaveBeenCalledOnce(); + }); +}); + +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(); + 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(), + }; + // 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")); + // 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(); + 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" }), + ); + }); +}); diff --git a/tests/viewer_lifecycle.test.ts b/tests/viewer_lifecycle.test.ts index 445a9ae..aed74d9 100644 --- a/tests/viewer_lifecycle.test.ts +++ b/tests/viewer_lifecycle.test.ts @@ -290,6 +290,158 @@ 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(); + }); + + 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);