diff --git a/src/components/layout/Toolbar.vue b/src/components/layout/Toolbar.vue index 5a33701..8329ff0 100644 --- a/src/components/layout/Toolbar.vue +++ b/src/components/layout/Toolbar.vue @@ -4,6 +4,7 @@ COMPAS ThreeJs + @@ -11,6 +12,7 @@ + + diff --git a/src/components/tools/objects/AddObjectGroup.vue b/src/components/tools/objects/AddObjectGroup.vue new file mode 100644 index 0000000..aa1ef92 --- /dev/null +++ b/src/components/tools/objects/AddObjectGroup.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/components/tools/objects/MaterialButton.vue b/src/components/tools/objects/MaterialButton.vue new file mode 100644 index 0000000..b74652e --- /dev/null +++ b/src/components/tools/objects/MaterialButton.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/src/components/tools/objects/index.ts b/src/components/tools/objects/index.ts new file mode 100644 index 0000000..8f6c3c1 --- /dev/null +++ b/src/components/tools/objects/index.ts @@ -0,0 +1,3 @@ +export { default as AddObjectButton } from "./AddObjectButton.vue"; +export { default as MaterialButton } from "./MaterialButton.vue"; +export { default as AddObjectGroup } from "./AddObjectGroup.vue"; diff --git a/src/conversions/geometry.ts b/src/conversions/geometry.ts index a1c047c..abdfb83 100644 --- a/src/conversions/geometry.ts +++ b/src/conversions/geometry.ts @@ -110,6 +110,57 @@ function matrixFromElements(elements: readonly number[]): THREE.Matrix4 { return matrix; } +/** + * Build a THREE.Matrix4 from a COMPAS 4x4 row-major matrix, as produced by + * `compas.geometry.Transformation.matrix` and the `apply_transform` + * viewer command's `matrix` field. + * + * Deliberately not shared with `matrixFromElements` above: that function + * consumes a different (flat, `compas_pb`-decoded) wire shape and applies an + * extra transpose whose ordering its own docstring flags as unverified. + * `THREE.Matrix4.set()` already takes its arguments in row-major order, so a + * row-major COMPAS matrix needs no reordering at all - this is also the + * exact inverse of the row-major nested list `ViewerRuntime.sendObjectTransform` + * already builds from a THREE.Matrix4 for the opposite (frontend -> backend) + * direction, so the round trip is verified by construction. + * + * @param rows - 4x4 row-major matrix + * @returns A THREE.Matrix4 representing the same matrix + */ +export function matrix4FromRowMajor( + rows: readonly (readonly number[])[], +): THREE.Matrix4 { + const at = (row: number, col: number): number => { + const value = rows[row]?.[col]; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error( + `A COMPAS row-major matrix must be 4x4 finite numbers, [${row}][${col}] is ${value}`, + ); + } + return value; + }; + const matrix = new THREE.Matrix4(); + matrix.set( + at(0, 0), + at(0, 1), + at(0, 2), + at(0, 3), + at(1, 0), + at(1, 1), + at(1, 2), + at(1, 3), + at(2, 0), + at(2, 1), + at(2, 2), + at(2, 3), + at(3, 0), + at(3, 1), + at(3, 2), + at(3, 3), + ); + return matrix; +} + /** * Read a COMPAS point list into a flat Float32Array of positions. * diff --git a/src/viewer/BIDIRECTIONAL_SYNC.md b/src/viewer/BIDIRECTIONAL_SYNC.md new file mode 100644 index 0000000..81cbbad --- /dev/null +++ b/src/viewer/BIDIRECTIONAL_SYNC.md @@ -0,0 +1,181 @@ +# Bidirectional sync — context for a future agent + +This documents the frontend half of making the viewer bidirectional: dragging an object, +adding a new one, and editing its material all send messages back to the backend, which +mutates the corresponding _live_ Python object rather than the frontend just displaying +whatever the backend last pushed. Before this work, outbound traffic was limited to UI +callbacks (`ui_callback`, `object_picked`, `object_action_callback`) — see +`ViewerRuntime.handleUiAction`/`handleObjectAction` in `viewer_runtime.ts` for that +existing pattern, which the new code follows. + +The paired backend implementation lives in the sibling `compas_threejs` repo, at +`src/compas_threejs/viewer/BIDIRECTIONAL_SYNC.md` — read that alongside this file, +especially for the exact message shapes each handler expects. Both repos carry this work +on a branch called `feature/bidirectional-sync`, branched off `main` in each. + +Everything below lives in `ViewerRuntime` (`viewer_runtime.ts`) unless noted otherwise. +All outbound sends go through the existing `sendData()` → `ViewerConnection.send()` path, +same as every pre-existing callback. + +## `object_transform` — the transform gizmo + +The gizmo (`TransformControls`) already existed, wired to picking, before this work — it +just didn't send anything. Two things were added: + +1. In the constructor, the existing `"dragging-changed"` listener now also captures + `this.dragStartMatrix = this.transformControls.object?.matrix.clone()` when a drag + _starts_ (`event.value === true`). +2. A new `"mouseUp"` listener (fires once, when the drag ends — unlike `"objectChange"`, + which fires every frame) calls `sendObjectTransform()`. + +`sendObjectTransform()` computes `delta = object.matrix.clone().multiply(dragStartMatrix.clone().invert())` +and sends it as `{dispatch: "object_transform", guid, matrix}`, where `matrix` is a +**row-major 4x4 nested list**. `THREE.Matrix4.elements` is column-major internally, so the +conversion explicitly transposes — see the comment at the transpose site if you touch +this, it's the kind of thing that silently breaks (this exact class of bug — wrong matrix +convention — is what caused `Remote`'s old camera/background messages to be dead code +before an earlier refactor, per the backend's `CONTEXTE.md`). + +**Why a delta, and why `dragStartMatrix` matters — read this before changing the math.** +Geometry conversion (`buildTransformationFromFrame` + `Object3D.applyMatrix4` in +`conversions/geometry.ts`) does **not** bake an object's frame into its vertex buffer. +`Object3D.applyMatrix4()` premultiplies the matrix into `object.matrix` and then +_decomposes_ it into `position`/`quaternion`/`scale`. So a freshly-converted mesh already +sits at its real, absolute world placement — it is not at identity. Two real bugs came +from getting this wrong, in order: + +- **Bug 1 — sent the absolute matrix as if it were a delta.** The original + implementation assumed `object.matrix` started at identity, so it sent the post-drag + matrix directly. The backend applied it via `geometry.transform(T)`, which composes `T` + _on top of_ the object's current state — so the object landed somewhere else entirely + (looked "inverted" or like it teleported). Fixed by capturing `dragStartMatrix` and + sending `M_after * M_before^-1` instead — see the backend doc's `Transformation` + section for why this composes correctly. +- **Bug 2 — a continuously self-animating object (e.g. a spinning torus with an + `App.loop` callback) fought its own drag.** The backend's loop calls `update_geometry` + many times a second regardless of what the frontend is doing. Every one of those + broadcasts was rebuilding the mesh mid-drag at the backend's last-known (not-yet-moved) + position, undoing the user's drag in real time — by `mouseUp`, the net movement was + ~zero, looking like the object "snapped back." Fixed in `manageGeometry` (see below). + +`manageGeometry` now has an early-return guard: if the incoming update's guid is the +object currently attached to `transformControls` **and** `transformControls.dragging` is +true, the update is dropped entirely rather than rebuilding the mesh out from under the +user. The next update after the drag ends — either the echo of the just-sent +`object_transform`, or the animation's next tick — resyncs normally. + +Separately, `manageGeometry` also carries gizmo attachment and highlight material over to +a freshly-rebuilt mesh when the _currently picked_ object's guid gets an update (e.g. the +echo of your own edit, or an unrelated animation tick while merely selected-but-not- +dragging) — otherwise every echo would silently detach the gizmo. + +**Known limitation, not solved**: the backend applies the delta on top of whatever its +live object's state is _at message-processing time_, which — for a continuously-animating +object — may have moved further since `dragStartMatrix` was captured (the drag can take a +second or more; the backend keeps animating the whole time). The result can carry a small +amount of "extra" motion corresponding to that elapsed animation. This is different from +(and much more minor than) Bug 2 above — it's an accepted characteristic of editing a live +object, not a bug to chase. + +## `apply_transform` — the reverse direction (backend → frontend) + +Everything above is frontend → backend. `apply_transform` is the mirror image: a script +calls `Workspace.transform_geometry(geometry, transformation)` on the backend, and instead +of re-sending the whole (potentially large) geometry, the frontend gets a small +`{dispatch: "handle_geometry", type: "apply_transform", guid, matrix}` message and applies +`matrix` directly to the existing `THREE.Object3D` via `applyMatrix4` — no mesh rebuild. + +`matrix` is a **row-major 4x4 nested list** — the exact same shape +`compas.geometry.Transformation.matrix` already has, and the exact same shape +`sendObjectTransform()` above already produces for the opposite direction (see its +transpose comment: `THREE.Matrix4.elements` is column-major, but the row-major nested list +built from it is verified to represent the same matrix, not its transpose). This message is +routed through the existing `handle_geometry` dispatch (`handleGeometry()` in +`viewer_runtime.ts`, next to `remove`/`set_visibility`/`toggle_visibility`), not a new +top-level dispatch type. + +**Conversion**: `matrix4FromRowMajor()` in `conversions/geometry.ts` builds the +`THREE.Matrix4` for this. It is deliberately **not** `matrixFromElements()`/ +`transformationToThreeJS()` a few lines above it — those consume a different (flat, +`compas_pb`-decoded) wire shape and apply an extra transpose that function's own docstring +flags as unverified. `THREE.Matrix4.set()` already takes arguments in row-major order, so a +row-major COMPAS matrix needs no reordering — if you're tempted to unify these two helpers, +don't, until that TODO is resolved. + +**Same dragging guard as `manageGeometry`**: if the object currently attached to +`transformControls` is mid-drag, an incoming `apply_transform` is dropped rather than +applied — otherwise a live gizmo drag would fight a backend-driven transform arriving mid- +drag (e.g. an `App.loop` callback transforming the same object every frame). + +**Reconnect correctness**: the backend also silently refreshes its reconnect-replay +snapshot after sending this (see the backend doc's `Workspace.transform_geometry` section) +so a client that connects mid-sequence sees the object's current position, not its +original one — nothing extra is needed on the frontend for that; it's purely a backend +bookkeeping concern. + +## `create_geometry` — "Add object" toolbar button + +`ViewerRuntime.createGeometry(type, params)` sends +`{dispatch: "create_geometry", type, point: [x,y,z], params}`, where `point` is the +camera's current orbit target (`this.controls.target`) so new objects spawn in view +instead of at a fixed, possibly-buried world origin. + +UI: `src/components/tools/objects/AddObjectButton.vue` — a toolbar `Popover` (pattern +copied from `SavedViewsButton.vue`) with a shape-type `Select` and per-type `NumberField` +params. On "Add", it calls `createGeometry` and closes. + +**No new receive-side code was needed.** The created object comes back as an ordinary +`add_geometry` broadcast — the existing `manageGeometry`/`dispatch()` path renders it +exactly like anything a script adds. This symmetry (reusing the backend's existing +`add_geometry` outbound path) is why this was a small feature: the frontend only had to +learn to _send_ one new message, not _receive_ one. + +**Placement UX was deliberately kept simple**: spawn at a sensible default, then let the +user drag it into place with the (already-existing, already-fixed) gizmo — not a +click/drag-to-draw-in-3D-space sketch tool. That would need a new interaction state +machine (raycasting against a ground plane, live preview mesh, per-shape-type gesture +logic) and was explicitly scoped out as a much larger follow-up. + +## `material_edit` — toolbar color/metalness/roughness + +Two new `ViewerRuntime` methods: + +- `getMaterialSnapshot(guid)` — reads `this.geometryMaterials.get(guid)` → + `this.materials.get(materialGuid)`, returns `{color, metalness, roughness} | null`. + Returns `null` if the object has no material yet, or its `materialType` isn't + `"standard_material"` — this is the gate that keeps material editing scoped to + `compas_threejs.materials.Material`-backed objects; `PointMaterial`/`LineMaterial`/ + `PhysicalMaterial` have unrelated property sets (e.g. a point's material has `size`, not + metalness/roughness) and aren't editable through this control. +- `setMaterial(guid, {color?, metalness?, roughness?})` — mutates the local + `THREE.MeshStandardMaterial` **in place** first (instant visual feedback, no round-trip + wait), then sends `{dispatch: "material_edit", guid, ...fields}`. + +UI: `src/components/tools/objects/MaterialButton.vue`, folded into the same toolbar +group as `AddObjectButton`. Disabled unless something is picked. Color swatch + two +`Slider` controls (0–1, step 0.05) for metalness/roughness, each firing `setMaterial` on +every change — edits stream continuously as you drag, matching how this app's existing +dynamic `Slider`/`NumberField` UI components already behave (see `Openbar.vue`), and +deliberately _not_ the "send once on release" pattern `object_transform` uses — materials +aren't touched by any per-frame animation loop, so there's no equivalent of Bug 2 above to +worry about here. + +**New reactive store field**: `ViewerStore.pickedObjectGuid` (`viewer_store.ts`). Nothing +previously exposed "what's currently picked" to Vue — `pickedObject` was a private plain +TS field on `ViewerRuntime`. Set in `pickFromPointer` (on pick), cleared in +`clearPickedObject` (on deselect/Escape/pick-miss). `MaterialButton.vue`'s enable/disable +state and target guid both come from this. + +## Verifying changes here + +`object_transform`/`create_geometry`/`material_edit` predate this repo's `vitest` suite and +have no automated coverage — verification during that work was `vue-tsc` (`npm run build`) +plus manual end-to-end checks against a real running backend `App`. `apply_transform` does +have coverage: `tests/viewer_commands.test.ts` (command validation) and +`tests/viewer_lifecycle.test.ts` (applying the matrix to an existing `Object3D`, and the +dragging guard) — run with `npm test`. For end-to-end confidence, also start an example, +pick/drag/add/recolor objects in the browser, and separately confirm the backend's +Python-side object state via ad hoc scripts (see the backend doc). After any change here, +the frontend must be rebuilt (`npm run build`) and the `dist/` output copied into +`compas_threejs/src/compas_threejs/viewer/frontend/` before it's reachable from a real +browser session — the backend serves its own bundled copy, not this repo live. diff --git a/src/viewer/viewer_commands.ts b/src/viewer/viewer_commands.ts index d8cabff..49044e6 100644 --- a/src/viewer/viewer_commands.ts +++ b/src/viewer/viewer_commands.ts @@ -23,7 +23,7 @@ export type UiCommandType = | "select"; export type HandleGeometryCommandType = - "remove" | "set_visibility" | "toggle_visibility"; + "remove" | "set_visibility" | "toggle_visibility" | "apply_transform"; interface MaterialCommandBase extends CommandRecord { dispatch: "material"; @@ -360,10 +360,17 @@ export interface ToggleGeometryVisibilityCommand extends HandleGeometryCommandBa type: "toggle_visibility"; } +export interface ApplyTransformGeometryCommand extends HandleGeometryCommandBase { + type: "apply_transform"; + /** A 4x4 row-major matrix, matching `compas.geometry.Transformation.matrix`. */ + matrix: number[][]; +} + export type HandleGeometryCommand = | RemoveGeometryCommand | SetGeometryVisibilityCommand - | ToggleGeometryVisibilityCommand; + | ToggleGeometryVisibilityCommand + | ApplyTransformGeometryCommand; export interface SpinnerCommand extends CommandRecord { dispatch: "spinner"; @@ -408,6 +415,7 @@ const HANDLE_GEOMETRY_TYPES = new Set([ "remove", "set_visibility", "toggle_visibility", + "apply_transform", ]); const MATERIAL_TYPES = new Set([ "standard_material", @@ -758,6 +766,19 @@ function validateHandleGeometry(record: CommandRecord): void { const type = readVariant(record, "type", HANDLE_GEOMETRY_TYPES); readNonEmptyString(record, "guid"); if (type === "set_visibility") readBoolean(record, "visible"); + if (type === "apply_transform") readMatrix4Rows(record, "matrix"); +} + +function readMatrix4Rows(record: CommandRecord, field: string): number[][] { + const value = record[field]; + const isRow = (row: unknown): row is number[] => + Array.isArray(row) && + row.length === 4 && + row.every((item) => typeof item === "number" && Number.isFinite(item)); + if (!Array.isArray(value) || value.length !== 4 || !value.every(isRow)) { + invalidField(record, field, "a 4x4 matrix of finite numbers"); + } + return value as number[][]; } function validateSpinner(record: CommandRecord): void { diff --git a/src/viewer/viewer_runtime.ts b/src/viewer/viewer_runtime.ts index 85c7a5c..27262f2 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -2,6 +2,7 @@ import { decodeMessage } from "../communications/decode"; import { ViewerConnection } from "../communications/viewer_connection"; import { convertToThreeJSGeometry, + matrix4FromRowMajor, UnsupportedCompasObjectError, } from "../conversions"; import { lightToThree } from "../conversions/lights"; @@ -128,6 +129,7 @@ export class ViewerRuntime { private pickedObject: THREE.Object3D | null = null; private pickedMaterial: THREE.Material | THREE.Material[] | null = null; private readonly hiddenGuids = new Set(); + private dragStartMatrix: THREE.Matrix4 | null = null; private readonly highlightMaterial = new THREE.MeshStandardMaterial({ color: "orange", emissive: "yellow", @@ -167,6 +169,19 @@ export class ViewerRuntime { this.transformHelper = this.transformControls.getHelper(); this.transformControls.addEventListener("dragging-changed", (event) => { this.controls.enabled = !event.value; + if (event.value) { + // Capture the object's world matrix as it stood right before this drag, so the + // delta sent to the backend on release is relative to it - NOT relative to + // identity. Conversion bakes each object's frame into its own position/quaternion + // (via THREE.Object3D.applyMatrix4, which decomposes into position/quaternion/ + // scale rather than baking into vertex data), so a freshly-built object already + // sits at its absolute world placement, not at the origin. + this.dragStartMatrix = + this.transformControls.object?.matrix.clone() ?? null; + } + }); + this.transformControls.addEventListener("mouseUp", () => { + this.sendObjectTransform(); }); this.scene.add(this.transformHelper); @@ -277,6 +292,67 @@ export class ViewerRuntime { }); } + /** + * Asks the backend to create a new geometry object of `type` (e.g. "box", "sphere", + * "point") with the given numeric `params`, spawned at the camera's current orbit + * target so it appears in view. The backend constructs the real COMPAS object and + * broadcasts it back via the existing add_geometry path - it arrives here exactly + * like any object added by a running script, so no new receive-side handling is + * needed. Pick it up with the transform gizmo afterwards to position it precisely. + */ + createGeometry(type: string, params: Record): void { + const point = this.vectorData(this.controls.target); + this.sendData({ + dispatch: "create_geometry", + type, + point: [point.x, point.y, point.z], + params, + }); + } + + /** + * Reads the current color/metalness/roughness of the object at `guid`, for + * pre-filling the material editor when it opens. Returns null if the object has no + * material yet, or its material isn't a "standard_material" (e.g. a Point's + * PointMaterial has an entirely different property set) - editing those is out of + * scope for this control. + */ + getMaterialSnapshot( + guid: string, + ): { color: string; metalness: number; roughness: number } | null { + const materialGuid = this.geometryMaterials.get(guid); + if (!materialGuid) return null; + const entry = this.materials.get(materialGuid); + if (!entry || entry.materialType !== "standard_material") return null; + const material = entry.material as THREE.MeshStandardMaterial; + return { + color: `#${material.color.getHexString()}`, + metalness: material.metalness, + roughness: material.roughness, + }; + } + + /** + * Applies a material edit both locally (instant visual feedback on the live + * THREE.Material - no need to wait for the backend round trip) and sends it to the + * backend so the corresponding live Material Python instance is updated the same way, + * e.g. via `examples/objects_action.py`'s "Make it blue" action. + */ + setMaterial( + guid: string, + fields: { color?: string; metalness?: number; roughness?: number }, + ): void { + const materialGuid = this.geometryMaterials.get(guid); + const entry = materialGuid ? this.materials.get(materialGuid) : undefined; + if (entry && entry.materialType === "standard_material") { + const material = entry.material as THREE.MeshStandardMaterial; + if (fields.color !== undefined) material.color.set(fields.color); + if (fields.metalness !== undefined) material.metalness = fields.metalness; + if (fields.roughness !== undefined) material.roughness = fields.roughness; + } + this.sendData({ dispatch: "material_edit", guid, ...fields }); + } + hideObjectInfo(): void { this.store.objectBarData.isVisible = false; } @@ -495,10 +571,31 @@ export class ViewerRuntime { } private manageGeometry(object: CommandRecord): void { - const converted = convertToThreeJSGeometry(object); const externalGuid = resolveExternalGeometryGuid(object); + if (externalGuid) { + const draggingTarget = this.geometries.get(externalGuid); + if ( + draggingTarget && + this.transformControls.dragging && + draggingTarget === this.transformControls.object + ) { + // The user is actively dragging this exact object with the gizmo - drop this + // incoming update instead of rebuilding it out from under them. This matters a lot + // for a continuously self-animating object (e.g. a spinning torus with an `App.loop` + // callback): its backend loop keeps calling update_geometry many times a second, + // and every one of those would otherwise swap in a freshly-converted mesh sitting at + // the backend's last-known (not-yet-moved) position, fighting the drag to a + // standstill so it looks like the object "snaps back" on release. The next update + // after the drag ends - the echo of our own object_transform, or the animation's + // next tick - resyncs to the real backend state. + return; + } + } + const converted = convertToThreeJSGeometry(object); const sceneKey = externalGuid ?? converted.uuid; const existing = this.geometries.get(sceneKey); + const wasSelected = + existing !== undefined && existing === this.pickedObject; if (existing) { this.scene.remove(existing); this.disposeObject(existing); @@ -517,6 +614,18 @@ export class ViewerRuntime { edges.layers.set(1); converted.add(edges); } + // If the replaced object was selected (e.g. this update is the echo of a gizmo edit + // the user just made), carry the selection - highlight material and gizmo attachment + // - over to the newly-built object instead of silently losing it. + if (wasSelected) { + this.pickedObject = converted; + if ("material" in converted) { + const renderable = converted as RenderableObject; + this.pickedMaterial = renderable.material ?? null; + renderable.material = this.highlightMaterial; + } + this.transformControls.attach(converted); + } } private manageMaterial(data: MaterialCommand): void { @@ -731,6 +840,7 @@ export class ViewerRuntime { this.store.selectedObjectGuid.value = guid; this.sendData({ dispatch: "object_picked", guid }); } + this.store.pickedObjectGuid.value = guid ?? null; } private clearPickedObject(): void { @@ -744,6 +854,7 @@ export class ViewerRuntime { this.pickedObject = null; this.pickedMaterial = null; this.transformControls.detach(); + this.store.pickedObjectGuid.value = null; this.store.objectBarData.data = null; this.store.objectActionsState.splice(0); this.store.selectedObjectGuid.value = null; @@ -762,6 +873,43 @@ export class ViewerRuntime { return undefined; } + /** + * Sends the object currently attached to the transform gizmo back to the backend as a + * delta transform, once dragging ends. Geometry conversion (`applyMatrix4` in + * `conversions/geometry.ts`) decomposes each object's frame into its own + * position/quaternion/scale (that's what `Object3D.applyMatrix4` does - it does NOT + * bake into vertex data), so `object.matrix` is already the object's absolute world + * placement both before and after a drag, not a delta relative to identity. What the + * backend needs is the delta between the placement captured at drag-start + * (`dragStartMatrix`, set in the `dragging-changed` listener above) and the placement + * after the drag - sending the absolute matrix instead would have the backend compose + * it on top of the object's current state a second time, landing it somewhere else + * entirely (this was the cause of a "moves to another location" bug). + */ + private sendObjectTransform(): void { + const object = this.transformControls.object; + const startMatrix = this.dragStartMatrix; + this.dragStartMatrix = null; + if (!object || !startMatrix) return; + + const delta = object.matrix.clone().multiply(startMatrix.clone().invert()); + if (delta.equals(new THREE.Matrix4())) return; + + const guid = this.findGeometryGuid(object); + if (!guid) return; + + // THREE.Matrix4.elements is column-major; transpose into a row-major 4x4 nested + // list, matching `compas.geometry.Transformation.from_matrix`'s expected shape. + const e = delta.elements; + const matrix = [ + [e[0], e[4], e[8], e[12]], + [e[1], e[5], e[9], e[13]], + [e[2], e[6], e[10], e[14]], + [e[3], e[7], e[11], e[15]], + ]; + this.sendData({ dispatch: "object_transform", guid, matrix }); + } + private handleKeyDown(event: KeyboardEvent): void { if (event.altKey || event.ctrlKey || event.metaKey) return; if (event.key === "Escape") { @@ -854,6 +1002,17 @@ export class ViewerRuntime { object.visible = data.visible; } else if (data.type === "toggle_visibility") { object.visible = !object.visible; + } else if (data.type === "apply_transform") { + if ( + this.transformControls.dragging && + object === this.transformControls.object + ) { + // Same reasoning as manageGeometry's dragging guard above: don't fight a live + // gizmo drag with a backend-driven transform arriving mid-drag (e.g. an + // App.loop callback transforming this same object every frame). + return; + } + object.applyMatrix4(matrix4FromRowMajor(data.matrix)); } } diff --git a/src/viewer/viewer_store.ts b/src/viewer/viewer_store.ts index 1c5f61e..43d6446 100644 --- a/src/viewer/viewer_store.ts +++ b/src/viewer/viewer_store.ts @@ -82,6 +82,7 @@ export interface ViewerStore { sidebarComponents: DynamicComponent[]; pickerEnabled: { value: boolean }; pickerMode: { value: "translate" | "rotate" | "scale" }; + pickedObjectGuid: { value: string | null }; blockPicker: { value: boolean }; showEdges: { value: boolean }; theme: { value: "light" | "dark" }; @@ -105,6 +106,7 @@ export function createViewerStore(): ViewerStore { sidebarComponents: reactive([]), pickerEnabled: reactive({ value: true }), pickerMode: reactive({ value: "translate" as const }), + pickedObjectGuid: reactive({ value: null as string | null }), blockPicker: reactive({ value: false }), showEdges: reactive({ value: false }), theme: reactive({ value: "light" as const }), diff --git a/tests/viewer_commands.test.ts b/tests/viewer_commands.test.ts index 7d40bd0..efedd4d 100644 --- a/tests/viewer_commands.test.ts +++ b/tests/viewer_commands.test.ts @@ -130,4 +130,57 @@ describe("viewer command validation", () => { }), ).toMatchObject({ type: "sky", guid: "sky-guid" }); }); + + it("accepts an apply_transform handle_geometry command with a 4x4 matrix", () => { + const matrix = [ + [1, 0, 0, 10], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ]; + const command = { + dispatch: "handle_geometry", + type: "apply_transform", + guid: "geometry-guid", + matrix, + }; + + expect(parseViewerCommand(command)).toEqual(command); + }); + + it("rejects an apply_transform command whose matrix isn't 4x4 finite numbers", () => { + for (const matrix of [ + [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + ], + [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, "1"], + ], + [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, Number.NaN], + ], + ]) { + expect(() => + parseViewerCommand({ + dispatch: "handle_geometry", + type: "apply_transform", + guid: "geometry-guid", + matrix, + }), + ).toThrowError( + expect.objectContaining>({ + code: "invalid_message", + details: expect.objectContaining({ field: "matrix" }), + }), + ); + } + }); }); diff --git a/tests/viewer_lifecycle.test.ts b/tests/viewer_lifecycle.test.ts index e6874c5..445a9ae 100644 --- a/tests/viewer_lifecycle.test.ts +++ b/tests/viewer_lifecycle.test.ts @@ -311,4 +311,72 @@ describe("createViewer", () => { runtime.dispose(); }); + + it("applies an apply_transform command to the existing Object3D in place", () => { + const container = document.createElement("div"); + document.body.append(container); + const runtime = new ViewerRuntime(container, { mode: "embedded" }); + runtime.attach(container); + + runtime.dispatch(boxBytes("transformable-box")); + const object = runtime.geometries.get("transformable-box")!; + expect(object.position.toArray()).toEqual([0, 0, 0]); + + // apply_transform is a dict-shaped command, wire-encoded via compas_pb's DictData + // fallback on the Python side (see Workspace.transform_geometry / Outbox.send_dict) - + // there's no JS-side encoder for that shape since the frontend never sends dicts back + // (see viewer_connection.ts), so this dispatches the already-decoded object directly, + // exactly like `dispatch()` would after `decodeMessage()` returned it. + const internals = runtime as unknown as { + dispatchObject(object: unknown): void; + }; + internals.dispatchObject({ + dispatch: "handle_geometry", + type: "apply_transform", + guid: "transformable-box", + matrix: [ + [1, 0, 0, 10], + [0, 1, 0, 5], + [0, 0, 1, 0], + [0, 0, 0, 1], + ], + }); + + expect(runtime.geometries.get("transformable-box")).toBe(object); + expect(object.position.toArray()).toEqual([10, 5, 0]); + + runtime.dispose(); + }); + + it("skips an apply_transform command for an object currently being dragged", () => { + const container = document.createElement("div"); + document.body.append(container); + const runtime = new ViewerRuntime(container, { mode: "embedded" }); + runtime.attach(container); + + runtime.dispatch(boxBytes("dragged-box")); + const object = runtime.geometries.get("dragged-box")!; + const internals = runtime as unknown as { + dispatchObject(object: unknown): void; + transformControls: { dragging: boolean; object?: THREE.Object3D }; + }; + internals.transformControls.dragging = true; + internals.transformControls.object = object; + + internals.dispatchObject({ + dispatch: "handle_geometry", + type: "apply_transform", + guid: "dragged-box", + matrix: [ + [1, 0, 0, 99], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ], + }); + + expect(object.position.toArray()).toEqual([0, 0, 0]); + + runtime.dispose(); + }); });