diff --git a/doc/changelog.d/53.added.md b/doc/changelog.d/53.added.md new file mode 100644 index 00000000..ed090c66 --- /dev/null +++ b/doc/changelog.d/53.added.md @@ -0,0 +1 @@ +Remote rendering 3.1d - route client per-part mutations through the server triggers diff --git a/src/ansys/visor/visor-client/src/App.tsx b/src/ansys/visor/visor-client/src/App.tsx index 2fb10ec6..bb2a8769 100644 --- a/src/ansys/visor/visor-client/src/App.tsx +++ b/src/ansys/visor/visor-client/src/App.tsx @@ -128,7 +128,8 @@ function App() { } const renderer = await WasmRenderer.createAsync( wasmView.current.vtkScene, - requireWasmAnnotation(sceneDetails.vtkInfo.rendererAnnotation) + requireWasmAnnotation(sceneDetails.vtkInfo.rendererAnnotation), + wasmView.current.trameTriggerAsync ); const newFrontend = new VisorFrontend(renderer, sceneDetails.vtkInfo.sceneGraph); if (visorArgs.current.darkMode != null) { diff --git a/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx new file mode 100644 index 00000000..7f8078d9 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx @@ -0,0 +1,304 @@ +import { CreateVisorSceneGraph, VisorSceneNodeExtended } from '../state/VisorSceneGraph.tsx'; +import VisorVtkDataArray from '../state/appstate/vtkInfo/VisorVtkDataArray.tsx'; +import type { IRenderer } from '../renderer/IRenderer'; + +/** + * The scene graph's six user actions, each of which must now do two things: + * apply to the client's own objects, as it always did, and send the matching + * mutation to the server. + * + * Every action is covered by a *pair* of tests, one per half. That is not + * redundancy: a single test asserting "both happened" would still pass if the + * two were fused, and the failure this pairing exists to catch is a silently + * dropped send, which the render cannot reveal because the client is still + * painting the same result itself. + * + * Expected values here are hand-written literals. In particular the + * colour-variable descriptor is written out rather than read back from the + * spectrum, so the test cannot agree with the code by making the same mistake. + */ + +const ROOT_ID = 0; +const PART_A_ID = 1; +const PART_B_ID = 2; + +const VARIABLE_ID = 'POINT::pressure::1'; + +function makePressureArray(): VisorVtkDataArray { + return new VisorVtkDataArray({ + indexForType: 0, + type: 'POINT', + name: 'pressure', + numComponents: 1, + magnitudeRange: [0, 10], + ranges: [[2, 8]], + }); +} + +function makeRendererDouble() { + return { + // The per-part applies that already existed. + setVisibilityAsync: jest.fn(async () => undefined), + setSelectedAsync: jest.fn(async () => undefined), + setOpacityAsync: jest.fn(async () => undefined), + setDiffuseColorRgbAsync: jest.fn(async () => undefined), + resetDiffuseColorAsync: jest.fn(async () => undefined), + setColorVariableAsync: jest.fn(async () => undefined), + clearColorVariableAsync: jest.fn(async () => undefined), + setEdgeVisibilityAsync: jest.fn(async () => undefined), + setScalarRangeAsync: jest.fn(async () => undefined), + // The per-part sends added for the server path. + sendPartVisibilityAsync: jest.fn(async () => undefined), + sendPartOpacityAsync: jest.fn(async () => undefined), + sendPartDiffuseColorAsync: jest.fn(async () => undefined), + sendPartSelectedAsync: jest.fn(async () => undefined), + sendPartColorVariableAsync: jest.fn(async () => undefined), + sendClearPartColorVariableAsync: jest.fn(async () => undefined), + }; +} + +type RendererDouble = ReturnType; + +function makeGraph(renderer: RendererDouble): VisorSceneNodeExtended { + return CreateVisorSceneGraph( + { + id: ROOT_ID, + dataArrays: [], + name: '', + isGroupNode: true, + isActorNode: false, + nodeType: 'root', + diffuseColor: [1, 1, 1], + bounds: [], + children: [ + { + id: PART_A_ID, + dataArrays: [makePressureArray()], + name: 'part-a', + isGroupNode: false, + isActorNode: true, + nodeType: 'vtkUnstructuredGrid', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + { + id: PART_B_ID, + dataArrays: [makePressureArray()], + name: 'part-b', + isGroupNode: false, + isActorNode: true, + nodeType: 'vtkUnstructuredGrid', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + ], + }, + undefined, + renderer as unknown as IRenderer + ); +} + +function setUp() { + const renderer = makeRendererDouble(); + const graph = makeGraph(renderer); + const partA = graph.descendantActorNodesOrSelfDictionary[PART_A_ID]; + return { renderer, graph, partA }; +} + +describe('hide / show', () => { + test('sends set_part_visibility for the part', async () => { + const { renderer, partA } = setUp(); + + await partA.setVisibilityAsync(false); + + expect(renderer.sendPartVisibilityAsync).toHaveBeenCalledWith(PART_A_ID, false); + }); + + test('still applies visibility to the client renderer', async () => { + const { renderer, partA } = setUp(); + + await partA.setVisibilityAsync(false); + + expect(renderer.setVisibilityAsync).toHaveBeenCalledWith(PART_A_ID, false); + }); +}); + +describe('opacity', () => { + test('sends set_part_opacity for the part', async () => { + const { renderer, partA } = setUp(); + + await partA.setOpacityAsync(0.25); + + expect(renderer.sendPartOpacityAsync).toHaveBeenCalledWith(PART_A_ID, 0.25); + }); + + test('still applies opacity to the client renderer', async () => { + const { renderer, partA } = setUp(); + + await partA.setOpacityAsync(0.25); + + expect(renderer.setOpacityAsync).toHaveBeenCalledWith(PART_A_ID, 0.25); + }); +}); + +describe('custom colour, set by rgb', () => { + test('sends the normalised colour', async () => { + const { renderer, partA } = setUp(); + + await partA.setDiffuseColorRgbAsync(0.25, 0.5, 0.75); + + expect(renderer.sendPartDiffuseColorAsync).toHaveBeenCalledWith( + PART_A_ID, + [0.25, 0.5, 0.75] + ); + }); + + test('still applies the colour to the client renderer', async () => { + const { renderer, partA } = setUp(); + + await partA.setDiffuseColorRgbAsync(0.25, 0.5, 0.75); + + expect(renderer.setDiffuseColorRgbAsync).toHaveBeenCalledWith( + PART_A_ID, + 0.25, + 0.5, + 0.75, + false + ); + }); +}); + +describe('custom colour, set by hex', () => { + test('sends the normalised colour', async () => { + const { renderer, partA } = setUp(); + + await partA.setDiffuseColorHexAsync('#ff0000'); + + expect(renderer.sendPartDiffuseColorAsync).toHaveBeenCalledWith(PART_A_ID, [1, 0, 0]); + }); + + test('still applies the colour to the client renderer', async () => { + const { renderer, partA } = setUp(); + + await partA.setDiffuseColorHexAsync('#ff0000'); + + expect(renderer.setDiffuseColorRgbAsync).toHaveBeenCalledWith(PART_A_ID, 1, 0, 0, false); + }); +}); + +describe('custom colour, reset', () => { + test('sends a null colour, not the default colour value', async () => { + const { renderer, partA } = setUp(); + + await partA.resetDiffuseColorAsync(); + + expect(renderer.sendPartDiffuseColorAsync).toHaveBeenCalledWith(PART_A_ID, null); + }); + + test('still applies the reset to the client renderer', async () => { + const { renderer, partA } = setUp(); + + await partA.resetDiffuseColorAsync(); + + expect(renderer.resetDiffuseColorAsync).toHaveBeenCalledWith(PART_A_ID, [1, 1, 1], false); + }); +}); + +describe('colour by variable', () => { + test('sends the descriptor for the part', async () => { + const { renderer, partA } = setUp(); + + await partA.setColorVariableAsync(VARIABLE_ID, 0); + + expect(renderer.sendPartColorVariableAsync).toHaveBeenCalledWith(PART_A_ID, { + spectrumId: 'POINT::pressure::1', + spectrumType: 'POINT', + spectrumName: 'pressure', + component: 0, + min: 2, + max: 8, + }); + }); + + test('still applies the colour variable to the client renderer', async () => { + const { renderer, partA } = setUp(); + + await partA.setColorVariableAsync(VARIABLE_ID, 0); + + expect(renderer.setColorVariableAsync).toHaveBeenCalledWith(PART_A_ID, { + spectrumId: 'POINT::pressure::1', + spectrumType: 'POINT', + spectrumName: 'pressure', + component: 0, + min: 2, + max: 8, + }); + }); +}); + +describe('clear colour by variable', () => { + test('sends clear_part_color_variable for the part', async () => { + const { renderer, partA } = setUp(); + + await partA.clearColorVariableAsync(); + + expect(renderer.sendClearPartColorVariableAsync).toHaveBeenCalledWith(PART_A_ID); + }); + + test('still clears the colour variable on the client renderer', async () => { + const { renderer, partA } = setUp(); + + await partA.clearColorVariableAsync(); + + expect(renderer.clearColorVariableAsync).toHaveBeenCalledWith(PART_A_ID); + }); +}); + +describe('selection', () => { + test('sends set_part_selected for the part, with no colour', async () => { + const { renderer, partA } = setUp(); + + await partA.setSelectedAsync(true); + + expect(renderer.sendPartSelectedAsync).toHaveBeenCalledWith(PART_A_ID, true); + }); + + test('still applies the selection to the client renderer, with its colour', async () => { + const { renderer, partA } = setUp(); + + await partA.setSelectedAsync(true); + + expect(renderer.setSelectedAsync).toHaveBeenCalledWith(PART_A_ID, true, [1, 1, 1]); + }); +}); + +describe('group fan-out', () => { + test('a group action sends for exactly the actor nodes beneath it', async () => { + // Asserted as the set of node ids the sender saw, not as a count: the + // design is deliberately insensitive to how many messages an action + // produces, so a count would pin the wrong property. + const { renderer, graph } = setUp(); + + await graph.setVisibilityAsync(false); + + const ids = renderer.sendPartVisibilityAsync.mock.calls.map( + (call) => (call as unknown as [number, boolean])[0] + ); + expect(new Set(ids)).toEqual(new Set([PART_A_ID, PART_B_ID])); + expect(ids).not.toContain(ROOT_ID); + }); + + test('a group action still applies to exactly the actor nodes beneath it', async () => { + const { renderer, graph } = setUp(); + + await graph.setVisibilityAsync(false); + + const ids = renderer.setVisibilityAsync.mock.calls.map( + (call) => (call as unknown as [number, boolean])[0] + ); + expect(new Set(ids)).toEqual(new Set([PART_A_ID, PART_B_ID])); + expect(ids).not.toContain(ROOT_ID); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/WasmRendererPartTriggers.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/WasmRendererPartTriggers.test.tsx new file mode 100644 index 00000000..26714ad2 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/WasmRendererPartTriggers.test.tsx @@ -0,0 +1,441 @@ +import { WasmRenderer } from '../renderer/WasmRenderer'; +import type { WasmRendererAnnotation } from '../renderer/RendererAnnotation'; +import type VtkScene from '../wasm/VtkScene'; +import type { TrameTriggerSender } from '../renderer/IRenderer'; + +/** + * WasmRenderer's per-part *send* surface: the six methods that carry a + * per-part mutation to the matching server trigger. + * + * Two things are pinned here, and they are pinned by separate tests on + * purpose: + * + * 1. Each send calls the injected sender with the trigger name and the + * exact payload the server's model expects. Every expected payload is a + * hand-written literal; nothing here rebuilds a payload the way the code + * under test builds it. + * + * 2. Each existing per-part apply still mutates the wasm object it always + * mutated. A test that only checked the render would still pass if the + * send were dropped, and a test that only checked the send would still + * pass if the apply were broken, so neither is allowed to stand in for + * the other. + * + * Also pinned: with no sender injected the sends are no-ops that do not + * throw, and a sender that rejects does not propagate out of a send. + */ + +const NODE_ID = 7; +const ACTOR_ID = 101; +const PROPERTY_ID = 102; +const MAPPER_ID = 103; +const ORIENTATION_WIDGET_ID = 201; +const PLANE_ID = 202; +const PLANE_WIDGET_ID = 203; +const PLANE_REPRESENTATION_ID = 204; +const BOUNDING_BOX_ALGORITHM_ID = 205; +const BOUNDING_BOX_OUTLINE_ID = 206; +const BOUNDING_BOX_AXES_ID = 207; + +function makeAnnotation(): WasmRendererAnnotation { + return { + rendererKind: 'wasm', + nodes: { + [String(NODE_ID)]: { + actorId: ACTOR_ID, + propertyId: PROPERTY_ID, + mapperId: MAPPER_ID, + }, + }, + widgets: { + orientationWidgetId: ORIENTATION_WIDGET_ID, + crossSectionPlaneId: PLANE_ID, + crossSectionPlaneWidgetId: PLANE_WIDGET_ID, + crossSectionPlaneRepresentationId: PLANE_REPRESENTATION_ID, + boundingBoxAlgorithmId: BOUNDING_BOX_ALGORITHM_ID, + boundingBoxOutlineActorId: BOUNDING_BOX_OUTLINE_ID, + boundingBoxAxesActorId: BOUNDING_BOX_AXES_ID, + }, + }; +} + +function makeFakeWasmObjects() { + const lut = { + SetHueRange: jest.fn(async () => undefined), + SetVectorModeToMagnitude: jest.fn(async () => undefined), + }; + const actor = { + SetVisibility: jest.fn(async () => undefined), + }; + const property = { + SetAmbientColor: jest.fn(async () => undefined), + SetAmbient: jest.fn(async () => undefined), + SetDiffuse: jest.fn(async () => undefined), + SetDiffuseColor: jest.fn(async () => undefined), + SetOpacity: jest.fn(async () => undefined), + SetEdgeColor: jest.fn(async () => undefined), + EdgeVisibilityOn: jest.fn(async () => undefined), + EdgeVisibilityOff: jest.fn(async () => undefined), + }; + const mapper = { + SetScalarModeToUsePointFieldData: jest.fn(async () => undefined), + SetScalarModeToUseCellFieldData: jest.fn(async () => undefined), + SetScalarRange: jest.fn(async () => undefined), + SetColorModeToMapScalars: jest.fn(async () => undefined), + ColorByArrayComponent: jest.fn(async () => undefined), + SetScalarVisibility: jest.fn(async () => undefined), + CreateDefaultLookupTable: jest.fn(async () => undefined), + GetLookupTable: jest.fn(async () => lut), + }; + const widget = { + observe: jest.fn(), + On: jest.fn(async () => undefined), + Off: jest.fn(async () => undefined), + GetOrigin: jest.fn(async () => [0, 0, 0]), + GetNormal: jest.fn(async () => [0, 0, 1]), + SetOrigin: jest.fn(async () => undefined), + SetNormal: jest.fn(async () => undefined), + }; + return { lut, actor, property, mapper, widget }; +} + +async function makeRenderer(sender: TrameTriggerSender | null) { + const objects = makeFakeWasmObjects(); + const scene = { + canvasDiv: document.createElement('div'), + render: jest.fn(), + clearObserversAndEventListeners: jest.fn(), + getVtkObject: (wasmId: number) => { + switch (wasmId) { + case ACTOR_ID: + return objects.actor; + case PROPERTY_ID: + return objects.property; + case MAPPER_ID: + return objects.mapper; + default: + return objects.widget; + } + }, + }; + const renderer = await WasmRenderer.createAsync( + scene as unknown as VtkScene, + makeAnnotation(), + sender + ); + return { renderer, ...objects }; +} + +/** A sender that records its calls and resolves. */ +function makeSender() { + return jest.fn(async () => undefined) as unknown as jest.Mock & TrameTriggerSender; +} + +describe('WasmRenderer per-part sends: trigger name and payload', () => { + test('sendPartVisibilityAsync sends set_part_visibility with nodeId and visible', async () => { + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.sendPartVisibilityAsync(7, false); + + expect(sender).toHaveBeenCalledWith('set_part_visibility', { + nodeId: 7, + visible: false, + }); + }); + + test('sendPartOpacityAsync sends set_part_opacity with nodeId and opacity', async () => { + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.sendPartOpacityAsync(7, 0.25); + + expect(sender).toHaveBeenCalledWith('set_part_opacity', { + nodeId: 7, + opacity: 0.25, + }); + }); + + test('sendPartDiffuseColorAsync sends set_part_diffuse_color with the colour', async () => { + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.sendPartDiffuseColorAsync(7, [0.25, 0.5, 0.75]); + + expect(sender).toHaveBeenCalledWith('set_part_diffuse_color', { + nodeId: 7, + diffuseRgb: [0.25, 0.5, 0.75], + }); + }); + + test('sendPartDiffuseColorAsync sends an explicit null diffuseRgb key for a reset', async () => { + // The key must be present and null. The server model gives diffuseRgb + // no default, so an omitted key is a rejected message, not a clear. + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.sendPartDiffuseColorAsync(7, null); + + expect(sender).toHaveBeenCalledWith('set_part_diffuse_color', { + nodeId: 7, + diffuseRgb: null, + }); + const payload = sender.mock.calls[0][1] as Record; + expect(Object.prototype.hasOwnProperty.call(payload, 'diffuseRgb')).toBe(true); + }); + + test('sendPartSelectedAsync sends set_part_selected with nodeId and selected', async () => { + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.sendPartSelectedAsync(7, true); + + expect(sender).toHaveBeenCalledWith('set_part_selected', { + nodeId: 7, + selected: true, + }); + }); + + test('sendPartSelectedAsync carries no colour', async () => { + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.sendPartSelectedAsync(7, true); + + const payload = sender.mock.calls[0][1] as Record; + expect(Object.keys(payload).sort()).toEqual(['nodeId', 'selected']); + }); + + test('sendPartColorVariableAsync maps the descriptor onto the wire keys', async () => { + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.sendPartColorVariableAsync(7, { + spectrumId: 'POINT::pressure::1', + spectrumType: 'POINT', + spectrumName: 'pressure', + component: 0, + min: 2, + max: 8, + }); + + expect(sender).toHaveBeenCalledWith('set_part_color_variable', { + nodeId: 7, + variableId: 'POINT::pressure::1', + association: 'POINT', + arrayName: 'pressure', + component: 0, + min: 2, + max: 8, + }); + }); + + test('sendPartColorVariableAsync sends CELL as the association for a cell array', async () => { + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.sendPartColorVariableAsync(7, { + spectrumId: 'CELL::temperature::3', + spectrumType: 'CELL', + spectrumName: 'temperature', + component: -1, + min: -1.5, + max: 4.5, + }); + + expect(sender).toHaveBeenCalledWith('set_part_color_variable', { + nodeId: 7, + variableId: 'CELL::temperature::3', + association: 'CELL', + arrayName: 'temperature', + component: -1, + min: -1.5, + max: 4.5, + }); + }); + + test('sendPartColorVariableAsync forwards variableId verbatim without parsing it', async () => { + // An id that would not survive being split and rebuilt: it is carried + // as an opaque token, and the association and array name travel in + // their own fields precisely so that nothing has to decompose it. + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.sendPartColorVariableAsync(7, { + spectrumId: 'not::a::parseable::id::at::all', + spectrumType: 'POINT', + spectrumName: 'pressure', + component: 0, + min: 0, + max: 1, + }); + + const payload = sender.mock.calls[0][1] as Record; + expect(payload.variableId).toBe('not::a::parseable::id::at::all'); + expect(payload.association).toBe('POINT'); + expect(payload.arrayName).toBe('pressure'); + }); + + test('sendClearPartColorVariableAsync sends clear_part_color_variable with only nodeId', async () => { + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.sendClearPartColorVariableAsync(7); + + expect(sender).toHaveBeenCalledWith('clear_part_color_variable', { + nodeId: 7, + }); + }); +}); + +describe('WasmRenderer per-part applies still mutate their wasm objects', () => { + test('setVisibilityAsync still sets visibility on the actor', async () => { + const { renderer, actor } = await makeRenderer(makeSender()); + + await renderer.setVisibilityAsync(NODE_ID, false); + + expect(actor.SetVisibility).toHaveBeenCalledWith(0); + }); + + test('setOpacityAsync still sets opacity on the property', async () => { + const { renderer, property } = await makeRenderer(makeSender()); + + await renderer.setOpacityAsync(NODE_ID, 0.25); + + expect(property.SetOpacity).toHaveBeenCalledWith(0.25); + }); + + test('setDiffuseColorRgbAsync still sets the diffuse colour on the property', async () => { + const { renderer, property } = await makeRenderer(makeSender()); + + await renderer.setDiffuseColorRgbAsync(NODE_ID, 0.25, 0.5, 0.75, false); + + expect(property.SetDiffuseColor).toHaveBeenCalledWith(0.25, 0.5, 0.75); + }); + + test('setSelectedAsync still applies the selection tint and lighting', async () => { + const { renderer, property } = await makeRenderer(makeSender()); + + await renderer.setSelectedAsync(NODE_ID, true, [1, 0, 0]); + + expect(property.SetAmbientColor).toHaveBeenCalledWith([0 / 255, 62 / 255, 111 / 255]); + expect(property.SetDiffuse).toHaveBeenCalledWith(0.5); + expect(property.SetAmbient).toHaveBeenCalledWith(0.5); + expect(property.SetDiffuseColor).toHaveBeenCalledWith(1, 0, 0); + }); + + test('setColorVariableAsync still configures the mapper and the default table', async () => { + const { renderer, mapper, lut } = await makeRenderer(makeSender()); + + await renderer.setColorVariableAsync(NODE_ID, { + spectrumId: 'POINT::pressure::1', + spectrumType: 'POINT', + spectrumName: 'pressure', + component: 0, + min: 2, + max: 8, + }); + + expect(mapper.SetScalarModeToUsePointFieldData).toHaveBeenCalled(); + expect(mapper.SetScalarRange).toHaveBeenCalledWith(2, 8); + expect(mapper.SetColorModeToMapScalars).toHaveBeenCalled(); + expect(mapper.ColorByArrayComponent).toHaveBeenCalledWith('pressure', 0); + expect(mapper.SetScalarVisibility).toHaveBeenCalledWith(1); + expect(mapper.CreateDefaultLookupTable).toHaveBeenCalled(); + expect(lut.SetHueRange).toHaveBeenCalledWith(0.667, 0.0); + expect(lut.SetVectorModeToMagnitude).toHaveBeenCalled(); + }); + + test('clearColorVariableAsync still turns scalar visibility off', async () => { + const { renderer, mapper } = await makeRenderer(makeSender()); + + await renderer.clearColorVariableAsync(NODE_ID); + + expect(mapper.SetScalarVisibility).toHaveBeenCalledWith(0); + }); +}); + +describe('WasmRenderer sends when no sender is injected', () => { + test('all six sends are no-ops that do not throw', async () => { + const { renderer } = await makeRenderer(null); + + await expect(renderer.sendPartVisibilityAsync(7, true)).resolves.toBeUndefined(); + await expect(renderer.sendPartOpacityAsync(7, 0.5)).resolves.toBeUndefined(); + await expect(renderer.sendPartDiffuseColorAsync(7, [1, 0, 0])).resolves.toBeUndefined(); + await expect(renderer.sendPartSelectedAsync(7, true)).resolves.toBeUndefined(); + await expect( + renderer.sendPartColorVariableAsync(7, { + spectrumId: 'POINT::pressure::1', + spectrumType: 'POINT', + spectrumName: 'pressure', + component: 0, + min: 2, + max: 8, + }) + ).resolves.toBeUndefined(); + await expect(renderer.sendClearPartColorVariableAsync(7)).resolves.toBeUndefined(); + }); + + test('a send touches no wasm object', async () => { + const { renderer, actor, property, mapper } = await makeRenderer(null); + + await renderer.sendPartVisibilityAsync(NODE_ID, false); + await renderer.sendPartOpacityAsync(NODE_ID, 0.25); + await renderer.sendPartSelectedAsync(NODE_ID, true); + + expect(actor.SetVisibility).not.toHaveBeenCalled(); + expect(property.SetOpacity).not.toHaveBeenCalled(); + expect(property.SetAmbientColor).not.toHaveBeenCalled(); + expect(mapper.SetScalarVisibility).not.toHaveBeenCalled(); + }); +}); + +describe('WasmRenderer sends when the sender rejects', () => { + test('the rejection does not propagate out of the send', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + const sender = jest.fn(async () => { + throw new Error('socket closed'); + }) as unknown as TrameTriggerSender; + const { renderer } = await makeRenderer(sender); + + await expect(renderer.sendPartOpacityAsync(7, 0.25)).resolves.toBeUndefined(); + + consoleError.mockRestore(); + }); + + test('the failure is logged with the trigger name and the node id', async () => { + // This log line is the only signal a failed send produces: the render + // looks identical either way, so the message must name what failed and + // for which part, under a fixed prefix that can be grepped. + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + const sender = jest.fn(async () => { + throw new Error('socket closed'); + }) as unknown as TrameTriggerSender; + const { renderer } = await makeRenderer(sender); + + await renderer.sendPartOpacityAsync(7, 0.25); + + expect(consoleError).toHaveBeenCalledTimes(1); + const message = String(consoleError.mock.calls[0][0]); + expect(message).toContain('[VISOR] per-part trigger send failed:'); + expect(message).toContain("trigger='set_part_opacity'"); + expect(message).toContain('nodeId=7'); + + consoleError.mockRestore(); + }); + + test('a non-Error rejection is caught too', async () => { + // The catch is unnarrowed on purpose: any rejection, of any shape, is + // a failed send. + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + const sender = jest.fn(async () => { + throw 'not an Error'; + }) as unknown as TrameTriggerSender; + const { renderer } = await makeRenderer(sender); + + await expect(renderer.sendPartVisibilityAsync(7, true)).resolves.toBeUndefined(); + expect(consoleError).toHaveBeenCalledTimes(1); + + consoleError.mockRestore(); + }); +}); diff --git a/src/ansys/visor/visor-client/src/renderer/IRenderer.ts b/src/ansys/visor/visor-client/src/renderer/IRenderer.ts index de07a0ac..5e18cb7f 100644 --- a/src/ansys/visor/visor-client/src/renderer/IRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/IRenderer.ts @@ -35,6 +35,23 @@ export type ColorVariableDescriptor = Readonly<{ max: number; }>; +/** + * The transport a renderer uses to send a per-part mutation to the server. + * + * This is the existing trame trigger call surface, nothing new: + * `RemoteVtkScene.trameTriggerAsync(name, ...args)`, which forwards one + * positional argument -- the payload object -- to the named server trigger. + * It is *injected* into the concrete renderer at construction rather than + * imported by it, so that this module stays types-only and compiles to an + * empty module: a value import here would be inherited by every importer, + * including the wasm transport, and would drag the scene graph and React + * into module graphs that must not have them. + * + * A renderer constructed without a sender performs no send. It does not + * throw and does not queue. + */ +export type TrameTriggerSender = (triggerName: string, payload: unknown) => Promise; + /** Result returned by pickGeometryAsync. Discriminated by `mode`. */ export type PickGeometryResult = | { found: false } @@ -140,6 +157,32 @@ export interface IRenderer { clearColorVariableAsync(nodeId: NodeId): Promise; setScalarRangeAsync(nodeId: NodeId, min: number, max: number): Promise; + // ---- Per-part mutations routed to the server ----------------------------- + /** + * These six carry a per-part mutation to the matching server trigger. They + * are *not* a second way to render: they neither read nor touch any wasm + * object, and they are called in addition to -- never instead of -- the + * per-part apply methods above, which keep applying to the client's own + * objects for now. + * + * Each payload is absolute, never relative: it carries the target value, + * so a send that is suppressed upstream, duplicated, or reordered is + * harmless. `nodeId` is always a part (actor) node; a group node fans out + * to its actor descendants before any of these is called. + * + * A send never rejects. With no sender injected it is a no-op; with one, + * a transport failure is logged and swallowed, because these run inside UI + * handlers whose behaviour must not change. + */ + sendPartVisibilityAsync(nodeId: NodeId, visible: boolean): Promise; + sendPartOpacityAsync(nodeId: NodeId, opacity: number): Promise; + /** `null` clears the custom colour; absence of a colour is absence, not a default. */ + sendPartDiffuseColorAsync(nodeId: NodeId, diffuseRgb: readonly number[] | null): Promise; + /** Carries no colour: the server reads the part's stored colour from its own record. */ + sendPartSelectedAsync(nodeId: NodeId, selected: boolean): Promise; + sendPartColorVariableAsync(nodeId: NodeId, descriptor: ColorVariableDescriptor): Promise; + sendClearPartColorVariableAsync(nodeId: NodeId): Promise; + // ---- View-level widgets (state is renderer-owned; see arch rule (a)) --- setCrossSectionVisibilityAsync(visible?: boolean): Promise; isCrossSectionVisible(): boolean; // cached bool, sync diff --git a/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts b/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts index 6711cf5e..07fffac7 100644 --- a/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts @@ -166,6 +166,41 @@ export class NullRenderer implements IRenderer { // no-op } + // ---- Per-part mutations routed to the server ----------------------------- + // A renderer with no server behind it sends nothing. These are no-ops for + // the same reason every other method here is: the null renderer has no + // transport, exactly as it has no wasm objects. + + async sendPartVisibilityAsync(_nodeId: NodeId, _visible: boolean): Promise { + // no-op + } + + async sendPartOpacityAsync(_nodeId: NodeId, _opacity: number): Promise { + // no-op + } + + async sendPartDiffuseColorAsync( + _nodeId: NodeId, + _diffuseRgb: readonly number[] | null + ): Promise { + // no-op + } + + async sendPartSelectedAsync(_nodeId: NodeId, _selected: boolean): Promise { + // no-op + } + + async sendPartColorVariableAsync( + _nodeId: NodeId, + _descriptor: ColorVariableDescriptor + ): Promise { + // no-op + } + + async sendClearPartColorVariableAsync(_nodeId: NodeId): Promise { + // no-op + } + async setCrossSectionVisibilityAsync(_visible?: boolean): Promise { // no-op } diff --git a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts index 91245546..0cb6fb98 100644 --- a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts @@ -4,6 +4,7 @@ import { IRenderer, NodeId, PickGeometryResult, + TrameTriggerSender, VisorCameraState, } from './IRenderer'; import VtkScene from '../wasm/VtkScene'; @@ -24,8 +25,13 @@ const SELECTION_TINT_RGB: Readonly = [0 / 255, 62 / 255, 111 / 255]; * this — nothing imports `WasmRenderer` yet. */ export class WasmRenderer implements IRenderer { - private constructor(vtkScene: VtkScene, annotation: WasmRendererAnnotation) { + private constructor( + vtkScene: VtkScene, + annotation: WasmRendererAnnotation, + triggerSender: TrameTriggerSender | null + ) { this.#vtkScene = vtkScene; + this.#triggerSender = triggerSender; const wasmPlaneId = annotation.widgets.crossSectionPlaneId; const wasmPlaneRepId = annotation.widgets.crossSectionPlaneRepresentationId; @@ -66,12 +72,14 @@ export class WasmRenderer implements IRenderer { static async createAsync( vtkScene: VtkScene, - annotation: WasmRendererAnnotation + annotation: WasmRendererAnnotation, + triggerSender: TrameTriggerSender | null = null ): Promise { - return new WasmRenderer(vtkScene, annotation); + return new WasmRenderer(vtkScene, annotation, triggerSender); } readonly #vtkScene: VtkScene; + readonly #triggerSender: TrameTriggerSender | null; readonly #actorIdToNodeId = new Map(); readonly #nodeIdToHandles = new Map(); readonly #crossSectionWidget: CrossSectionWidget; @@ -343,6 +351,102 @@ export class WasmRenderer implements IRenderer { await wasmMapper.SetScalarRange(min, max); } + // ---- Per-part mutations routed to the server ----------------------------- + /** + * Send one per-part payload to its server trigger. + * + * No sender injected -> no-op. This is the state every non-browser context + * is in, and it is deliberate rather than defensive: the renderer is + * constructible without a transport. + * + * A rejection is logged and swallowed, never rethrown. These sends run + * inside UI handlers that behaved a certain way before this call existed, + * and both stacks currently apply the same mutation independently, so a + * transport failure must not change what the user sees or break an + * interaction. The catch is deliberately unnarrowed: any rejection, of any + * shape, is a failed send. The log line below is therefore the *only* + * signal that a send failed -- the render will look correct either way -- + * so its prefix is fixed and greppable, and it names both the trigger and + * the node so a failure can be attributed without a debugger. + */ + async #sendTriggerAsync( + triggerName: string, + nodeId: NodeId, + payload: Record + ): Promise { + if (this.#triggerSender == null) { + return; + } + try { + await this.#triggerSender(triggerName, payload); + } catch (err) { + console.error( + `[VISOR] per-part trigger send failed: trigger='${triggerName}' nodeId=${nodeId}`, + err + ); + } + } + + async sendPartVisibilityAsync(nodeId: NodeId, visible: boolean): Promise { + await this.#sendTriggerAsync('set_part_visibility', nodeId, { + nodeId, + visible, + }); + } + + async sendPartOpacityAsync(nodeId: NodeId, opacity: number): Promise { + await this.#sendTriggerAsync('set_part_opacity', nodeId, { + nodeId, + opacity, + }); + } + + async sendPartDiffuseColorAsync( + nodeId: NodeId, + diffuseRgb: readonly number[] | null + ): Promise { + // The key is always present: `diffuseRgb: null` is how a reset is + // expressed. Omitting it is a different message, and not a valid one. + await this.#sendTriggerAsync('set_part_diffuse_color', nodeId, { + nodeId, + diffuseRgb, + }); + } + + async sendPartSelectedAsync(nodeId: NodeId, selected: boolean): Promise { + // No colour crosses this trigger by design; the server reads the + // part's stored colour from its own record. + await this.#sendTriggerAsync('set_part_selected', nodeId, { + nodeId, + selected, + }); + } + + async sendPartColorVariableAsync( + nodeId: NodeId, + descriptor: ColorVariableDescriptor + ): Promise { + // `variableId` is the client-built opaque token, forwarded verbatim; + // nothing on either side parses it. The association travels as its own + // typed 'POINT'|'CELL' field, and the array name as its own field, + // precisely so that no one has to. + await this.#sendTriggerAsync('set_part_color_variable', nodeId, { + nodeId, + variableId: descriptor.spectrumId, + association: descriptor.spectrumType, + arrayName: descriptor.spectrumName, + component: descriptor.component, + min: descriptor.min, + max: descriptor.max, + }); + } + + async sendClearPartColorVariableAsync(nodeId: NodeId): Promise { + await this.#sendTriggerAsync('clear_part_color_variable', nodeId, { + nodeId, + }); + } + // ---- View-level widgets ------------------------------------------------- async setCrossSectionVisibilityAsync(visible?: boolean): Promise { await this.#crossSectionWidget.setVisibilityAsync(visible); diff --git a/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx b/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx index bfe8275f..9f9e8ab3 100644 --- a/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx +++ b/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx @@ -160,6 +160,10 @@ export const CreateVisorSceneGraph = (() => { customDiffuseColor.rgbNormalized, _selected ); + // A reset is "no custom colour", sent as an explicit null -- + // not as the default colour's value, and not by omitting the + // key. Absence is absence. + await renderer!.sendPartDiffuseColorAsync(nodeId, null); }, async setDiffuseColorHexAsync(hex) { customDiffuseColor.setHex(hex); @@ -171,6 +175,7 @@ export const CreateVisorSceneGraph = (() => { rgbNormalized[2], _selected ); + await renderer!.sendPartDiffuseColorAsync(nodeId, rgbNormalized); }, async setDiffuseColorRgbAsync(r, g, b) { customDiffuseColor.setRgb(r, g, b); @@ -182,6 +187,7 @@ export const CreateVisorSceneGraph = (() => { rgbNormalized[2], _selected ); + await renderer!.sendPartDiffuseColorAsync(nodeId, rgbNormalized); }, async clearColorVariableAsync() { _spectrumId = null; @@ -189,6 +195,7 @@ export const CreateVisorSceneGraph = (() => { _spectrumMin = -1; _spectrumMax = -1; await renderer!.clearColorVariableAsync(nodeId); + await renderer!.sendClearPartColorVariableAsync(nodeId); }, async setColorVariableAsync(id, component) { if (_spectrumId === id && _spectrumComponent === component) { @@ -210,14 +217,16 @@ export const CreateVisorSceneGraph = (() => { _spectrumComponent = component; _spectrumMin = min; _spectrumMax = max; - await renderer!.setColorVariableAsync(nodeId, { + const descriptor = { spectrumId: id, spectrumType: spectrum.type, spectrumName: spectrum.name, component, min, max, - }); + }; + await renderer!.setColorVariableAsync(nodeId, descriptor); + await renderer!.sendPartColorVariableAsync(nodeId, descriptor); }, async setScalarRangeAsync(min, max) { await renderer!.setScalarRangeAsync(nodeId, min, max); @@ -236,6 +245,7 @@ export const CreateVisorSceneGraph = (() => { return; } await renderer!.setVisibilityAsync(nodeId, visible); + await renderer!.sendPartVisibilityAsync(nodeId, visible); }, async setSelectedAsync(selected) { if (_selected === selected) { @@ -255,6 +265,9 @@ export const CreateVisorSceneGraph = (() => { _selected, customDiffuseColor.rgbNormalized ); + // The trigger carries no colour: the server reads the part's + // stored colour from its own record. + await renderer!.sendPartSelectedAsync(nodeId, _selected); }, async setEdgeVisibilityAsync(edgeVisibility: boolean) { if (_edgeVisibility === edgeVisibility) { @@ -287,6 +300,7 @@ export const CreateVisorSceneGraph = (() => { return; } await renderer!.setOpacityAsync(nodeId, opacity); + await renderer!.sendPartOpacityAsync(nodeId, opacity); }, get spectrumId() { return _spectrumId; diff --git a/tests/e2e/regressions/test_variable_components.py b/tests/e2e/regressions/test_variable_components.py index d30787db..9cf947da 100644 --- a/tests/e2e/regressions/test_variable_components.py +++ b/tests/e2e/regressions/test_variable_components.py @@ -39,6 +39,89 @@ TENSOR_COMPONENT_LABELS = ["XX", "XY", "XZ", "YX", "YY", "YZ", "ZX", "ZY", "ZZ"] +# =========================================================================== +# Selecting a tensor component colors the part (screenshot diff) +# =========================================================================== + +@pytest.mark.parametrize("visor_asset_spec", [TENSOR_ASSETS], indirect=True) +@pytest.mark.regression +class TestTensorComponentColoring: + """ + Verify that selecting a tensor component updates the rendered + view, confirmed by screenshot baseline comparison. + """ + + def test_initial_render_baseline(self, page: Page, visor_server, baseline_dir, request): + """Take a baseline screenshot of the tensor mesh initial render.""" + target_url = visor_server.url + page.set_viewport_size({"width": 1280, "height": 800}) + page.goto(target_url, wait_until="networkidle") + canvas = wait_for_canvas_alive(page, "tensor-initial") + settle(page, 300) # preserve prior render-settle timing before baseline capture + + result = visual.verify_canvas_against_baseline( + canvas_locator=canvas, + baseline_dir=baseline_dir, + pixel_threshold=2.55, + request=request, + compare_images=compare_images, + test_id="e2e02_tensor_initial_render", + test_suite="regression", + ) + + suite_result = visual.make_test_result(result) + assert suite_result.passed, "Initial render baseline failed\n" + suite_result.summary() + + def test_component_selection_changes_rendering(self, page: Page, visor_server): + """Selecting different tensor components produces visually distinct canvas renders.""" + target_url = visor_server.url + page.set_viewport_size({"width": 1280, "height": 800}) + page.goto(target_url, wait_until="networkidle") + canvas = wait_for_canvas_alive(page, "tensor-coloring") + + # Select the part to activate the variable selection UI + part_row = page.locator("div.visor-tree-view td:nth-child(2)").filter(has_text="tensor_test_sphere") + part_row.wait_for(state="visible", timeout=10_000) + part_row.click() + + # Select the Stress variable + variable_select = page.locator("select").filter(has=page.locator("option[data-name='Stress']")) + variable_select.wait_for(state="visible", timeout=10_000) + variable_select.select_option(value="POINT::Stress::9") + + # Wait for the component dropdown to become visible + component_select = page.locator("label").filter(has_text="Component").locator("select") + component_select.wait_for(state="visible", timeout=10_000) + + # Capture a canvas screenshot per component for the representative subset + components_to_test = ["XX", "YY", "ZZ", "Magnitude"] + screenshots_root = Path("tests/artifacts/regression/screenshots") + screenshots_root.mkdir(parents=True, exist_ok=True) + + stamp = time.strftime("%Y%m%d-%H%M%S") + shot_paths: list[Path] = [] + for comp in components_to_test: + component_select.select_option(label=comp) + settle(page, 500) # Allow renderer time to update + shot_path = screenshots_root / f"e2e04_coloring_{comp}_{stamp}.png" + canvas.screenshot(path=str(shot_path)) + shot_paths.append(shot_path) + + # Assert each consecutive pair of screenshots differs (RMS > threshold), + # confirming that selecting a different component changes the rendering. + min_rms = 1.0 + for i in range(len(shot_paths) - 1): + img_a = Image.open(shot_paths[i]) + img_b = Image.open(shot_paths[i + 1]) + img_a, img_b = ensure_same_size_and_mode(img_a, img_b) + rms, _ = rms_diff(img_a, img_b) + assert rms > min_rms, ( + f"Switching from '{components_to_test[i]}' to '{components_to_test[i + 1]}' " + f"did not change the render (RMS={rms:.3f}, expected > {min_rms}). " + f"Screenshots: {shot_paths[i]}, {shot_paths[i + 1]}" + ) + + # =========================================================================== # Variable dropdown shows all 9 tensor components # =========================================================================== @@ -124,89 +207,6 @@ def test_tensor_component_dropdown_options(self, page: Page, visor_server, basel ) -# =========================================================================== -# Selecting a tensor component colors the part (screenshot diff) -# =========================================================================== - -@pytest.mark.parametrize("visor_asset_spec", [TENSOR_ASSETS], indirect=True) -@pytest.mark.regression -class TestTensorComponentColoring: - """ - Verify that selecting a tensor component updates the rendered - view, confirmed by screenshot baseline comparison. - """ - - def test_initial_render_baseline(self, page: Page, visor_server, baseline_dir, request): - """Take a baseline screenshot of the tensor mesh initial render.""" - target_url = visor_server.url - page.set_viewport_size({"width": 1280, "height": 800}) - page.goto(target_url, wait_until="networkidle") - canvas = wait_for_canvas_alive(page, "tensor-initial") - settle(page, 300) # preserve prior render-settle timing before baseline capture - - result = visual.verify_canvas_against_baseline( - canvas_locator=canvas, - baseline_dir=baseline_dir, - pixel_threshold=2.55, - request=request, - compare_images=compare_images, - test_id="e2e02_tensor_initial_render", - test_suite="regression", - ) - - suite_result = visual.make_test_result(result) - assert suite_result.passed, "Initial render baseline failed\n" + suite_result.summary() - - def test_component_selection_changes_rendering(self, page: Page, visor_server): - """Selecting different tensor components produces visually distinct canvas renders.""" - target_url = visor_server.url - page.set_viewport_size({"width": 1280, "height": 800}) - page.goto(target_url, wait_until="networkidle") - canvas = wait_for_canvas_alive(page, "tensor-coloring") - - # Select the part to activate the variable selection UI - part_row = page.locator("div.visor-tree-view td:nth-child(2)").filter(has_text="tensor_test_sphere") - part_row.wait_for(state="visible", timeout=10_000) - part_row.click() - - # Select the Stress variable - variable_select = page.locator("select").filter(has=page.locator("option[data-name='Stress']")) - variable_select.wait_for(state="visible", timeout=10_000) - variable_select.select_option(value="POINT::Stress::9") - - # Wait for the component dropdown to become visible - component_select = page.locator("label").filter(has_text="Component").locator("select") - component_select.wait_for(state="visible", timeout=10_000) - - # Capture a canvas screenshot per component for the representative subset - components_to_test = ["Magnitude", "XX", "YY", "ZZ"] - screenshots_root = Path("tests/artifacts/regression/screenshots") - screenshots_root.mkdir(parents=True, exist_ok=True) - - stamp = time.strftime("%Y%m%d-%H%M%S") - shot_paths: list[Path] = [] - for comp in components_to_test: - component_select.select_option(label=comp) - settle(page, 500) # Allow renderer time to update - shot_path = screenshots_root / f"e2e04_coloring_{comp}_{stamp}.png" - canvas.screenshot(path=str(shot_path)) - shot_paths.append(shot_path) - - # Assert each consecutive pair of screenshots differs (RMS > threshold), - # confirming that selecting a different component changes the rendering. - min_rms = 1.0 - for i in range(len(shot_paths) - 1): - img_a = Image.open(shot_paths[i]) - img_b = Image.open(shot_paths[i + 1]) - img_a, img_b = ensure_same_size_and_mode(img_a, img_b) - rms, _ = rms_diff(img_a, img_b) - assert rms > min_rms, ( - f"Switching from '{components_to_test[i]}' to '{components_to_test[i + 1]}' " - f"did not change the render (RMS={rms:.3f}, expected > {min_rms}). " - f"Screenshots: {shot_paths[i]}, {shot_paths[i + 1]}" - ) - - # =========================================================================== # Regression — Selecting scalar/vector components still works # ===========================================================================