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);