From 34699f5d22e8a8682d578f7094d509199f7cc702 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 29 Sep 2026 15:21:04 -0700 Subject: [PATCH 1/8] feat: client uses server-sourced variables list instead of building its own --- .../visor/visor-client/src/VisorFrontend.tsx | 41 +- .../components/ui-panels/Panel_TopRight.tsx | 3 +- .../ui-panels/Panel_TopRight_Util.tsx | 7 +- .../VariableRecordProjection.test.tsx | 518 ++++++++++++++++++ .../VisorSceneGraphPartTriggers.test.tsx | 25 +- .../jest-tests/VisorVariableManager.test.tsx | 67 +-- .../src/state/VisorSceneGraph.tsx | 4 +- .../src/state/VisorVariableManager.tsx | 234 +++++++- .../src/state/appstate/VisorSceneState.tsx | 15 +- .../src/state/appstate/VisorVariableState.tsx | 80 +++ 10 files changed, 895 insertions(+), 99 deletions(-) create mode 100644 src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx diff --git a/src/ansys/visor/visor-client/src/VisorFrontend.tsx b/src/ansys/visor/visor-client/src/VisorFrontend.tsx index 100a07d3..5142b311 100644 --- a/src/ansys/visor/visor-client/src/VisorFrontend.tsx +++ b/src/ansys/visor/visor-client/src/VisorFrontend.tsx @@ -4,7 +4,6 @@ import { getPromiseResolver } from './utils/JsHelpers'; import VisorAppState from './state/appstate/VisorAppState.tsx'; import VisorDatasetState from './state/appstate/VisorDatasetState.tsx'; import VisorPartState from './state/appstate/VisorPartState.tsx'; -import VisorVariableState from './state/appstate/VisorVariableState.tsx'; import { TreeViewUtil } from './treeview/TreeView.tsx'; import { StateInput } from './state/appstate/VisorStateCommon.tsx'; import VisorVtkSceneNode from './state/appstate/vtkInfo/VisorVtkSceneNode.tsx'; @@ -305,29 +304,9 @@ export class VisorFrontend { } sceneState.copyDataset(datasetState); } - const variableInfos = variableManager.globalVariableCollection; - for (const variableInfo of variableInfos.array) { - const variableState = new VisorVariableState(); - variableState.setId(variableInfo.id.toString()); - variableState.setArrayName(variableInfo.name); - variableState.setType(variableInfo.type); - variableState.setNumComponents(variableInfo.numComponents); - const magnitudeRange = variableInfo.getRangeInfo(-1); - if (magnitudeRange == null) { - throw new Error(`range at component ${-1} not found`); - } - variableState.setMagnitudeRange(magnitudeRange.customRange); - const ranges: number[][] = []; - for (let i = 0; i < variableInfo.numComponents; i++) { - const range = variableInfo.getRangeInfo(i); - if (range == null) { - throw new Error(`range at component ${i} not found`); - } - ranges.push(range.customRange); - } - variableState.setRanges(ranges); - sceneState.copyVariable(variableState); - } + // No variable block: the server owns the variable records and + // delivers them on every push. Carrying a copy across a rebuild + // would replace the delivered records with this one. return appState; }; this.setAppStateAsync = async (state, updateUI) => { @@ -460,6 +439,13 @@ export class VisorFrontend { const promise = renderer.setCrossSectionNormalAsync(crossSectionState.normal); promises.push(promise); } + // Held before the part loop: a part coloured below reads its + // range from these records. Guarded on presence, so a state with + // no variable block leaves the held records alone, while an empty + // block clears them. + if (sceneState.hasVariableStates) { + variableManager.setRecords(sceneState.getVariableStates()); + } for (const dataset_state of sceneState.getDatasetStates()) { for (const part_state of dataset_state.getPartStates()) { let node; @@ -549,6 +535,13 @@ export class VisorFrontend { // above; the tree derives its rows from them here. treeViewUtil.synchronize(); } + // After the tree has taken the delivered selection, so the panel + // re-reads that selection's ranges from the records just held. + // Skipped until the panel exists; its mount reads them itself. + if (isPanelTopRightUtilSet()) { + const panelTopRight = await panelTopRightUtilPromise; + await panelTopRight.refreshSelectionAsync(); + } await renderer.resizeAsync(); }; Object.freeze(this); diff --git a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopRight.tsx b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopRight.tsx index 23ce8561..e106406c 100644 --- a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopRight.tsx +++ b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopRight.tsx @@ -493,7 +493,8 @@ export const Panel_TopRight: FC<{ legendTabElem.click(); } }, - () => tabIndex + () => tabIndex, + () => onSelectionChangeAsync(treeViewUtil.selectedNodes) ); visorState.setPanelTopRightUtil(util); // Must stay on the line after the util handoff: it suppresses the three mount writes diff --git a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopRight_Util.tsx b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopRight_Util.tsx index 96576216..446547b4 100644 --- a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopRight_Util.tsx +++ b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopRight_Util.tsx @@ -7,7 +7,8 @@ export class Panel_TopRight_Util { getIsPanelCollapsed: () => boolean, getIsLegendCollapsed: () => boolean, selectTab: (tabIndex: number) => void, - getTabIndex: () => number + getTabIndex: () => number, + refreshSelectionAsync: () => Promise ) { this.expandPanel = expandPanel; this.collapsePanel = collapsePanel; @@ -17,6 +18,7 @@ export class Panel_TopRight_Util { this.#getIsLegendCollapsed = getIsLegendCollapsed; this.selectTab = selectTab; this.#getTabIndex = getTabIndex; + this.refreshSelectionAsync = refreshSelectionAsync; } readonly collapsePanel: () => void; @@ -40,4 +42,7 @@ export class Panel_TopRight_Util { get tabIndex() { return this.#getTabIndex(); } + + /** Re-reads the current selection into the properties and legend panels. */ + readonly refreshSelectionAsync: () => Promise; } diff --git a/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx new file mode 100644 index 00000000..11742421 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx @@ -0,0 +1,518 @@ +import { render, fireEvent, act, within } from '@testing-library/react'; +import { VisorFrontend } from '../VisorFrontend'; +import { Panel_TopRight } from '../components/ui-panels/Panel_TopRight'; +import { Panel_TopRight_Util } from '../components/ui-panels/Panel_TopRight_Util.tsx'; +import { getVariableManager } from '../state/VisorVariableManager.tsx'; +import VisorAppState from '../state/appstate/VisorAppState.tsx'; +import VisorVariableState from '../state/appstate/VisorVariableState.tsx'; +import type { VisorSceneNodeExtended } from '../state/VisorSceneGraph.tsx'; +import type { TreeViewUtil } from '../treeview/TreeView.tsx'; +import type { IRenderer } from '../renderer/IRenderer'; +import type VisorVtkSceneNode from '../state/appstate/vtkInfo/VisorVtkSceneNode.tsx'; + +/** + * The client as a projection of server-owned variable records. + * + * Three subjects: + * + * 1. The variable manager holds the delivered records and nothing else. A + * part's variables are the records that list it; a range is the record's + * slot, magnitude at component -1 and component i at i; nothing is + * widened or reset on the client; an incomplete record is refused. + * + * 2. `setAppStateAsync` holds a delivered variable block before the part + * loop, and only when the block is present: an absent block (a state the + * client produced itself, as a rebuild carries across) leaves the held + * records alone, and an empty block clears them. + * + * 3. The top-right panel re-reads the selection's ranges after every + * delivery, after the tree has synchronized, so a delivered range reaches + * the legend with no selection change. + * + * Expected values are hand-written literals. The trigger sender is a double + * that absorbs every send; nothing here asserts on it. + * + * jsdom has no `ResizeObserver` and jest here runs with no `setupFiles`, so + * the stub below is this module's own. + */ + +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} +(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub; + +const PART_A_ID = 1; +const PART_B_ID = 2; +const PRESSURE_ID = 'POINT::pressure::1'; +const TEMPERATURE_ID = 'POINT::temperature::1'; + +/** A complete wire record for `pressure`, custom range [2, 8] inside default [0, 10]. */ +function pressureRecord(overrides: Record = {}): Record { + return { + id: PRESSURE_ID, + arrayName: 'pressure', + type: 'POINT', + numComponents: 1, + partIds: [PART_A_ID], + defaultMagnitudeRange: [0, 10], + defaultRanges: [[0, 10]], + magnitudeRange: [2, 8], + ranges: [[2, 8]], + ...overrides, + }; +} + +/** A complete wire record for `temperature`, listing part B only. */ +function temperatureRecord(): Record { + return { + id: TEMPERATURE_ID, + arrayName: 'temperature', + type: 'POINT', + numComponents: 1, + partIds: [PART_B_ID], + defaultMagnitudeRange: [-20, 100], + defaultRanges: [[-20, 100]], + magnitudeRange: [-20, 100], + ranges: [[-20, 100]], + }; +} + +function makeSceneGraphNode() { + return { + id: 0, + dataArrays: [], + name: '', + isGroupNode: true, + isActorNode: false, + nodeType: 'root', + diffuseColor: [1, 1, 1], + bounds: [], + children: [ + { + id: PART_A_ID, + dataArrays: [], + name: 'part-a', + isGroupNode: false, + isActorNode: true, + nodeType: 'vtkUnstructuredGrid', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + ], + }; +} + +/** + * Every renderer member the frontend's constructor, `getAppStateAsync`, + * `setAppStateAsync`, the part's own methods and the mounted panel reach. + */ +function makeRendererDouble() { + return { + attachSceneGraph: jest.fn(), + domElement: document.createElement('div'), + addCameraSettledListener: jest.fn(() => jest.fn()), + addViewerClickedListener: jest.fn(() => jest.fn()), + renderAsync: jest.fn(async () => undefined), + resizeAsync: jest.fn(async () => undefined), + // Read by getAppStateAsync. + isOrthographicEnabled: jest.fn(() => false), + isCrossSectionVisible: jest.fn(() => false), + areEdgesVisibleGlobally: jest.fn(() => true), + isBoundingBoxVisible: jest.fn(() => false), + getCameraStateAsync: jest.fn(async () => ({ + position: [0, 0, 1], + focalPoint: [0, 0, 0], + viewUp: [0, 1, 0], + clippingRange: [0.1, 10], + parallelProjection: false, + viewAngle: 30, + parallelScale: 1, + })), + getCrossSectionOriginAsync: jest.fn(async () => [0, 0, 0]), + getCrossSectionNormalAsync: jest.fn(async () => [1, 0, 0]), + // Written by setAppStateAsync. + setOrthographicModeAsync: jest.fn(async () => undefined), + setCrossSectionVisibilityAsync: jest.fn(async () => undefined), + setEdgeVisibilityGlobalAsync: jest.fn(async () => undefined), + setBoundingBoxVisibilityAsync: jest.fn(async () => undefined), + setCameraPositionAsync: jest.fn(async () => undefined), + setCameraFocalPointAsync: jest.fn(async () => undefined), + setCameraViewUpAsync: jest.fn(async () => undefined), + setCameraClippingRangeAsync: jest.fn(async () => undefined), + setCameraViewAngleAsync: jest.fn(async () => undefined), + setCameraParallelScaleAsync: jest.fn(async () => undefined), + setCrossSectionOriginAsync: jest.fn(async () => undefined), + setCrossSectionNormalAsync: jest.fn(async () => undefined), + // Reached through the part's own methods. + setColorVariableAsync: jest.fn(async () => undefined), + sendPartColorVariableAsync: jest.fn(async () => undefined), + clearColorVariableAsync: jest.fn(async () => undefined), + sendClearPartColorVariableAsync: jest.fn(async () => undefined), + setScalarRangeAsync: jest.fn(async () => undefined), + setDiffuseColorRgbAsync: jest.fn(async () => undefined), + sendPartDiffuseColorAsync: jest.fn(async () => undefined), + setSelectedAsync: jest.fn(async () => undefined), + sendPartSelectedAsync: jest.fn(async () => undefined), + setOpacityAsync: jest.fn(async () => undefined), + sendPartOpacityAsync: jest.fn(async () => undefined), + setVisibilityAsync: jest.fn(async () => undefined), + sendPartVisibilityAsync: jest.fn(async () => undefined), + }; +} + +type RendererDouble = ReturnType; + +function makeFrontend(): { frontend: VisorFrontend; renderer: RendererDouble } { + const renderer = makeRendererDouble(); + const frontend = new VisorFrontend( + renderer as unknown as IRenderer, + makeSceneGraphNode() as unknown as VisorVtkSceneNode, + jest.fn(async () => undefined) + ); + return { frontend, renderer }; +} + +/** The tree-view util members the frontend and the mounted panel reach. */ +function makeTreeViewUtilDouble( + selectedNodes: VisorSceneNodeExtended[], + synchronize: () => void = () => {} +): TreeViewUtil { + return { + addSelectionChangeListener: jest.fn(() => jest.fn()), + rows: [], + rowUtilsMap: new Map(), + selectedNodes, + updateSelectedNodesArray: jest.fn(), + synchronize, + } as unknown as TreeViewUtil; +} + +describe('the variable manager holds the delivered records', () => { + test('a part collection lists exactly the held variables whose record lists the part', () => { + const manager = getVariableManager(); + // Taken before the delivery: a frozen node keeps the collection it + // was built with, so the collection has to be a live view. + const partA = manager.getPartVariableCollection(PART_A_ID); + const partB = manager.getPartVariableCollection(PART_B_ID); + const partC = manager.getPartVariableCollection(3); + + manager.setRecords([ + new VisorVariableState(pressureRecord({ partIds: [PART_A_ID, PART_B_ID] })), + new VisorVariableState(temperatureRecord()), + ]); + + expect(partA.array.map((v) => v.id)).toEqual(['POINT::pressure::1']); + expect(partB.array.map((v) => v.id)).toEqual([ + 'POINT::pressure::1', + 'POINT::temperature::1', + ]); + expect(partC.array).toEqual([]); + expect(partA.getVariable('POINT::temperature::1')).toBeNull(); + expect(partB.getVariable('POINT::temperature::1')?.name).toBe('temperature'); + }); + + test('a second delivery replaces the default range rather than widening it', () => { + const manager = getVariableManager(); + manager.setRecords([new VisorVariableState(pressureRecord())]); + + manager.setRecords([ + new VisorVariableState( + pressureRecord({ + defaultMagnitudeRange: [1, 5], + magnitudeRange: [1, 5], + }) + ), + ]); + + const variable = manager.getPartVariableCollection(PART_A_ID).getVariable(PRESSURE_ID)!; + expect(variable.getRangeInfo(-1)).toEqual({ defaultRange: [1, 5], customRange: [1, 5] }); + }); + + test('component -1 reads the magnitude slot and component i reads range i, default and custom apart', () => { + const manager = getVariableManager(); + manager.setRecords([ + new VisorVariableState({ + id: 'POINT::velocity::2', + arrayName: 'velocity', + type: 'POINT', + numComponents: 2, + partIds: [PART_A_ID], + defaultMagnitudeRange: [0, 10], + defaultRanges: [ + [-1, 1], + [-2, 2], + ], + magnitudeRange: [0, 5], + ranges: [ + [-0.5, 0.5], + [-1.5, 1.5], + ], + }), + ]); + + const variable = manager + .getPartVariableCollection(PART_A_ID) + .getVariable('POINT::velocity::2')!; + expect(variable.getRangeInfo(-1)).toEqual({ defaultRange: [0, 10], customRange: [0, 5] }); + expect(variable.getRangeInfo(0)).toEqual({ + defaultRange: [-1, 1], + customRange: [-0.5, 0.5], + }); + expect(variable.getRangeInfo(1)).toEqual({ + defaultRange: [-2, 2], + customRange: [-1.5, 1.5], + }); + expect(variable.getRangeInfo(2)).toBeNull(); + expect(variable.getRangeInfo(-2)).toBeNull(); + }); + + test('a custom range written through one collection is read through every other', () => { + const manager = getVariableManager(); + manager.finishAddingDataArrayMetadata(); + manager.setRecords([ + new VisorVariableState(pressureRecord({ partIds: [PART_A_ID, PART_B_ID] })), + ]); + + manager + .getPartVariableCollection(PART_A_ID) + .getVariable(PRESSURE_ID)! + .setCustomRange(-1, 3, 7); + + expect(manager.globalVariableCollection.getVariable(PRESSURE_ID)!.getRangeInfo(-1)).toEqual( + { defaultRange: [0, 10], customRange: [3, 7] } + ); + expect( + manager.getPartVariableCollection(PART_B_ID).getVariable(PRESSURE_ID)!.getRangeInfo(-1) + ).toEqual({ defaultRange: [0, 10], customRange: [3, 7] }); + }); + + test('an incomplete record is refused, naming the field, and the held records are kept', () => { + const manager = getVariableManager(); + manager.setRecords([new VisorVariableState(pressureRecord())]); + const fields = [ + 'id', + 'arrayName', + 'type', + 'numComponents', + 'partIds', + 'defaultMagnitudeRange', + 'defaultRanges', + 'magnitudeRange', + 'ranges', + ]; + + for (const field of fields) { + const record = pressureRecord(); + delete record[field]; + expect(() => manager.setRecords([new VisorVariableState(record)])).toThrow( + `'${field}'` + ); + } + expect(() => + manager.setRecords([ + new VisorVariableState( + pressureRecord({ + numComponents: 2, + defaultRanges: [ + [0, 10], + [0, 10], + ], + ranges: [[2, 8]], + }) + ), + ]) + ).toThrow(`'ranges'`); + + const variable = manager.getPartVariableCollection(PART_A_ID).getVariable(PRESSURE_ID)!; + expect(variable.getRangeInfo(-1)).toEqual({ defaultRange: [0, 10], customRange: [2, 8] }); + }); +}); + +describe('setAppStateAsync holds a delivered variable block', () => { + test('a part is coloured at the delivered custom range, not the default', async () => { + const { frontend, renderer } = makeFrontend(); + + // Delivered as an instance: the block has to survive the copy + // `setAppStateAsync` makes of it. + await frontend.setAppStateAsync( + new VisorAppState({ + scene: { + variableStates: { [PRESSURE_ID]: pressureRecord() }, + datasetStates: { + '0': { + id: '0', + partStates: { + '1': { id: '1', variableId: PRESSURE_ID, variableComponent: -1 }, + }, + }, + }, + }, + }), + false + ); + + expect(renderer.setColorVariableAsync).toHaveBeenCalledWith(PART_A_ID, { + variableId: 'POINT::pressure::1', + variableType: 'POINT', + variableName: 'pressure', + component: -1, + min: 2, + max: 8, + }); + }); + + test("the client's own state carried back leaves the held records untouched", async () => { + // The client's own state carries `ui.darkTheme`, whose branch fetches + // the two theme stylesheets; jsdom has no `fetch`. + const globals = globalThis as unknown as { fetch: unknown }; + const originalFetch = globals.fetch; + globals.fetch = jest.fn(async () => ({ text: async () => '' })); + try { + const { frontend } = makeFrontend(); + await frontend.setAppStateAsync( + { scene: { variableStates: { [PRESSURE_ID]: pressureRecord() } } }, + false + ); + + await frontend.setAppStateAsync(await frontend.getAppStateAsync(), false); + + expect( + frontend.globalVariableCollection.getVariable(PRESSURE_ID)?.getRangeInfo(-1) + ).toEqual({ + defaultRange: [0, 10], + customRange: [2, 8], + }); + } finally { + globals.fetch = originalFetch; + } + }); + + test('a delivered empty block clears the held records', async () => { + const { frontend } = makeFrontend(); + await frontend.setAppStateAsync( + { scene: { variableStates: { [PRESSURE_ID]: pressureRecord() } } }, + false + ); + + await frontend.setAppStateAsync({ scene: { variableStates: {} } }, false); + + expect(frontend.globalVariableCollection.array).toEqual([]); + expect(frontend.globalVariableCollection.getVariable(PRESSURE_ID)).toBeNull(); + }); + + test('the top-right panel is refreshed after the tree synchronizes', async () => { + const { frontend } = makeFrontend(); + const order: string[] = []; + frontend.setTreeViewUtil(makeTreeViewUtilDouble([], () => order.push('synchronize'))); + const noop = () => {}; + frontend.setPanelTopRightUtil( + new Panel_TopRight_Util( + noop, + noop, + noop, + noop, + () => false, + () => false, + noop, + () => 0, + async () => { + order.push('refresh'); + } + ) + ); + + await frontend.setAppStateAsync({ scene: { variableStates: {} } }, true); + + expect(order).toEqual(['synchronize', 'refresh']); + }); +}); + +describe('the legend follows a delivery with no selection change', () => { + /** + * A real frontend holding `pressure` at [2, 8], with part A coloured by + * its magnitude and selected in the tree, and the top-right panel mounted + * on it. + */ + async function mountOnDeliveredRecord(): Promise<{ + frontend: VisorFrontend; + container: HTMLElement; + }> { + const { frontend } = makeFrontend(); + const part = frontend.sceneGraph.descendantActorNodesOrSelfDictionary[PART_A_ID]; + frontend.setTreeViewUtil(makeTreeViewUtilDouble([part])); + await frontend.setAppStateAsync( + { scene: { variableStates: { [PRESSURE_ID]: pressureRecord() } } }, + false + ); + await part.setColorVariableAsync(PRESSURE_ID, -1); + + let container: HTMLElement = null!; + await act(async () => { + container = render( + {}} /> + ).container; + }); + await frontend.panelTopRightUtilPromise; + return { frontend, container }; + } + + /** + * The legend's min or max row, located by its heading: the ids in this + * component are `randomId()`-generated. + */ + function legendRow(container: HTMLElement, heading: 'Min' | 'Max') { + const label = within(container).getByText(heading, { selector: 'div' }).closest('label')!; + return { + input: label.querySelector('input') as HTMLInputElement, + reset: label.querySelector('a') as HTMLAnchorElement, + }; + } + + test('a delivered custom range is shown in the legend', async () => { + const { frontend, container } = await mountOnDeliveredRecord(); + expect(legendRow(container, 'Min').input.value).toBe('2'); + expect(legendRow(container, 'Max').input.value).toBe('8'); + + await act(async () => { + await frontend.setAppStateAsync( + { + scene: { + variableStates: { + [PRESSURE_ID]: pressureRecord({ magnitudeRange: [3, 7] }), + }, + }, + }, + true + ); + }); + + expect(legendRow(container, 'Min').input.value).toBe('3'); + expect(legendRow(container, 'Max').input.value).toBe('7'); + }); + + test('reset shows the delivered default range', async () => { + const { frontend, container } = await mountOnDeliveredRecord(); + + await act(async () => { + await frontend.setAppStateAsync( + { + scene: { + variableStates: { + [PRESSURE_ID]: pressureRecord({ defaultMagnitudeRange: [-1, 12] }), + }, + }, + }, + true + ); + }); + const minRow = legendRow(container, 'Min'); + await act(async () => { + fireEvent.click(minRow.reset); + }); + + expect(minRow.input.value).toBe('-1'); + }); +}); 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 index fe47f3d5..4ff9b0aa 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx @@ -1,4 +1,6 @@ import { CreateVisorSceneGraph, VisorSceneNodeExtended } from '../state/VisorSceneGraph.tsx'; +import { getVariableManager } from '../state/VisorVariableManager.tsx'; +import VisorVariableState from '../state/appstate/VisorVariableState.tsx'; import VisorVtkDataArray from '../state/appstate/vtkInfo/VisorVtkDataArray.tsx'; import type { IRenderer } from '../renderer/IRenderer'; @@ -35,6 +37,25 @@ function makePressureArray(): VisorVtkDataArray { }); } +/** + * The server's record for `pressure`, listing both parts. A part's colour + * variable is read from the record, not from the part's data arrays, so the + * component range the descriptor carries is `ranges[0]`, the custom range. + */ +function makePressureRecord(): VisorVariableState { + return new VisorVariableState({ + id: VARIABLE_ID, + arrayName: 'pressure', + type: 'POINT', + numComponents: 1, + partIds: [PART_A_ID, PART_B_ID], + defaultMagnitudeRange: [0, 10], + defaultRanges: [[0, 10]], + magnitudeRange: [0, 10], + ranges: [[2, 8]], + }); +} + function makeRendererDouble() { return { // The per-part applies that already existed. @@ -60,6 +81,8 @@ function makeRendererDouble() { type RendererDouble = ReturnType; function makeGraph(renderer: RendererDouble): VisorSceneNodeExtended { + const variableManager = getVariableManager(); + variableManager.setRecords([makePressureRecord()]); return CreateVisorSceneGraph( { id: ROOT_ID, @@ -95,7 +118,7 @@ function makeGraph(renderer: RendererDouble): VisorSceneNodeExtended { }, ], }, - undefined, + variableManager, renderer as unknown as IRenderer ); } diff --git a/src/ansys/visor/visor-client/src/jest-tests/VisorVariableManager.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/VisorVariableManager.test.tsx index 7630d4ad..fa4a9984 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/VisorVariableManager.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/VisorVariableManager.test.tsx @@ -1,4 +1,5 @@ import VisorVtkDataArray from '../state/appstate/vtkInfo/VisorVtkDataArray.tsx'; +import VisorVariableState from '../state/appstate/VisorVariableState.tsx'; import { getVariableManager, VisorVariableInfo } from '../state/VisorVariableManager.tsx'; describe('getVariableManager', () => { @@ -43,7 +44,19 @@ describe('getVariableManager', () => { const firstManager = getVariableManager(); const secondManager = getVariableManager(); - firstManager.addDataArrayMetadata([createDataArray()]); + firstManager.setRecords([ + new VisorVariableState({ + id: 'POINT::temperature::1', + arrayName: 'temperature', + type: 'POINT', + numComponents: 1, + partIds: [1], + defaultMagnitudeRange: [0, 10], + defaultRanges: [[0, 10]], + magnitudeRange: [0, 10], + ranges: [[0, 10]], + }), + ]); firstManager.finishAddingDataArrayMetadata(); secondManager.finishAddingDataArrayMetadata(); @@ -521,58 +534,6 @@ describe('getVariableManager', () => { }); describe('global variable collection', () => { - test('contains variables added before finishing', () => { - const manager = getVariableManager(); - - manager.addDataArrayMetadata([ - createDataArray({ - name: 'temperature', - }), - createDataArray({ - name: 'pressure', - }), - ]); - - manager.finishAddingDataArrayMetadata(); - - const global = manager.globalVariableCollection; - - expect(global.array).toHaveLength(2); - expect(global.array.map((item) => item.name)).toEqual(['temperature', 'pressure']); - }); - - test('contains only one entry for duplicate variable IDs', () => { - const manager = getVariableManager(); - - manager.addDataArrayMetadata([ - createDataArray({ - name: 'temperature', - }), - ]); - - manager.addDataArrayMetadata([ - createDataArray({ - name: 'temperature', - }), - ]); - - manager.finishAddingDataArrayMetadata(); - - expect(manager.globalVariableCollection.array).toHaveLength(1); - }); - - test('returns the same variable object as a local collection', () => { - const manager = getVariableManager(); - - const local = manager.addDataArrayMetadata([createDataArray()]); - - manager.finishAddingDataArrayMetadata(); - - const id = local.array[0].id; - const global = manager.globalVariableCollection; - - expect(global.getVariable(id)).toBe(local.array[0]); - }); test('returns null for null and unknown global IDs', () => { const manager = getVariableManager(); diff --git a/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx b/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx index 32cec1ce..309f2cdd 100644 --- a/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx +++ b/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx @@ -115,7 +115,9 @@ export const CreateVisorSceneGraph = (() => { } const nodeId = simpleNode.id; - const variableCollection = variableManager.addDataArrayMetadata(simpleNode.dataArrays); + // A live view: the node is frozen, and the records it reads are + // replaced on every delivery. + const variableCollection = variableManager.getPartVariableCollection(nodeId); const node: VisorSceneNodeExtended = { id: simpleNode.id, diff --git a/src/ansys/visor/visor-client/src/state/VisorVariableManager.tsx b/src/ansys/visor/visor-client/src/state/VisorVariableManager.tsx index 0c973f20..5f7bfa1a 100644 --- a/src/ansys/visor/visor-client/src/state/VisorVariableManager.tsx +++ b/src/ansys/visor/visor-client/src/state/VisorVariableManager.tsx @@ -1,4 +1,5 @@ import VisorVtkDataArray, { FieldAssociation } from './appstate/vtkInfo/VisorVtkDataArray.tsx'; +import type VisorVariableState from './appstate/VisorVariableState.tsx'; /** * Describes a selectable component of a variable. @@ -125,7 +126,25 @@ export type VisorVariableManager = Readonly<{ finishAddingDataArrayMetadata: () => void; /** - * Finalized collection of all globally registered variables. + * Replaces the held variable records with a delivered set. + * + * @param records - Every variable record in the delivery, each complete. + * + * @throws Error if any record is missing a field, carries fewer ranges than + * components, or has an unsupported component count. The held set is then + * left unchanged. + */ + setRecords: (records: VisorVariableState[]) => void; + + /** + * Gets a live collection of the held variables whose records list a part. + * + * @param partId - Scene-graph id of the part. + */ + getPartVariableCollection: (partId: number) => VisorVariableCollection; + + /** + * Live collection of every held variable record. * * @throws Error if * {@link VisorVariableManager.finishAddingDataArrayMetadata} has not yet @@ -134,6 +153,108 @@ export type VisorVariableManager = Readonly<{ globalVariableCollection: VisorVariableCollection; }>; +/** + * One held variable record, with its ranges in slot order: slot 0 is the + * magnitude and slot `i + 1` is component `i`. + */ +class HeldVariableRecord { + private constructor( + private readonly partIds: ReadonlySet, + private readonly defaultRanges: number[][], + private readonly customRanges: number[][] + ) {} + + /** + * Copies a delivered record into slot order. + * + * @throws Error if the record is missing a field or carries fewer ranges + * than components. + */ + static fromState(state: VisorVariableState): HeldVariableRecord { + const id = state.id; + const numComponents = state.numComponents; + const missing = (field: string) => + new Error(`Variable record '${id}' is missing '${field}'`); + + if (id === '') { + throw missing('id'); + } else if (state.arrayName === '') { + throw missing('arrayName'); + } else if (state.type === undefined) { + throw missing('type'); + } else if (!(numComponents > 0)) { + throw missing('numComponents'); + } else if (state.partIds === undefined) { + throw missing('partIds'); + } + + const defaultMagnitudeRange = state.defaultMagnitudeRange; + const defaultRanges = state.defaultRanges; + const magnitudeRange = state.magnitudeRange; + if (defaultMagnitudeRange === undefined) { + throw missing('defaultMagnitudeRange'); + } else if (defaultRanges === undefined) { + throw missing('defaultRanges'); + } else if (magnitudeRange === undefined) { + throw missing('magnitudeRange'); + } + + const toSlots = ( + field: string, + magnitude: number[], + perComponent: (number[] | undefined)[] + ): number[][] => { + const slots: number[][] = [[magnitude[0], magnitude[1]]]; + for (let i = 0; i < numComponents; i++) { + const range = perComponent[i]; + if (range === undefined) { + throw new Error( + `Variable record '${id}' has no entry ${i} in '${field}' for ${numComponents} component(s)` + ); + } + slots.push([range[0], range[1]]); + } + return slots; + }; + + return new HeldVariableRecord( + new Set(state.partIds), + toSlots('defaultRanges', defaultMagnitudeRange, defaultRanges), + toSlots('ranges', magnitudeRange, state.ranges) + ); + } + + /** Whether the record lists the part. */ + includesPart(partId: number): boolean { + return this.partIds.has(partId); + } + + /** Cloned default and custom range of a component, `-1` being the magnitude. */ + getRangeInfo( + component: number | null | undefined + ): null | { defaultRange: number[]; customRange: number[] } { + if (component == null) { + return null; + } + const slot = component + 1; + return slot >= 0 && slot < this.customRanges.length + ? { + defaultRange: [...this.defaultRanges[slot]], + customRange: [...this.customRanges[slot]], + } + : null; + } + + /** Writes the custom range of a component, `-1` being the magnitude; ignores any other id. */ + setCustomRange(component: number, min: number, max: number): void { + const slot = component + 1; + if (slot >= 0 && slot < this.customRanges.length) { + this.customRanges[slot][0] = min; + this.customRanges[slot][1] = max; + } + } +} + /** * Creates a variable manager for aggregating metadata from VTK data arrays. * @@ -195,49 +316,91 @@ export function getVariableManager(): VisorVariableManager { componentLabels: ['Magnitude', 'XX', 'XY', 'XZ', 'YX', 'YY', 'YZ', 'ZX', 'ZY', 'ZZ'], }); - /** Finalized global collection, or `null` until registration is complete. */ - let globalVariableCollection: VisorVariableCollection | null = null; + /** Held records by id, each with the variable object that reads it, in delivery order. */ + let heldRecords: Map = + new Map(); + + /** Whether the global collection may be read. */ + let finished = false; + + /** The one global collection: a live view over the held records. */ + const globalVariableCollection: VisorVariableCollection = Object.freeze({ + get array() { + return [...heldRecords.values()].map((entry) => entry.info); + }, + + /** + * Finds a held variable. + * + * @param id - Variable identifier, or `null`. + * @returns The matching variable, or `null` when none is held. + */ + getVariable(id: string | null) { + return id != null ? (heldRecords.get(id)?.info ?? null) : null; + }, + }); return Object.freeze({ addDataArrayMetadata, /** - * Finalizes the global collection after all data-array metadata has been added. + * Opens the global collection for reading. * - * @throws Error if the global collection has already been finalized. + * @throws Error if the global collection has already been opened. */ finishAddingDataArrayMetadata() { - if (globalVariableCollection != null) { + if (finished) { throw new Error(`finishAddingDataArrayMetadata() has already been called`); } + finished = true; + }, - const array = []; - for (const item of globalVariableMap.values()) { - array.push(item); + setRecords(records: VisorVariableState[]) { + const next: Map = + new Map(); + for (const state of records) { + const record = HeldVariableRecord.fromState(state); + const existing = heldRecords.get(state.id)?.info; + const info = + existing != null && + existing.name === state.arrayName && + existing.type === state.type && + existing.numComponents === state.numComponents + ? existing + : createRecordVariableInfo(state); + next.set(state.id, { record, info }); } + heldRecords = next; + }, - globalVariableCollection = Object.freeze({ - array, + getPartVariableCollection(partId: number): VisorVariableCollection { + return Object.freeze({ + get array() { + return [...heldRecords.values()] + .filter((entry) => entry.record.includesPart(partId)) + .map((entry) => entry.info); + }, /** - * Finds a globally registered variable. + * Finds a held variable whose record lists this part. * * @param id - Variable identifier, or `null`. - * @returns The matching variable, or `null` when none exists. + * @returns The matching variable, or `null` when none is held for the part. */ getVariable(id: string | null) { - return id != null ? (globalVariableMap.get(id) ?? null) : null; + const entry = id != null ? heldRecords.get(id) : undefined; + return entry != null && entry.record.includesPart(partId) ? entry.info : null; }, }); }, /** - * Gets the finalized global variable collection. + * Gets the global variable collection. * - * @throws Error if metadata registration has not yet been finalized. + * @throws Error if the collection has not yet been opened. */ get globalVariableCollection() { - if (globalVariableCollection == null) { + if (!finished) { throw new Error(`finishAddingDataArrayMetadata() has not been called yet`); } @@ -245,6 +408,43 @@ export function getVariableManager(): VisorVariableManager { }, }); + /** + * Creates the variable object for a held record. Its ranges are read from, + * and written to, whichever record is held under its id at the time. + * + * @throws Error if the record has an unsupported component count. + */ + function createRecordVariableInfo(state: VisorVariableState): VisorVariableInfo { + const id = state.id; + const type = state.type!; + const name = state.arrayName; + const numComponents = state.numComponents; + const labelInfo = labelInfoMap.get(numComponents); + if (labelInfo == null) { + const msg = `No label info was found for variable record '${id}'. Do we support label info`; + throw new Error(`${msg} for variables with ${numComponents} component(s)?`); + } + + const componentOptions: VisorVariableComponentMetadata[] = labelInfo.componentLabels.map( + (label, i) => ({ id: i - 1, name: label }) + ); + + return Object.freeze({ + id, + type, + name, + shape: labelInfo.shape, + fullName: `${type} - ${name} (${labelInfo.shape})`, + numComponents, + componentOptions, + getRangeInfo: (component: number | null | undefined) => + heldRecords.get(id)?.record.getRangeInfo(component) ?? null, + setCustomRange: (component: number, min: number, max: number) => { + heldRecords.get(id)?.record.setCustomRange(component, min, max); + }, + }); + } + /** * Creates or updates variable information for a data array. * diff --git a/src/ansys/visor/visor-client/src/state/appstate/VisorSceneState.tsx b/src/ansys/visor/visor-client/src/state/appstate/VisorSceneState.tsx index 86bb5151..bc4b4825 100644 --- a/src/ansys/visor/visor-client/src/state/appstate/VisorSceneState.tsx +++ b/src/ansys/visor/visor-client/src/state/appstate/VisorSceneState.tsx @@ -16,6 +16,7 @@ export default class VisorSceneState { private _crossSection: VisorCrossSectionState = new VisorCrossSectionState(); private _datasetStates: Record = {}; private _variableStates: Record = {}; + private _hasVariableStates: boolean = false; private _orthographicEnabled: boolean | undefined = undefined; private _crossSectionEnabled: boolean | undefined = undefined; private _edgesEnabled: boolean | undefined = undefined; @@ -88,6 +89,14 @@ export default class VisorSceneState { return delete this._datasetStates[idStr]; } + /** + * Whether this state carries a variable block at all. An empty block is a + * delivery of zero variables; an absent one delivers nothing. + */ + get hasVariableStates(): boolean { + return this._hasVariableStates; + } + getVariableState(idStr: string): VisorVariableState | null { ensureString(idStr, 'idStr'); return this._variableStates[idStr] ?? null; @@ -112,6 +121,7 @@ export default class VisorSceneState { const newState = new VisorVariableState(variableState, key); const existing = this._variableStates[newState.id]; + this._hasVariableStates = true; if (existing) { return existing.copy(newState, replace); @@ -236,6 +246,7 @@ export default class VisorSceneState { const variableStates = data.variableStates; if (variableStates) { + this._hasVariableStates = true; for (const [k, v] of Object.entries(variableStates)) { this.copyVariable(v as JsonDict, replace, k); } @@ -276,7 +287,9 @@ export default class VisorSceneState { crossSectionEnabled: this.crossSectionEnabled, edgesEnabled: this.edgesEnabled, boundingBoxEnabled: this.boundingBoxEnabled, - variableStates, + // Omitted rather than emitted empty, so that a copy of this state + // does not read as a delivery of zero variables. + ...(this._hasVariableStates ? { variableStates } : {}), datasetStates, }; } diff --git a/src/ansys/visor/visor-client/src/state/appstate/VisorVariableState.tsx b/src/ansys/visor/visor-client/src/state/appstate/VisorVariableState.tsx index a2d01884..f573199b 100644 --- a/src/ansys/visor/visor-client/src/state/appstate/VisorVariableState.tsx +++ b/src/ansys/visor/visor-client/src/state/appstate/VisorVariableState.tsx @@ -14,6 +14,9 @@ export default class VisorVariableState { private _arrayName: string = ''; private _type: FieldAssociation | undefined = undefined; private _numComponents: number = 0; + private _partIds: number[] | undefined = undefined; + private _defaultMagnitudeRange: number[] | undefined = undefined; + private _defaultRanges: (number[] | undefined)[] | undefined = undefined; private _magnitudeRange: number[] | undefined = undefined; private _ranges: (number[] | undefined)[] = []; @@ -64,6 +67,69 @@ export default class VisorVariableState { } } + /** Ids of the parts that carry this variable's data array. */ + get partIds(): number[] | undefined { + return this._partIds; + } + + /** Sets the carrying part ids; `undefined` clears them only when `replace` is set. */ + setPartIds(val: number[] | undefined, replace = false): void { + if (val === undefined) { + if (replace) { + this._partIds = undefined; + } + } else { + ensureNumberArray(val, 'val'); + this._partIds = [...val]; + } + } + + /** Magnitude range computed from the data, before any custom range is applied. */ + get defaultMagnitudeRange(): number[] | undefined { + return this._defaultMagnitudeRange; + } + + /** Sets the default magnitude range; `undefined` clears it only when `replace` is set. */ + setDefaultMagnitudeRange(val: number[] | undefined, replace = false): void { + if (val === undefined) { + if (replace) { + this._defaultMagnitudeRange = undefined; + } + } else { + ensureNumberArray(val, 'val', 2); + this._defaultMagnitudeRange = val; + } + } + + /** Per-component ranges computed from the data, before any custom range is applied. */ + get defaultRanges(): (number[] | undefined)[] | undefined { + return this._defaultRanges; + } + + /** Sets the per-component default ranges; `undefined` clears them only when `replace` is set. */ + setDefaultRanges(val: (number[] | undefined)[] | undefined, replace = false): void { + if (val === undefined) { + if (replace) { + this._defaultRanges = undefined; + } + return; + } + ensureArray(val, 'val'); + const defaultRanges = this._defaultRanges ?? []; + for (let i = 0; i < val.length; i++) { + const minmax = val[i]; + if (minmax == undefined) { + if (replace) { + defaultRanges[i] = minmax; + } + } else { + ensureNumberArray(minmax, 'minmax', 2); + defaultRanges[i] = minmax; + } + } + this._defaultRanges = defaultRanges; + } + get magnitudeRange(): number[] | undefined { return this._magnitudeRange; } @@ -124,6 +190,17 @@ export default class VisorVariableState { this.setNumComponents( data.numComponents === undefined ? this._numComponents : data.numComponents ); + this.setPartIds(data.partIds === undefined ? this._partIds : data.partIds, replace); + this.setDefaultMagnitudeRange( + data.defaultMagnitudeRange === undefined + ? this._defaultMagnitudeRange + : data.defaultMagnitudeRange, + replace + ); + this.setDefaultRanges( + data.defaultRanges === undefined ? this._defaultRanges : data.defaultRanges, + replace + ); this.setMagnitudeRange( data.magnitudeRange === undefined ? this._magnitudeRange : data.magnitudeRange, replace @@ -143,6 +220,9 @@ export default class VisorVariableState { arrayName: this.arrayName, type: this.type, numComponents: this.numComponents, + partIds: this.partIds, + defaultMagnitudeRange: this.defaultMagnitudeRange, + defaultRanges: this.defaultRanges, magnitudeRange: this.magnitudeRange, ranges: this.ranges, }; From 1fe527911f2c7ed26cd325c1783687939657c4b8 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 29 Sep 2026 21:39:48 -0700 Subject: [PATCH 2/8] feat: client sends variable ranges to the server and drops its own range creation --- .../visor/visor-client/src/VisorFrontend.tsx | 41 +- .../VariableRecordProjection.test.tsx | 251 ++++++++-- .../jest-tests/VisorVariableManager.test.tsx | 449 +++++------------- .../src/state/VisorSceneGraph.tsx | 14 + .../src/state/VisorVariableManager.tsx | 200 +------- 5 files changed, 363 insertions(+), 592 deletions(-) diff --git a/src/ansys/visor/visor-client/src/VisorFrontend.tsx b/src/ansys/visor/visor-client/src/VisorFrontend.tsx index 5142b311..dc8b75f5 100644 --- a/src/ansys/visor/visor-client/src/VisorFrontend.tsx +++ b/src/ansys/visor/visor-client/src/VisorFrontend.tsx @@ -65,7 +65,6 @@ export class VisorFrontend { // Respect base path prefix set by the Dash component (for reverse-proxy deployments). const bp: string = ((window as any).__visorArgs?.basePath ?? '').replace(/\/$/, ''); - const self = this; this.globalVariableCollection = variableManager.globalVariableCollection; this.#unit = ''; this.darkMode = darkMode; @@ -99,6 +98,18 @@ export class VisorFrontend { remover?.(); }; this.defaultActorColor = []; + // Reports one variable slot's range; errors are logged, not rethrown, + // since the client already applied the range locally. + this.sendVariableRangeAsync = async (variableId, component, min, max) => { + try { + await triggerSender('set_variable_range', { variableId, component, min, max }); + } catch (err) { + console.error( + `[VISOR] variable range trigger send failed: variableId='${variableId}' component=${component}`, + err + ); + } + }; this.setVariableRangeAsync = async (variableId, component, min, max) => { const variable = variableManager.globalVariableCollection.getVariable(variableId); if (variable == null) { @@ -113,6 +124,7 @@ export class VisorFrontend { await actorNode.setScalarRangeAsync(min, max); } } + await this.sendVariableRangeAsync(variableId, component, min, max); }; this.addCameraChangedListener = (callback) => { return renderer.addCameraChangedListener(callback); @@ -508,24 +520,7 @@ export class VisorFrontend { } } } - // wait for all the parts to be updated before updating the variable ranges - await Promise.all(promises); - promises.length = 0; - for (const variable_state of sceneState.getVariableStates()) { - const idStr = variable_state.id; - if (variable_state.magnitudeRange !== undefined) { - const range = variable_state.magnitudeRange; - const promise = self.setVariableRangeAsync(idStr, -1, range[0], range[1]); - promises.push(promise); - } - for (let i = 0; i < variable_state.ranges.length; i++) { - const range = variable_state.ranges[i]; - if (range !== undefined) { - const promise = self.setVariableRangeAsync(idStr, i, range[0], range[1]); - promises.push(promise); - } - } - } + // Every part is applied before the tree and the panel re-read them. await Promise.all(promises); if (updateUI) { await panelTopRightUtilPromise; @@ -580,6 +575,14 @@ export class VisorFrontend { toggleFullScreenAsync: () => Promise; addCameraChangedListener: (callback: (cameraState: VisorCameraState) => void) => () => void; globalVariableCollection: VisorVariableCollection; + /** Reports one variable slot's range to the server, -1 being the magnitude; a failed send is logged, not rethrown. */ + sendVariableRangeAsync: ( + variableId: string, + component: number, + min: number, + max: number + ) => Promise; + /** Writes a variable slot's range to the held record, applies it to every part coloured by that slot, then reports it to the server. */ setVariableRangeAsync: ( variableId: string, component: number, diff --git a/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx index 11742421..d3146bd9 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx @@ -29,8 +29,14 @@ import type VisorVtkSceneNode from '../state/appstate/vtkInfo/VisorVtkSceneNode. * delivery, after the tree has synchronized, so a delivered range reaches * the legend with no selection change. * - * Expected values are hand-written literals. The trigger sender is a double - * that absorbs every send; nothing here asserts on it. + * 4. The range trigger is sent once per edit, carrying the server's + * component convention, and never for a delivery. A delivered range + * that differs from the one a part last applied reaches that part with + * no send at all. + * + * Expected values are hand-written literals. The trigger-sender double + * absorbs every send except in the range-trigger tests, which assert its + * calls after clearing it and the renderer double at the end of their setup. * * jsdom has no `ResizeObserver` and jest here runs with no `setupFiles`, so * the stub below is this module's own. @@ -47,6 +53,7 @@ const PART_A_ID = 1; const PART_B_ID = 2; const PRESSURE_ID = 'POINT::pressure::1'; const TEMPERATURE_ID = 'POINT::temperature::1'; +const VELOCITY_ID = 'POINT::velocity::2'; /** A complete wire record for `pressure`, custom range [2, 8] inside default [0, 10]. */ function pressureRecord(overrides: Record = {}): Record { @@ -79,6 +86,27 @@ function temperatureRecord(): Record { }; } +/** A complete wire record for two-component `velocity`, listing part A. */ +function velocityRecord(): Record { + return { + id: VELOCITY_ID, + arrayName: 'velocity', + type: 'POINT', + numComponents: 2, + partIds: [PART_A_ID], + defaultMagnitudeRange: [0, 10], + defaultRanges: [ + [-1, 1], + [-2, 2], + ], + magnitudeRange: [0, 10], + ranges: [ + [-1, 1], + [-2, 2], + ], + }; +} + function makeSceneGraphNode() { return { id: 0, @@ -165,14 +193,21 @@ function makeRendererDouble() { type RendererDouble = ReturnType; -function makeFrontend(): { frontend: VisorFrontend; renderer: RendererDouble } { +type TriggerSenderDouble = jest.Mock, [string, unknown]>; + +function makeFrontend(): { + frontend: VisorFrontend; + renderer: RendererDouble; + triggerSender: TriggerSenderDouble; +} { const renderer = makeRendererDouble(); + const triggerSender = jest.fn(async () => undefined) as unknown as TriggerSenderDouble; const frontend = new VisorFrontend( renderer as unknown as IRenderer, makeSceneGraphNode() as unknown as VisorVtkSceneNode, - jest.fn(async () => undefined) + triggerSender ); - return { frontend, renderer }; + return { frontend, renderer, triggerSender }; } /** The tree-view util members the frontend and the mounted panel reach. */ @@ -430,49 +465,90 @@ describe('setAppStateAsync holds a delivered variable block', () => { }); }); -describe('the legend follows a delivery with no selection change', () => { - /** - * A real frontend holding `pressure` at [2, 8], with part A coloured by - * its magnitude and selected in the tree, and the top-right panel mounted - * on it. - */ - async function mountOnDeliveredRecord(): Promise<{ - frontend: VisorFrontend; - container: HTMLElement; - }> { - const { frontend } = makeFrontend(); - const part = frontend.sceneGraph.descendantActorNodesOrSelfDictionary[PART_A_ID]; - frontend.setTreeViewUtil(makeTreeViewUtilDouble([part])); - await frontend.setAppStateAsync( - { scene: { variableStates: { [PRESSURE_ID]: pressureRecord() } } }, - false - ); - await part.setColorVariableAsync(PRESSURE_ID, -1); +/** + * A real frontend holding one delivered record, with part A coloured by the + * given slot and selected in the tree, and the top-right panel mounted on it. + */ +async function mountOnDeliveredRecord( + record: Record, + variableId: string, + component: number +): Promise<{ + frontend: VisorFrontend; + container: HTMLElement; + triggerSender: TriggerSenderDouble; +}> { + const { frontend, triggerSender } = makeFrontend(); + const part = frontend.sceneGraph.descendantActorNodesOrSelfDictionary[PART_A_ID]; + frontend.setTreeViewUtil(makeTreeViewUtilDouble([part])); + await frontend.setAppStateAsync( + { scene: { variableStates: { [variableId]: record } } }, + false + ); + await part.setColorVariableAsync(variableId, component); - let container: HTMLElement = null!; - await act(async () => { - container = render( - {}} /> - ).container; - }); - await frontend.panelTopRightUtilPromise; - return { frontend, container }; - } - - /** - * The legend's min or max row, located by its heading: the ids in this - * component are `randomId()`-generated. - */ - function legendRow(container: HTMLElement, heading: 'Min' | 'Max') { - const label = within(container).getByText(heading, { selector: 'div' }).closest('label')!; - return { - input: label.querySelector('input') as HTMLInputElement, - reset: label.querySelector('a') as HTMLAnchorElement, - }; - } + let container: HTMLElement = null!; + await act(async () => { + container = render( + {}} /> + ).container; + }); + await frontend.panelTopRightUtilPromise; + return { frontend, container, triggerSender }; +} + +/** + * The legend's min or max row, located by its heading: the ids in this + * component are `randomId()`-generated. + */ +function legendRow(container: HTMLElement, heading: 'Min' | 'Max') { + const label = within(container).getByText(heading, { selector: 'div' }).closest('label')!; + return { + input: label.querySelector('input') as HTMLInputElement, + reset: label.querySelector('a') as HTMLAnchorElement, + }; +} + +/** Types a range into the legend and clicks Apply, then lets its handler settle. */ +async function applyRange(container: HTMLElement, min: string, max: string): Promise { + legendRow(container, 'Min').input.value = min; + legendRow(container, 'Max').input.value = max; + const apply = within(container).getByText('Apply', { selector: 'button' }); + await act(async () => { + fireEvent.click(apply); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} +/** The `set_variable_range` sends among a trigger-sender double's calls. */ +function rangeSends(triggerSender: TriggerSenderDouble): unknown[][] { + return triggerSender.mock.calls.filter(([name]) => name === 'set_variable_range'); +} + +/** A delivery holding one `pressure` record and colouring part A by its magnitude. */ +function deliveryColouringPartA(record: Record) { + return { + scene: { + variableStates: { [PRESSURE_ID]: record }, + datasetStates: { + '0': { + id: '0', + partStates: { + '1': { id: '1', variableId: PRESSURE_ID, variableComponent: -1 }, + }, + }, + }, + }, + }; +} + +describe('the legend follows a delivery with no selection change', () => { test('a delivered custom range is shown in the legend', async () => { - const { frontend, container } = await mountOnDeliveredRecord(); + const { frontend, container } = await mountOnDeliveredRecord( + pressureRecord(), + PRESSURE_ID, + -1 + ); expect(legendRow(container, 'Min').input.value).toBe('2'); expect(legendRow(container, 'Max').input.value).toBe('8'); @@ -494,7 +570,11 @@ describe('the legend follows a delivery with no selection change', () => { }); test('reset shows the delivered default range', async () => { - const { frontend, container } = await mountOnDeliveredRecord(); + const { frontend, container } = await mountOnDeliveredRecord( + pressureRecord(), + PRESSURE_ID, + -1 + ); await act(async () => { await frontend.setAppStateAsync( @@ -516,3 +596,82 @@ describe('the legend follows a delivery with no selection change', () => { expect(minRow.input.value).toBe('-1'); }); }); + +describe('the range trigger is sent for an edit and never for a delivery', () => { + test('an apply click on the magnitude sends one set_variable_range with component -1', async () => { + const { container, triggerSender } = await mountOnDeliveredRecord( + pressureRecord(), + PRESSURE_ID, + -1 + ); + triggerSender.mockClear(); + + await applyRange(container, '3', '7'); + + expect(triggerSender.mock.calls).toEqual([ + [ + 'set_variable_range', + { variableId: 'POINT::pressure::1', component: -1, min: 3, max: 7 }, + ], + ]); + }); + + test('an apply click on component 1 sends component 1', async () => { + const { container, triggerSender } = await mountOnDeliveredRecord( + velocityRecord(), + VELOCITY_ID, + 1 + ); + triggerSender.mockClear(); + + await applyRange(container, '-1', '1'); + + expect(triggerSender.mock.calls).toEqual([ + [ + 'set_variable_range', + { variableId: 'POINT::velocity::2', component: 1, min: -1, max: 1 }, + ], + ]); + }); + + test('a delivered push sends no set_variable_range', async () => { + const { frontend, triggerSender } = makeFrontend(); + + await frontend.setAppStateAsync(deliveryColouringPartA(pressureRecord()), false); + + expect(rangeSends(triggerSender)).toEqual([]); + }); + + test('a changed delivered range reaches a part already coloured by that slot, sending nothing', async () => { + const { frontend, renderer, triggerSender } = makeFrontend(); + await frontend.setAppStateAsync(deliveryColouringPartA(pressureRecord()), false); + renderer.setScalarRangeAsync.mockClear(); + renderer.sendPartColorVariableAsync.mockClear(); + triggerSender.mockClear(); + + // Magnitude moves to [3, 7]; component 0 stays at [2, 8]. + await frontend.setAppStateAsync( + deliveryColouringPartA(pressureRecord({ magnitudeRange: [3, 7] })), + false + ); + + expect(renderer.setScalarRangeAsync.mock.calls).toEqual([[1, 3, 7]]); + expect(renderer.sendPartColorVariableAsync).not.toHaveBeenCalled(); + expect(rangeSends(triggerSender)).toEqual([]); + }); + + test('a delivered range differing from the last edit reaches a part already coloured by that slot', async () => { + const { frontend, renderer, triggerSender } = makeFrontend(); + await frontend.setAppStateAsync(deliveryColouringPartA(pressureRecord()), false); + await frontend.setVariableRangeAsync(PRESSURE_ID, -1, 3, 7); + renderer.setScalarRangeAsync.mockClear(); + renderer.sendPartColorVariableAsync.mockClear(); + triggerSender.mockClear(); + + await frontend.setAppStateAsync(deliveryColouringPartA(pressureRecord()), false); + + expect(renderer.setScalarRangeAsync.mock.calls).toEqual([[1, 2, 8]]); + expect(renderer.sendPartColorVariableAsync).not.toHaveBeenCalled(); + expect(rangeSends(triggerSender)).toEqual([]); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/VisorVariableManager.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/VisorVariableManager.test.tsx index fa4a9984..539b51dc 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/VisorVariableManager.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/VisorVariableManager.test.tsx @@ -1,4 +1,3 @@ -import VisorVtkDataArray from '../state/appstate/vtkInfo/VisorVtkDataArray.tsx'; import VisorVariableState from '../state/appstate/VisorVariableState.tsx'; import { getVariableManager, VisorVariableInfo } from '../state/VisorVariableManager.tsx'; @@ -8,7 +7,7 @@ describe('getVariableManager', () => { const manager = getVariableManager(); expect(Object.isFrozen(manager)).toBe(true); - expect(typeof manager.addDataArrayMetadata).toBe('function'); + expect(typeof manager.setRecords).toBe('function'); expect(typeof manager.finishAddingDataArrayMetadata).toBe('function'); }); @@ -65,31 +64,17 @@ describe('getVariableManager', () => { }); }); - describe('addDataArrayMetadata', () => { - test('returns an empty frozen collection for an empty array', () => { - const manager = getVariableManager(); - - const collection = manager.addDataArrayMetadata([]); - - expect(collection.array).toEqual([]); - expect(Object.isFrozen(collection)).toBe(true); - expect(Object.isFrozen(collection.array)).toBe(true); - }); - + describe('setRecords', () => { test('creates scalar variable metadata', () => { - const manager = getVariableManager(); - - const collection = manager.addDataArrayMetadata([ - createDataArray({ - type: 'POINT', - name: 'temperature', - numComponents: 1, - magnitudeRange: [-20, 100], - ranges: [[-20, 100]], - }), - ]); - - const variable = collection.array[0]; + const variable = heldVariable({ + id: 'POINT::temperature::1', + arrayName: 'temperature', + numComponents: 1, + defaultMagnitudeRange: [-20, 100], + defaultRanges: [[-20, 100]], + magnitudeRange: [-20, 100], + ranges: [[-20, 100]], + }); expect(variable.id).toBe('POINT::temperature::1'); expect(variable.type).toBe('POINT'); @@ -106,15 +91,16 @@ describe('getVariableManager', () => { }); test('creates Vector2 component options', () => { - const variable = addSingleVariable( - createDataArray({ - numComponents: 2, - ranges: [ - [-1, 1], - [-2, 2], - ], - }) - ); + const ranges = [ + [-1, 1], + [-2, 2], + ]; + const variable = heldVariable({ + id: 'POINT::displacement::2', + numComponents: 2, + defaultRanges: ranges, + ranges, + }); expect(variable.shape).toBe('Vector2'); expect(variable.componentOptions).toEqual([ @@ -125,16 +111,7 @@ describe('getVariableManager', () => { }); test('creates Vector3 component options', () => { - const variable = addSingleVariable( - createDataArray({ - numComponents: 3, - ranges: [ - [-1, 1], - [-2, 2], - [-3, 3], - ], - }) - ); + const variable = heldVariable(); expect(variable.shape).toBe('Vector3'); expect(variable.componentOptions).toEqual([ @@ -146,17 +123,18 @@ describe('getVariableManager', () => { }); test('creates Vector4 component options', () => { - const variable = addSingleVariable( - createDataArray({ - numComponents: 4, - ranges: [ - [-1, 1], - [-2, 2], - [-3, 3], - [-4, 4], - ], - }) - ); + const ranges = [ + [-1, 1], + [-2, 2], + [-3, 3], + [-4, 4], + ]; + const variable = heldVariable({ + id: 'POINT::displacement::4', + numComponents: 4, + defaultRanges: ranges, + ranges, + }); expect(variable.shape).toBe('Vector4'); expect(variable.componentOptions).toEqual([ @@ -169,12 +147,14 @@ describe('getVariableManager', () => { }); test('creates nine-component tensor labels', () => { - const variable = addSingleVariable( - createDataArray({ - numComponents: 9, - ranges: Array.from({ length: 9 }, (_, i) => [-i, i]), - }) - ); + const ranges = Array.from({ length: 9 }, (_, i) => [-i, i]); + const variable = heldVariable({ + id: 'POINT::stress::9', + arrayName: 'stress', + numComponents: 9, + defaultRanges: ranges, + ranges, + }); expect(variable.componentOptions).toEqual([ { id: -1, name: 'Magnitude' }, @@ -192,102 +172,61 @@ describe('getVariableManager', () => { test('throws for an unsupported component count', () => { const manager = getVariableManager(); + const ranges = [ + [0, 1], + [0, 1], + [0, 1], + [0, 1], + [0, 1], + ]; expect(() => { - manager.addDataArrayMetadata([ - createDataArray({ + manager.setRecords([ + record({ + id: 'POINT::displacement::5', numComponents: 5, - ranges: [ - [0, 1], - [0, 1], - [0, 1], - [0, 1], - [0, 1], - ], + defaultRanges: ranges, + ranges, }), ]); - }).toThrow('Do we support label info for data arrays with 5 component(s)?'); + }).toThrow('Do we support label info for variables with 5 component(s)?'); }); test('returns variables in input order', () => { const manager = getVariableManager(); - const collection = manager.addDataArrayMetadata([ - createDataArray({ - name: 'first', - }), - createDataArray({ - name: 'second', - }), - ]); - - expect(collection.array.map((item) => item.name)).toEqual(['first', 'second']); - }); - - test('creates separate variables for different names', () => { - const manager = getVariableManager(); - - const collection = manager.addDataArrayMetadata([ - createDataArray({ - name: 'temperature', - }), - createDataArray({ - name: 'pressure', - }), + manager.setRecords([ + record({ id: 'POINT::first::3', arrayName: 'first' }), + record({ id: 'POINT::second::3', arrayName: 'second' }), ]); + manager.finishAddingDataArrayMetadata(); - expect(collection.array).toHaveLength(2); - expect(collection.array[0]).not.toBe(collection.array[1]); - }); - - test('creates separate variables for different types', () => { - const manager = getVariableManager(); - - const collection = manager.addDataArrayMetadata([ - createDataArray({ - type: 'POINT', - }), - createDataArray({ - type: 'CELL', - }), + expect(manager.globalVariableCollection.array.map((item) => item.name)).toEqual([ + 'first', + 'second', ]); - - expect(collection.array).toHaveLength(2); - expect(collection.array[0].id).toBe('POINT::displacement::3'); - expect(collection.array[1].id).toBe('CELL::displacement::3'); }); - test('creates separate variables for different component counts', () => { + test('reuses the same variable object for the same ID', () => { const manager = getVariableManager(); + manager.finishAddingDataArrayMetadata(); - const collection = manager.addDataArrayMetadata([ - createDataArray({ - numComponents: 2, - ranges: [ - [-1, 1], - [-2, 2], - ], - }), - createDataArray({ - numComponents: 3, - ranges: [ - [-1, 1], - [-2, 2], - [-3, 3], - ], - }), - ]); + manager.setRecords([record()]); + const first = manager.globalVariableCollection.getVariable('POINT::displacement::3'); + manager.setRecords([record({ magnitudeRange: [1, 9] })]); - expect(collection.array).toHaveLength(2); - expect(collection.array[0].id).toBe('POINT::displacement::2'); - expect(collection.array[1].id).toBe('POINT::displacement::3'); + expect(first).not.toBeNull(); + expect(manager.globalVariableCollection.getVariable('POINT::displacement::3')).toBe( + first + ); }); }); describe('local variable collections', () => { test('looks up a variable by ID', () => { const manager = getVariableManager(); - const collection = manager.addDataArrayMetadata([createDataArray()]); + const collection = manager.getPartVariableCollection(1); + manager.setRecords([record()]); const variable = collection.array[0]; @@ -296,65 +235,23 @@ describe('getVariableManager', () => { test('returns null for null and unknown IDs', () => { const manager = getVariableManager(); - const collection = manager.addDataArrayMetadata([createDataArray()]); + const collection = manager.getPartVariableCollection(1); + manager.setRecords([record()]); expect(collection.getVariable(null)).toBeNull(); expect(collection.getVariable('unknown')).toBeNull(); }); - test('returns a frozen collection and array', () => { - const manager = getVariableManager(); - const collection = manager.addDataArrayMetadata([createDataArray()]); - - expect(Object.isFrozen(collection)).toBe(true); - expect(Object.isFrozen(collection.array)).toBe(true); - }); - test('variable metadata objects are frozen', () => { - const variable = addSingleVariable(createDataArray()); + const variable = heldVariable(); expect(Object.isFrozen(variable)).toBe(true); }); }); describe('range information', () => { - test('returns the magnitude range for component -1', () => { - const variable = addSingleVariable( - createDataArray({ - magnitudeRange: [0, 10], - }) - ); - - expect(variable.getRangeInfo(-1)).toEqual({ - defaultRange: [0, 10], - customRange: [0, 10], - }); - }); - - test('returns the range for an individual component', () => { - const variable = addSingleVariable( - createDataArray({ - ranges: [ - [-1, 1], - [-2, 2], - [-3, 3], - ], - }) - ); - - expect(variable.getRangeInfo(0)).toEqual({ - defaultRange: [-1, 1], - customRange: [-1, 1], - }); - - expect(variable.getRangeInfo(2)).toEqual({ - defaultRange: [-3, 3], - customRange: [-3, 3], - }); - }); - test('returns null for null, undefined, and out-of-range components', () => { - const variable = addSingleVariable(createDataArray()); + const variable = heldVariable(); expect(variable.getRangeInfo(null)).toBeNull(); expect(variable.getRangeInfo(undefined)).toBeNull(); @@ -364,11 +261,7 @@ describe('getVariableManager', () => { }); test('returns cloned range arrays', () => { - const variable = addSingleVariable( - createDataArray({ - magnitudeRange: [0, 10], - }) - ); + const variable = heldVariable(); const first = variable.getRangeInfo(-1)!; first.defaultRange[0] = -999; @@ -383,15 +276,7 @@ describe('getVariableManager', () => { }); test('setCustomRange changes only the custom range', () => { - const variable = addSingleVariable( - createDataArray({ - ranges: [ - [-1, 1], - [-2, 2], - [-3, 3], - ], - }) - ); + const variable = heldVariable(); variable.setCustomRange(1, -20, 20); @@ -402,11 +287,7 @@ describe('getVariableManager', () => { }); test('setCustomRange can change the magnitude range', () => { - const variable = addSingleVariable( - createDataArray({ - magnitudeRange: [0, 10], - }) - ); + const variable = heldVariable(); variable.setCustomRange(-1, 2, 8); @@ -417,7 +298,7 @@ describe('getVariableManager', () => { }); test('setCustomRange ignores invalid component IDs', () => { - const variable = addSingleVariable(createDataArray()); + const variable = heldVariable(); variable.setCustomRange(-2, -100, 100); variable.setCustomRange(100, -100, 100); @@ -429,116 +310,11 @@ describe('getVariableManager', () => { }); }); - describe('duplicate variable aggregation', () => { - test('reuses the same variable object for the same ID', () => { - const manager = getVariableManager(); - - const firstCollection = manager.addDataArrayMetadata([createDataArray()]); - - const secondCollection = manager.addDataArrayMetadata([createDataArray()]); - - expect(secondCollection.array[0]).toBe(firstCollection.array[0]); - }); - - test('expands default ranges using duplicate metadata', () => { - const manager = getVariableManager(); - - const firstVariable = manager.addDataArrayMetadata([ - createDataArray({ - magnitudeRange: [0, 10], - ranges: [ - [-1, 1], - [-2, 2], - [-3, 3], - ], - }), - ]).array[0]; - - manager.addDataArrayMetadata([ - createDataArray({ - magnitudeRange: [-5, 20], - ranges: [ - [-10, 0.5], - [-1, 15], - [-30, 30], - ], - }), - ]); - - expect(firstVariable.getRangeInfo(-1)).toEqual({ - defaultRange: [-5, 20], - customRange: [-5, 20], - }); - - expect(firstVariable.getRangeInfo(0)).toEqual({ - defaultRange: [-10, 1], - customRange: [-10, 1], - }); - - expect(firstVariable.getRangeInfo(1)).toEqual({ - defaultRange: [-2, 15], - customRange: [-2, 15], - }); - - expect(firstVariable.getRangeInfo(2)).toEqual({ - defaultRange: [-30, 30], - customRange: [-30, 30], - }); - }); - - test('resets custom ranges to the expanded defaults when duplicate metadata is added', () => { - const manager = getVariableManager(); - - const variable = manager.addDataArrayMetadata([ - createDataArray({ - magnitudeRange: [0, 10], - }), - ]).array[0]; - - variable.setCustomRange(-1, 2, 8); - - expect(variable.getRangeInfo(-1)?.customRange).toEqual([2, 8]); - - manager.addDataArrayMetadata([ - createDataArray({ - magnitudeRange: [-5, 20], - }), - ]); - - expect(variable.getRangeInfo(-1)).toEqual({ - defaultRange: [-5, 20], - customRange: [-5, 20], - }); - }); - - test('keeps existing bounds when duplicate ranges are narrower', () => { - const manager = getVariableManager(); - - const variable = manager.addDataArrayMetadata([ - createDataArray({ - magnitudeRange: [-10, 20], - }), - ]).array[0]; - - manager.addDataArrayMetadata([ - createDataArray({ - magnitudeRange: [-5, 10], - }), - ]); - - expect(variable.getRangeInfo(-1)).toEqual({ - defaultRange: [-10, 20], - customRange: [-10, 20], - }); - }); - }); - describe('global variable collection', () => { - test('returns null for null and unknown global IDs', () => { const manager = getVariableManager(); - manager.addDataArrayMetadata([createDataArray()]); + manager.setRecords([record()]); manager.finishAddingDataArrayMetadata(); expect(manager.globalVariableCollection.getVariable(null)).toBeNull(); @@ -556,39 +332,40 @@ describe('getVariableManager', () => { }); }); -interface DataArrayOptions { - indexForType?: number; - type?: string; - name?: string; - numComponents?: number; - magnitudeRange?: number[]; - ranges?: number[][]; -} - -function createDataArray({ - indexForType = 0, - type = 'POINT', - name = 'displacement', - numComponents = 3, - magnitudeRange = [0, 10], - ranges = [ - [-1, 1], - [-2, 2], - [-3, 3], - ], -}: DataArrayOptions = {}): VisorVtkDataArray { - return new VisorVtkDataArray({ - indexForType, - type, - name, - numComponents, - magnitudeRange, - ranges, +/** + * A complete record, by default `POINT::displacement::3` on part 1 with + * magnitude [0, 10] and components [-1, 1], [-2, 2], [-3, 3], custom equal to + * default. + */ +function record(overrides: Record = {}): VisorVariableState { + return new VisorVariableState({ + id: 'POINT::displacement::3', + arrayName: 'displacement', + type: 'POINT', + numComponents: 3, + partIds: [1], + defaultMagnitudeRange: [0, 10], + defaultRanges: [ + [-1, 1], + [-2, 2], + [-3, 3], + ], + magnitudeRange: [0, 10], + ranges: [ + [-1, 1], + [-2, 2], + [-3, 3], + ], + ...overrides, }); } -function addSingleVariable(dataArray: VisorVtkDataArray): VisorVariableInfo { +/** The one variable a fresh manager holds after delivering a single record. */ +function heldVariable(overrides: Record = {}): VisorVariableInfo { const manager = getVariableManager(); + manager.setRecords([record(overrides)]); + manager.finishAddingDataArrayMetadata(); - return manager.addDataArrayMetadata([dataArray]).array[0]; + return manager.globalVariableCollection.array[0]; } + diff --git a/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx b/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx index 309f2cdd..3d94363d 100644 --- a/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx +++ b/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx @@ -45,7 +45,9 @@ export type VisorSceneNodeExtended = Readonly<{ setSelectedAsync: (selected: boolean) => Promise; setOpacityAsync: (opacity: number) => Promise; clearColorVariableAsync: () => Promise; + /** Colours the part by a variable slot at its held range; on the slot already applied, re-applies only a changed range and sends nothing. */ setColorVariableAsync: (id: string, component?: number | null) => Promise; + /** Applies a scalar range to the part and records it as the part's applied range; sends nothing. */ setScalarRangeAsync: (min: number, max: number) => Promise; variableCollection: VisorVariableCollection; variableId: string | null; @@ -195,6 +197,16 @@ export const CreateVisorSceneGraph = (() => { }, async setColorVariableAsync(id, component) { if (_variableId === id && _variableComponent === component) { + // Already coloured by this slot: only a held range that + // differs from the one last applied reaches the actor. + const held = variableCollection.getVariable(id)?.getRangeInfo(component); + if (held == null) { + return; + } + const [heldMin, heldMax] = held.customRange; + if (heldMin !== _variableMin || heldMax !== _variableMax) { + await node.setScalarRangeAsync(heldMin, heldMax); + } return; } else if (component == null) { return; @@ -225,6 +237,8 @@ export const CreateVisorSceneGraph = (() => { await renderer!.sendPartColorVariableAsync(nodeId, descriptor); }, async setScalarRangeAsync(min, max) { + _variableMin = min; + _variableMax = max; await renderer!.setScalarRangeAsync(nodeId, min, max); }, async setVisibilityAsync(visible) { diff --git a/src/ansys/visor/visor-client/src/state/VisorVariableManager.tsx b/src/ansys/visor/visor-client/src/state/VisorVariableManager.tsx index 5f7bfa1a..d993d593 100644 --- a/src/ansys/visor/visor-client/src/state/VisorVariableManager.tsx +++ b/src/ansys/visor/visor-client/src/state/VisorVariableManager.tsx @@ -1,4 +1,4 @@ -import VisorVtkDataArray, { FieldAssociation } from './appstate/vtkInfo/VisorVtkDataArray.tsx'; +import { FieldAssociation } from './appstate/vtkInfo/VisorVtkDataArray.tsx'; import type VisorVariableState from './appstate/VisorVariableState.tsx'; /** @@ -60,7 +60,7 @@ export type VisorVariableInfo = Readonly<{ * missing or outside the supported range. */ getRangeInfo: (component: number | null | undefined) => null | { - /** Aggregate range calculated from all matching data arrays. */ + /** Delivered default range of the component. */ defaultRange: number[]; /** User-configurable range for the selected component. */ @@ -97,29 +97,16 @@ export type VisorVariableCollection = Readonly<{ }>; /** - * Coordinates variable metadata across multiple groups of VTK data arrays. + * Holds the delivered variable records and projects them as variables. * * @remarks - * Call {@link VisorVariableManager.addDataArrayMetadata} for every relevant - * group of arrays, then call - * {@link VisorVariableManager.finishAddingDataArrayMetadata} once. The global - * collection is unavailable until finalization is complete. + * {@link VisorVariableManager.setRecords} replaces the held records on every + * delivery. Call {@link VisorVariableManager.finishAddingDataArrayMetadata} + * once before reading the global collection. */ export type VisorVariableManager = Readonly<{ /** - * Adds metadata for a group of VTK data arrays. - * - * @param dataArrays - Data arrays from which variable metadata is derived. - * @returns A collection containing one variable for each supplied data array. - * - * @remarks - * Arrays with the same type, name, and component count share a global - * variable. Their default ranges are expanded to include all observed values. - */ - addDataArrayMetadata: (dataArrays: VisorVtkDataArray[]) => VisorVariableCollection; - - /** - * Finalizes the global variable collection. + * Opens the global collection for reading. * * @throws Error if this method has already been called. */ @@ -256,23 +243,14 @@ class HeldVariableRecord { } /** - * Creates a variable manager for aggregating metadata from VTK data arrays. + * Creates a variable manager holding delivered variable records. * - * @returns A new variable manager with no registered variables. + * @returns A new variable manager with no held records. */ export function getVariableManager(): VisorVariableManager { /** Tracks variable IDs and their assigned lookup positions. */ const variableIdLookup: Map = new Map(); - /** Stores each globally unique variable by its ID. */ - const globalVariableMap: Map = new Map(); - - /** Stores aggregate default ranges for each variable. */ - const globalDefaultRanges: Map = new Map(); - - /** Stores user-configurable ranges for each variable. */ - const globalCustomRanges: Map = new Map(); - /** Maps supported component counts to shape and component-label metadata. */ const labelInfoMap: Map< number, @@ -341,8 +319,6 @@ export function getVariableManager(): VisorVariableManager { }); return Object.freeze({ - addDataArrayMetadata, - /** * Opens the global collection for reading. * @@ -444,162 +420,4 @@ export function getVariableManager(): VisorVariableManager { }, }); } - - /** - * Creates or updates variable information for a data array. - * - * @param dataArray - Source data array metadata. - * @returns The newly created variable, or the existing compatible variable. - * - * @remarks - * When a compatible variable already exists, its default ranges are expanded - * to include the new array's ranges. Its custom ranges are then reset to the - * updated defaults. - * - * @throws Error if the data array has an unsupported component count. - */ - function tryAddVariableInfo(dataArray: VisorVtkDataArray): VisorVariableInfo { - const { type, name, numComponents, magnitudeRange, ranges } = dataArray; - - // Use a human-readable ID like 'point::displacement::3' - const id = `${type}::${name}::${numComponents}`; - - if (globalVariableMap.has(id)) { - // Update the existing ranges for this variable - // with each subsequent new set of ranges. - const defaultRanges = globalDefaultRanges.get(id)!; - const customRanges = globalCustomRanges.get(id)!; - - magnitudeRange[0] < defaultRanges[0][0] && (defaultRanges[0][0] = magnitudeRange[0]); - magnitudeRange[1] > defaultRanges[0][1] && (defaultRanges[0][1] = magnitudeRange[1]); - - ranges.forEach((range, i) => { - range[0] < defaultRanges[i + 1][0] && (defaultRanges[i + 1][0] = range[0]); - range[1] > defaultRanges[i + 1][1] && (defaultRanges[i + 1][1] = range[1]); - }); - - customRanges.forEach((range, i) => { - range[0] = defaultRanges[i][0]; - range[1] = defaultRanges[i][1]; - }); - - return globalVariableMap.get(id)!; - } - - const labelInfo = labelInfoMap.get(numComponents); - if (labelInfo == null) { - const msg = `No label info was found for this data array. Do we support label info`; - throw new Error(`${msg} for data arrays with ${numComponents} component(s)?`); - } - - const componentOptions: VisorVariableComponentMetadata[] = []; - for (let i = 0; i < labelInfo.componentLabels.length; i++) { - componentOptions.push({ - id: i - 1, - name: labelInfo.componentLabels[i], - }); - } - - const [defaultRanges, customRanges] = (() => { - const arr: number[][] = []; - const arrClone: number[][] = []; - - arr.push([magnitudeRange[0], magnitudeRange[1]]); - arrClone.push([magnitudeRange[0], magnitudeRange[1]]); - - for (let i = 0; i < numComponents; i++) { - arr.push([ranges[i][0], ranges[i][1]]); - arrClone.push([ranges[i][0], ranges[i][1]]); - } - - return [arr, arrClone]; - })(); - - globalDefaultRanges.set(id, defaultRanges); - globalCustomRanges.set(id, customRanges); - - const info: VisorVariableInfo = Object.freeze({ - id, - type, - name, - shape: labelInfo.shape, - fullName: `${type} - ${name} (${labelInfo.shape})`, - numComponents, - componentOptions, - - /** - * Gets cloned range information for a component. - * - * @param component - Component index, with `-1` representing magnitude. - * @returns Range information, or `null` for an invalid component. - */ - getRangeInfo: (component) => { - if (component == null) { - return null; - } - - const i = component + 1; // Add 1 because "magnitude" occupies index 0. - - return i >= 0 && i < customRanges.length - ? { - // Clone the arrays so they cannot be directly modified by the user. - defaultRange: [...defaultRanges[i]], - customRange: [...customRanges[i]], - } - : null; - }, - - /** - * Changes the custom range for a component. - * - * @param component - Component index, with `-1` representing magnitude. - * @param min - New minimum range value. - * @param max - New maximum range value. - */ - setCustomRange: (component, min, max) => { - const i = component + 1; // Add 1 because "magnitude" occupies index 0. - - if (i >= 0 && i < customRanges.length) { - customRanges[i][0] = min; - customRanges[i][1] = max; - } - }, - }); - - globalVariableMap.set(id, info); - return info; - } - - /** - * Registers a group of data arrays and creates its local variable collection. - * - * @param dataArrays - Data arrays to register. - * @returns An immutable collection containing variables for the supplied arrays. - */ - function addDataArrayMetadata(dataArrays: VisorVtkDataArray[]): VisorVariableCollection { - const array: VisorVariableInfo[] = []; - const map: Map = new Map(); - - for (let i = 0; i < dataArrays.length; i++) { - const info = tryAddVariableInfo(dataArrays[i]); - array.push(info); - map.set(info.id, info); - } - - Object.freeze(array); - - return Object.freeze({ - array, - - /** - * Finds a variable within this local collection. - * - * @param id - Variable identifier, or `null`. - * @returns The matching variable, or `null` when none exists. - */ - getVariable(id: string | null) { - return id != null ? (map.get(id) ?? null) : null; - }, - }); - } } From b241a9a807392c38ac397d2d87f3e440194bbee7 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 11:21:28 -0700 Subject: [PATCH 3/8] pre-commit fix --- .../src/jest-tests/VariableRecordProjection.test.tsx | 9 ++------- .../src/jest-tests/VisorVariableManager.test.tsx | 1 - 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx index d3146bd9..584b40e0 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx @@ -481,17 +481,12 @@ async function mountOnDeliveredRecord( const { frontend, triggerSender } = makeFrontend(); const part = frontend.sceneGraph.descendantActorNodesOrSelfDictionary[PART_A_ID]; frontend.setTreeViewUtil(makeTreeViewUtilDouble([part])); - await frontend.setAppStateAsync( - { scene: { variableStates: { [variableId]: record } } }, - false - ); + await frontend.setAppStateAsync({ scene: { variableStates: { [variableId]: record } } }, false); await part.setColorVariableAsync(variableId, component); let container: HTMLElement = null!; await act(async () => { - container = render( - {}} /> - ).container; + container = render( {}} />).container; }); await frontend.panelTopRightUtilPromise; return { frontend, container, triggerSender }; diff --git a/src/ansys/visor/visor-client/src/jest-tests/VisorVariableManager.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/VisorVariableManager.test.tsx index 539b51dc..eb1841dc 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/VisorVariableManager.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/VisorVariableManager.test.tsx @@ -368,4 +368,3 @@ function heldVariable(overrides: Record = {}): VisorVariableInf return manager.globalVariableCollection.array[0]; } - From 77400e7bcedfc741c5aff8a17b7132408ea45f8d Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:16:11 +0000 Subject: [PATCH 4/8] chore: adding changelog file 153.added.md [dependabot-skip] --- doc/changelog.d/153.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/153.added.md diff --git a/doc/changelog.d/153.added.md b/doc/changelog.d/153.added.md new file mode 100644 index 00000000..e371a444 --- /dev/null +++ b/doc/changelog.d/153.added.md @@ -0,0 +1 @@ +[Remote rendering 3.5b] client uses server owned variables From fbcf9f9a48f2fa266e73d20a08a7132de826d6b7 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Fri, 2 Oct 2026 14:27:32 -0700 Subject: [PATCH 5/8] reuse lut when already exists --- src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts index db877e19..276d76a0 100644 --- a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts @@ -386,8 +386,11 @@ export class WasmRenderer implements IRenderer { await wasmMapper.ColorByArrayComponent(descriptor.variableName, descriptor.component); await wasmMapper.SetScalarVisibility(1); // Force creation of LUT if not already done. (Alternatively, after 9.5.20250802.dev0, you can call mapper.SetLookupTable(null)) - await wasmMapper.CreateDefaultLookupTable(); - const lut = await wasmMapper.GetLookupTable(); + let lut = await wasmMapper.GetLookupTable(); + if (lut == null || !lut.id) { + await wasmMapper.CreateDefaultLookupTable(); + const lut = await wasmMapper.GetLookupTable(); + } await lut.SetHueRange(0.667, 0.0); await lut.SetVectorModeToMagnitude(); } From afd905b1a2601592246668fa4036cd96ebaa31d6 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Fri, 2 Oct 2026 14:46:31 -0700 Subject: [PATCH 6/8] update jest test for reusing mapper's LUT --- .../WasmRendererPartTriggers.test.tsx | 55 +++++++++++++++---- .../visor-client/src/renderer/WasmRenderer.ts | 5 +- 2 files changed, 48 insertions(+), 12 deletions(-) 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 index e33b6856..b6baf16f 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/WasmRendererPartTriggers.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/WasmRendererPartTriggers.test.tsx @@ -61,6 +61,8 @@ function makeAnnotation(): WasmRendererAnnotation { function makeFakeWasmObjects() { const lut = { + // A non-zero id marks this as a LUT the mapper already owns. + id: 301, SetHueRange: jest.fn(async () => undefined), SetVectorModeToMagnitude: jest.fn(async () => undefined), }; @@ -332,28 +334,61 @@ describe('WasmRenderer per-part applies still mutate their wasm objects', () => expect(property.SetDiffuseColor).toHaveBeenCalledWith(1, 0, 0); }); - test('setColorVariableAsync still configures the mapper and the default table', async () => { + const POINT_PRESSURE = { + variableId: 'POINT::pressure::1', + variableType: 'POINT' as const, + variableName: 'pressure', + component: 0, + min: 2, + max: 8, + }; + + test('setColorVariableAsync still configures the mapper and the lookup table', async () => { const { renderer, mapper, lut } = await makeRenderer(makeSender()); - await renderer.setColorVariableAsync(NODE_ID, { - variableId: 'POINT::pressure::1', - variableType: 'POINT', - variableName: 'pressure', - component: 0, - min: 2, - max: 8, - }); + await renderer.setColorVariableAsync(NODE_ID, POINT_PRESSURE); 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('setColorVariableAsync reuses the mapper LUT when one already exists', async () => { + // Recreating the default table would discard LUT state that already + // exists, so an existing table must be configured in place. + const { renderer, mapper, lut } = await makeRenderer(makeSender()); + + await renderer.setColorVariableAsync(NODE_ID, POINT_PRESSURE); + + expect(mapper.CreateDefaultLookupTable).not.toHaveBeenCalled(); + expect(mapper.GetLookupTable).toHaveBeenCalledTimes(1); + expect(lut.SetHueRange).toHaveBeenCalledWith(0.667, 0.0); + expect(lut.SetVectorModeToMagnitude).toHaveBeenCalled(); + }); + + test.each([ + ['null', null], + ['a LUT with no id', { id: 0 }], + ])( + 'setColorVariableAsync creates and configures the default LUT when the mapper returns %s', + async (_label, missingLut) => { + const { renderer, mapper, lut } = await makeRenderer(makeSender()); + mapper.GetLookupTable.mockResolvedValueOnce(missingLut as unknown as typeof lut); + + await renderer.setColorVariableAsync(NODE_ID, POINT_PRESSURE); + + expect(mapper.CreateDefaultLookupTable).toHaveBeenCalledTimes(1); + expect(mapper.GetLookupTable).toHaveBeenCalledTimes(2); + // The table fetched after creation is the one configured. + 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()); diff --git a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts index 276d76a0..8457d2d0 100644 --- a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts @@ -385,11 +385,12 @@ export class WasmRenderer implements IRenderer { await wasmMapper.SetColorModeToMapScalars(); await wasmMapper.ColorByArrayComponent(descriptor.variableName, descriptor.component); await wasmMapper.SetScalarVisibility(1); - // Force creation of LUT if not already done. (Alternatively, after 9.5.20250802.dev0, you can call mapper.SetLookupTable(null)) + // Reuse the mapper's LUT if it already has one; otherwise force creation of the default. + // (Alternatively, after 9.5.20250802.dev0, you can call mapper.SetLookupTable(null)) let lut = await wasmMapper.GetLookupTable(); if (lut == null || !lut.id) { await wasmMapper.CreateDefaultLookupTable(); - const lut = await wasmMapper.GetLookupTable(); + lut = await wasmMapper.GetLookupTable(); } await lut.SetHueRange(0.667, 0.0); await lut.SetVectorModeToMagnitude(); From 7720a9993fb728d0ce777948eb6d4ae0dcd53efb Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Mon, 5 Oct 2026 07:33:27 -0700 Subject: [PATCH 7/8] Revert "update jest test for reusing mapper's LUT" This reverts commit afd905b1a2601592246668fa4036cd96ebaa31d6. --- .../WasmRendererPartTriggers.test.tsx | 55 ++++--------------- .../visor-client/src/renderer/WasmRenderer.ts | 5 +- 2 files changed, 12 insertions(+), 48 deletions(-) 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 index b6baf16f..e33b6856 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/WasmRendererPartTriggers.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/WasmRendererPartTriggers.test.tsx @@ -61,8 +61,6 @@ function makeAnnotation(): WasmRendererAnnotation { function makeFakeWasmObjects() { const lut = { - // A non-zero id marks this as a LUT the mapper already owns. - id: 301, SetHueRange: jest.fn(async () => undefined), SetVectorModeToMagnitude: jest.fn(async () => undefined), }; @@ -334,61 +332,28 @@ describe('WasmRenderer per-part applies still mutate their wasm objects', () => expect(property.SetDiffuseColor).toHaveBeenCalledWith(1, 0, 0); }); - const POINT_PRESSURE = { - variableId: 'POINT::pressure::1', - variableType: 'POINT' as const, - variableName: 'pressure', - component: 0, - min: 2, - max: 8, - }; - - test('setColorVariableAsync still configures the mapper and the lookup table', async () => { + test('setColorVariableAsync still configures the mapper and the default table', async () => { const { renderer, mapper, lut } = await makeRenderer(makeSender()); - await renderer.setColorVariableAsync(NODE_ID, POINT_PRESSURE); + await renderer.setColorVariableAsync(NODE_ID, { + variableId: 'POINT::pressure::1', + variableType: 'POINT', + variableName: '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('setColorVariableAsync reuses the mapper LUT when one already exists', async () => { - // Recreating the default table would discard LUT state that already - // exists, so an existing table must be configured in place. - const { renderer, mapper, lut } = await makeRenderer(makeSender()); - - await renderer.setColorVariableAsync(NODE_ID, POINT_PRESSURE); - - expect(mapper.CreateDefaultLookupTable).not.toHaveBeenCalled(); - expect(mapper.GetLookupTable).toHaveBeenCalledTimes(1); - expect(lut.SetHueRange).toHaveBeenCalledWith(0.667, 0.0); - expect(lut.SetVectorModeToMagnitude).toHaveBeenCalled(); - }); - - test.each([ - ['null', null], - ['a LUT with no id', { id: 0 }], - ])( - 'setColorVariableAsync creates and configures the default LUT when the mapper returns %s', - async (_label, missingLut) => { - const { renderer, mapper, lut } = await makeRenderer(makeSender()); - mapper.GetLookupTable.mockResolvedValueOnce(missingLut as unknown as typeof lut); - - await renderer.setColorVariableAsync(NODE_ID, POINT_PRESSURE); - - expect(mapper.CreateDefaultLookupTable).toHaveBeenCalledTimes(1); - expect(mapper.GetLookupTable).toHaveBeenCalledTimes(2); - // The table fetched after creation is the one configured. - 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()); diff --git a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts index 8457d2d0..276d76a0 100644 --- a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts @@ -385,12 +385,11 @@ export class WasmRenderer implements IRenderer { await wasmMapper.SetColorModeToMapScalars(); await wasmMapper.ColorByArrayComponent(descriptor.variableName, descriptor.component); await wasmMapper.SetScalarVisibility(1); - // Reuse the mapper's LUT if it already has one; otherwise force creation of the default. - // (Alternatively, after 9.5.20250802.dev0, you can call mapper.SetLookupTable(null)) + // Force creation of LUT if not already done. (Alternatively, after 9.5.20250802.dev0, you can call mapper.SetLookupTable(null)) let lut = await wasmMapper.GetLookupTable(); if (lut == null || !lut.id) { await wasmMapper.CreateDefaultLookupTable(); - lut = await wasmMapper.GetLookupTable(); + const lut = await wasmMapper.GetLookupTable(); } await lut.SetHueRange(0.667, 0.0); await lut.SetVectorModeToMagnitude(); From c74c90822536400e1eef8e09d6191e8147678cc2 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Mon, 5 Oct 2026 07:33:37 -0700 Subject: [PATCH 8/8] Revert "reuse lut when already exists" This reverts commit fbcf9f9a48f2fa266e73d20a08a7132de826d6b7. --- src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts index 276d76a0..db877e19 100644 --- a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts @@ -386,11 +386,8 @@ export class WasmRenderer implements IRenderer { await wasmMapper.ColorByArrayComponent(descriptor.variableName, descriptor.component); await wasmMapper.SetScalarVisibility(1); // Force creation of LUT if not already done. (Alternatively, after 9.5.20250802.dev0, you can call mapper.SetLookupTable(null)) - let lut = await wasmMapper.GetLookupTable(); - if (lut == null || !lut.id) { - await wasmMapper.CreateDefaultLookupTable(); - const lut = await wasmMapper.GetLookupTable(); - } + await wasmMapper.CreateDefaultLookupTable(); + const lut = await wasmMapper.GetLookupTable(); await lut.SetHueRange(0.667, 0.0); await lut.SetVectorModeToMagnitude(); }