From 7d00ed17dba1b557093142732869be3ff078d790 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 11:12:28 +0200 Subject: [PATCH] fix(viewer): keep material edits on a picked object after deselecting it A picked object wears the pick highlight, with its own material parked until deselection. A material update for it replaced the highlight but left the stale parked material, so deselecting put the old color back until the object was rebuilt (e.g. by moving it). Updates now replace the parked material, and a local edit shows the real material at once. Co-Authored-By: Claude Opus 5.5 --- src/viewer/viewer_runtime.ts | 30 ++++++++++++++++++- tests/viewer_lifecycle.test.ts | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/viewer/viewer_runtime.ts b/src/viewer/viewer_runtime.ts index 27262f2..4deae80 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -349,6 +349,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 }); } @@ -637,7 +641,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, @@ -843,6 +856,21 @@ export class ViewerRuntime { this.store.pickedObjectGuid.value = guid ?? null; } + /** + * Shows the picked object's own material instead of the pick highlight, for + * while that material is being edited. The object stays picked, and + * `clearPickedObject` restores the same material. + */ + private revealPickedMaterial(): void { + if ( + this.pickedObject && + this.pickedMaterial && + "material" in this.pickedObject + ) { + (this.pickedObject as RenderableObject).material = this.pickedMaterial; + } + } + private clearPickedObject(): void { if ( this.pickedObject && diff --git a/tests/viewer_lifecycle.test.ts b/tests/viewer_lifecycle.test.ts index 445a9ae..100755e 100644 --- a/tests/viewer_lifecycle.test.ts +++ b/tests/viewer_lifecycle.test.ts @@ -290,6 +290,61 @@ describe("createViewer", () => { expect(registeredDispose).toHaveBeenCalledOnce(); }); + it("keeps a material edit on a picked object after it is deselected", () => { + const container = document.createElement("div"); + document.body.append(container); + const runtime = new ViewerRuntime(container, { mode: "embedded" }); + runtime.attach(container); + vi.spyOn( + runtime.renderer.domElement, + "getBoundingClientRect", + ).mockReturnValue({ left: 0, top: 0, width: 800, height: 600 } as DOMRect); + // A backend object without a material of its own, like `add_geometry(box)`. + runtime.dispatch(boxBytes("plain-box")); + const box = runtime.geometries.get("plain-box") as THREE.Mesh; + const internals = runtime as unknown as { + dispatchObject(object: unknown): void; + pickFromPointer(event: MouseEvent): void; + clearPickedObject(): void; + }; + internals.pickFromPointer( + new MouseEvent("mousedown", { clientX: 400, clientY: 300, button: 0 }), + ); + expect(runtime.store.pickedObjectGuid.value).toBe("plain-box"); + + // The backend's echo of a material edit arrives while the box is picked. + internals.dispatchObject({ + dispatch: "material", + type: "standard_material", + guid: "edited-material", + geometry_guid: "plain-box", + color: "#00ff00", + metalness: 0, + roughness: 1, + emissive: "#000000", + emissive_intensity: 0, + flat_shading: false, + wireframe: false, + transparent: false, + opacity: 1, + }); + const shown = () => + `#${(box.material as THREE.MeshStandardMaterial).color.getHexString()}`; + expect(shown()).toBe("#00ff00"); + + internals.clearPickedObject(); + expect(shown()).toBe("#00ff00"); + + // A local edit shows at once too, instead of hiding under the highlight. + internals.pickFromPointer( + new MouseEvent("mousedown", { clientX: 400, clientY: 300, button: 0 }), + ); + runtime.setMaterial("plain-box", { color: "#0000ff" }); + expect(shown()).toBe("#0000ff"); + + runtime.dispose(); + }); + it("renders geometry without an external GUID", () => { const container = document.createElement("div"); document.body.append(container);