diff --git a/doc/changelog.d/171.maintenance.md b/doc/changelog.d/171.maintenance.md new file mode 100644 index 00000000..c3799d9f --- /dev/null +++ b/doc/changelog.d/171.maintenance.md @@ -0,0 +1 @@ +[Remote rendering 3.6d] Deliver server state to the browser without sending it back diff --git a/src/ansys/visor/viewer/app/trame/local_app.py b/src/ansys/visor/viewer/app/trame/local_app.py index 0d5e153d..5dd27b96 100644 --- a/src/ansys/visor/viewer/app/trame/local_app.py +++ b/src/ansys/visor/viewer/app/trame/local_app.py @@ -629,7 +629,7 @@ def set_panel_top_right_tab_index(self, payload) -> None: """Frontend -> Backend: the top-right panel reports its active tab. The index is forwarded verbatim and is never range-checked here; the - client's ``selectTab`` is the guard, so a future third tab is a UI + client's ``applyTabIndex`` is the guard, so a future third tab is a UI change rather than a validation failure at this boundary. """ api = self._mutation_api("set_panel_top_right_tab_index", payload) diff --git a/src/ansys/visor/viewer/models/runtime/requests/widget_state_payloads.py b/src/ansys/visor/viewer/models/runtime/requests/widget_state_payloads.py index 2f757b3c..5613f027 100644 --- a/src/ansys/visor/viewer/models/runtime/requests/widget_state_payloads.py +++ b/src/ansys/visor/viewer/models/runtime/requests/widget_state_payloads.py @@ -25,7 +25,7 @@ toggle's. ``tab_index`` is deliberately unbounded: the client only ever sends ``0`` -or ``1``, and ``Panel_TopRight_Util.selectTab`` already ignores anything +or ``1``, and ``Panel_TopRight_Util.applyTabIndex`` already ignores anything else, so the bound lives there, not here. """ diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index 16ab53bb..6e5350a7 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -1049,7 +1049,7 @@ def set_panel_top_right_tab_index(self, tab_index: int) -> None: """Record which top-right tab is active. The value is stored opaquely and is never interpreted here; it is - only ever handed back to the client's ``selectTab``, which is where + only ever handed back to the client's ``applyTabIndex``, which is where the range guard lives. """ with self._vtk_lock: diff --git a/src/ansys/visor/visor-client/src/VisorFrontend.tsx b/src/ansys/visor/visor-client/src/VisorFrontend.tsx index 3a8f5974..eafd0f30 100644 --- a/src/ansys/visor/visor-client/src/VisorFrontend.tsx +++ b/src/ansys/visor/visor-client/src/VisorFrontend.tsx @@ -363,34 +363,22 @@ export class VisorFrontend { void (async () => { const panelTopLeft = await panelTopLeftUtilPromise; if (uiState.panelTopLeftPanelCollapsed !== undefined) { - if (uiState.panelTopLeftPanelCollapsed) { - panelTopLeft.collapsePanel(); - } else { - panelTopLeft.expandPanel(); - } + panelTopLeft.applyPanelCollapsed(uiState.panelTopLeftPanelCollapsed); } })(); void (async () => { const panelTopRight = await panelTopRightUtilPromise; if (uiState.panelTopRightPanelCollapsed !== undefined) { - if (uiState.panelTopRightPanelCollapsed) { - panelTopRight.collapsePanel(); - } else { - panelTopRight.expandPanel(); - } + panelTopRight.applyPanelCollapsed(uiState.panelTopRightPanelCollapsed); } if (uiState.panelTopRightLegendCollapsed !== undefined) { - if (uiState.panelTopRightLegendCollapsed) { - panelTopRight.collapseLegend(); - } else { - panelTopRight.expandLegend(); - } + panelTopRight.applyLegendCollapsed(uiState.panelTopRightLegendCollapsed); } if (uiState.panelTopRightTabIndex !== undefined) { - panelTopRight.selectTab(uiState.panelTopRightTabIndex); + panelTopRight.applyTabIndex(uiState.panelTopRightTabIndex); } })(); - const promises: Promise[] = []; + const promises: Promise[] = []; const sceneState = appState.scene; if (sceneState.unit !== undefined) { const unit = (this.#unit = sceneState.unit); @@ -403,17 +391,17 @@ export class VisorFrontend { // projection's sole writer is the camera write below. See // AppStateProjectionLoad.test.ts for why. if (sceneState.crossSectionEnabled !== undefined) { - const promise = renderer.setCrossSectionVisibilityAsync( + const promise = renderer.applyCrossSectionVisibilityAsync( sceneState.crossSectionEnabled ); promises.push(promise); } if (sceneState.edgesEnabled !== undefined) { - const promise = renderer.setEdgeVisibilityGlobalAsync(sceneState.edgesEnabled); + const promise = renderer.applyEdgeVisibilityGlobalAsync(sceneState.edgesEnabled); promises.push(promise); } if (sceneState.boundingBoxEnabled !== undefined) { - const promise = renderer.setBoundingBoxVisibilityAsync( + const promise = renderer.applyBoundingBoxVisibilityAsync( sceneState.boundingBoxEnabled ); promises.push(promise); @@ -436,9 +424,9 @@ export class VisorFrontend { promises.push(promise); } if (cameraState.parallelProjection !== undefined) { - // Routed through setOrthographicModeAsync, not setCameraParallelProjectionAsync: only + // Routed through applyOrthographicModeAsync, not setCameraParallelProjectionAsync: only // the former also sets the widget flag, so the latter would leave it stale for the next save. - const promise = renderer.setOrthographicModeAsync(cameraState.parallelProjection); + const promise = renderer.applyOrthographicModeAsync(cameraState.parallelProjection); promises.push(promise); } if (cameraState.viewAngle !== undefined) { @@ -484,11 +472,11 @@ export class VisorFrontend { // write this actor's ambient/diffuse properties with // different values, so they must be sequenced for this // node rather than raced as independent promises. - let partPromise: Promise = Promise.resolve(); + let partPromise: Promise = Promise.resolve(); if (part_state.diffuseRgb !== undefined) { const [r, g, b] = part_state.diffuseRgb; partPromise = partPromise.then(() => - partNode.setDiffuseColorRgbAsync(r, g, b) + partNode.applyDiffuseColorRgbAsync(r, g, b) ); } // `selected` is applied to the node, like every other @@ -497,30 +485,22 @@ export class VisorFrontend { if (part_state.selected !== undefined) { const selected = part_state.selected; partPromise = partPromise.then(() => - partNode.setSelectedAsync(selected) + partNode.applySelectedAsync(selected) ); } promises.push(partPromise); if (part_state.opacity !== undefined) { - const promise = node.setOpacityAsync(part_state.opacity); + const promise = node.applyOpacityAsync(part_state.opacity); promises.push(promise); } if (part_state.visible !== undefined) { - const promise = node.setVisibilityAsync(part_state.visible); + const promise = node.applyVisibilityAsync(part_state.visible); promises.push(promise); } - // A null reference is delivered without a component. - if (part_state.variableId !== undefined) { - let promise; - if (part_state.variableId !== null) { - promise = node.setColorVariableAsync( - part_state.variableId, - part_state.variableComponent - ); - } else { - promise = node.clearColorVariableAsync(); - } - promises.push(promise); + // A null reference clears the colouring; an absent one leaves it. + const colorVariable = part_state.colorVariable; + if (colorVariable !== undefined) { + promises.push(node.applyColorVariableAsync(colorVariable)); } } } diff --git a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft.tsx b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft.tsx index 8d9eadd3..0be55a43 100644 --- a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft.tsx +++ b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft.tsx @@ -24,22 +24,22 @@ export const Panel_TopLeft: FC<{ const treeViewContainer = document.getElementById(treeViewContainerId) as HTMLDivElement; treeViewUtil.current.addSelectionChangeListener((selectedNodes) => { (async () => { + // One call per part with its target value; a part whose value + // is unchanged sends nothing. + const selected = new Set(selectedNodes); for (const node of visorState.sceneGraph.descendantActorNodesOrSelfArray) { - await node.setSelectedAsync(false); - } - for (const node of selectedNodes) { - await node.setSelectedAsync(true); + await node.setSelectedAsync(selected.has(node)); } await visorState.render(); })(); }); treeViewUtil.current.addVisibilityChangeListener((visibleNodes) => { (async () => { + // One call per part with its target value; a part whose value + // is unchanged sends nothing. + const visible = new Set(visibleNodes); for (const node of visorState.sceneGraph.descendantActorNodesOrSelfArray) { - await node.setVisibilityAsync(false); - } - for (const node of visibleNodes) { - await node.setVisibilityAsync(true); + await node.setVisibilityAsync(visible.has(node)); } // await visorState.updateBoundingBoxBoundsAsync(); await visorState.render(); @@ -57,46 +57,34 @@ export const Panel_TopLeft: FC<{ 'theme-hover-background-3', ]) as HTMLButtonElement; let isPanelCollapsed = false; - let sendEnabled = false; - collapseButton.onclick = (e) => { - reactComponentContainer.style.width = `${reactComponentContainer.offsetWidth}px`; - treeViewContainer.style.display = 'none'; - collapseButton.remove(); - collapseButtonContainer.appendChild(expandButton); - isPanelCollapsed = true; - if (sendEnabled) { - void visorState.sendPanelTopLeftPanelCollapsedAsync(isPanelCollapsed); + const applyPanelCollapsed = (collapsed: boolean) => { + if (collapsed) { + reactComponentContainer.style.width = `${reactComponentContainer.offsetWidth}px`; + treeViewContainer.style.display = 'none'; + collapseButton.remove(); + collapseButtonContainer.appendChild(expandButton); + } else { + reactComponentContainer.style.removeProperty('width'); + treeViewContainer.style.removeProperty('display'); + expandButton.remove(); + collapseButtonContainer.appendChild(collapseButton); } + isPanelCollapsed = collapsed; + }; + collapseButton.onclick = (e) => { + applyPanelCollapsed(true); + void visorState.sendPanelTopLeftPanelCollapsedAsync(isPanelCollapsed); }; expandButton.onclick = (e) => { - reactComponentContainer.style.removeProperty('width'); - treeViewContainer.style.removeProperty('display'); - expandButton.remove(); - collapseButtonContainer.appendChild(collapseButton); - isPanelCollapsed = false; - if (sendEnabled) { - void visorState.sendPanelTopLeftPanelCollapsedAsync(isPanelCollapsed); - } + applyPanelCollapsed(false); + void visorState.sendPanelTopLeftPanelCollapsedAsync(isPanelCollapsed); }; - expandButton.click(); + applyPanelCollapsed(false); visorState.setTreeViewUtil(treeViewUtil.current); - const util = new Panel_TopLeft_Util( - treeViewUtil.current, - () => { - expandButton.click(); - }, - () => { - collapseButton.click(); - }, - () => { - return isPanelCollapsed; - } - ); + const util = new Panel_TopLeft_Util(treeViewUtil.current, applyPanelCollapsed, () => { + return isPanelCollapsed; + }); visorState.setPanelTopLeftUtil(util); - // Must stay on the line after the util handoff: it suppresses the mount click above and is - // open before any delivered apply awaiting the util promise can click; scaffolding, - // removed when delivery is separated from mutation. - sendEnabled = true; onLoad(util); }, []); return ( diff --git a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft_Util.tsx b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft_Util.tsx index 86248969..e6b946da 100644 --- a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft_Util.tsx +++ b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft_Util.tsx @@ -4,21 +4,23 @@ import { VisorSceneNodeExtended } from '../../state/VisorSceneGraph.tsx'; export class Panel_TopLeft_Util { constructor( treeViewUtil: TreeViewUtil, - expandPanel: () => void, - collapsePanel: () => void, + applyPanelCollapsed: (collapsed: boolean) => void, getIsPanelCollapsed: () => boolean ) { this.treeViewUtil = treeViewUtil; - this.expandPanel = expandPanel; - this.collapsePanel = collapsePanel; + this.#applyPanelCollapsed = applyPanelCollapsed; this.#getIsPanelCollapsed = getIsPanelCollapsed; } readonly treeViewUtil: TreeViewUtil; - readonly expandPanel: () => void; - readonly collapsePanel: () => void; + readonly #applyPanelCollapsed: (collapsed: boolean) => void; readonly #getIsPanelCollapsed: () => boolean; + /** Collapse or expand the panel; send nothing. */ + applyPanelCollapsed(collapsed: boolean): void { + this.#applyPanelCollapsed(collapsed); + } + get isPanelCollapsed() { return this.#getIsPanelCollapsed(); } 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 e106406c..2a905ed2 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 @@ -224,13 +224,10 @@ export const Panel_TopRight: FC<{ document.documentElement) as HTMLElement ); - let collapsePanel: () => void; - let expandPanel: () => void; + let applyPanelCollapsed: (collapsed: boolean) => void; let getIsPanelCollapsed: () => boolean; - let collapseLegend: () => void; - let expandLegend: () => void; + let applyLegendCollapsed: (collapsed: boolean) => void; let getIsLegendCollapsed: () => boolean; - let sendEnabled = false; { // set up top right panel collapse/expand buttons @@ -242,35 +239,31 @@ export const Panel_TopRight: FC<{ ]) as HTMLButtonElement; const componentMinHeight: string = componentContainer.style.minHeight; let isCollapsed = false; - collapseButton.onclick = (e) => { - componentContainer.style.width = `${componentContainer.offsetWidth}px`; - componentContainer.style.removeProperty('min-height'); - collapsiblePanel.style.display = 'none'; - collapseButton.remove(); - collapseButtonContainer.appendChild(expandButton); - isCollapsed = true; - if (sendEnabled) { - void visorState.sendPanelTopRightPanelCollapsedAsync(isCollapsed); - } - }; - expandButton.onclick = (e) => { - componentContainer.style.removeProperty('width'); - componentContainer.style.minHeight = componentMinHeight; - collapsiblePanel.style.removeProperty('display'); - expandButton.remove(); - collapseButtonContainer.appendChild(collapseButton); - isCollapsed = false; - if (sendEnabled) { - void visorState.sendPanelTopRightPanelCollapsedAsync(isCollapsed); + applyPanelCollapsed = (collapsed) => { + if (collapsed) { + componentContainer.style.width = `${componentContainer.offsetWidth}px`; + componentContainer.style.removeProperty('min-height'); + collapsiblePanel.style.display = 'none'; + collapseButton.remove(); + collapseButtonContainer.appendChild(expandButton); + } else { + componentContainer.style.removeProperty('width'); + componentContainer.style.minHeight = componentMinHeight; + collapsiblePanel.style.removeProperty('display'); + expandButton.remove(); + collapseButtonContainer.appendChild(collapseButton); } + isCollapsed = collapsed; }; - expandButton.click(); - collapsePanel = () => { - collapseButton.click(); + collapseButton.onclick = (e) => { + applyPanelCollapsed(true); + void visorState.sendPanelTopRightPanelCollapsedAsync(isCollapsed); }; - expandPanel = () => { - expandButton.click(); + expandButton.onclick = (e) => { + applyPanelCollapsed(false); + void visorState.sendPanelTopRightPanelCollapsedAsync(isCollapsed); }; + applyPanelCollapsed(false); getIsPanelCollapsed = () => { return isCollapsed; }; @@ -284,33 +277,29 @@ export const Panel_TopRight: FC<{ 'theme-hover-background-3', ]) as HTMLButtonElement; let isCollapsed = false; - collapseButton.onclick = (e) => { - legendOverlayElem.style.width = `${legendOverlayElem.offsetWidth}px`; - legendCollapsibleElem.style.display = 'none'; - collapseButton.remove(); - legendCollapseButtonContainer.appendChild(expandButton); - isCollapsed = true; - if (sendEnabled) { - void visorState.sendPanelTopRightLegendCollapsedAsync(isCollapsed); - } - }; - expandButton.onclick = (e) => { - legendOverlayElem.style.removeProperty('width'); - legendCollapsibleElem.style.removeProperty('display'); - expandButton.remove(); - legendCollapseButtonContainer.appendChild(collapseButton); - isCollapsed = false; - if (sendEnabled) { - void visorState.sendPanelTopRightLegendCollapsedAsync(isCollapsed); + applyLegendCollapsed = (collapsed) => { + if (collapsed) { + legendOverlayElem.style.width = `${legendOverlayElem.offsetWidth}px`; + legendCollapsibleElem.style.display = 'none'; + collapseButton.remove(); + legendCollapseButtonContainer.appendChild(expandButton); + } else { + legendOverlayElem.style.removeProperty('width'); + legendCollapsibleElem.style.removeProperty('display'); + expandButton.remove(); + legendCollapseButtonContainer.appendChild(collapseButton); } + isCollapsed = collapsed; }; - expandButton.click(); - collapseLegend = () => { - collapseButton.click(); + collapseButton.onclick = (e) => { + applyLegendCollapsed(true); + void visorState.sendPanelTopRightLegendCollapsedAsync(isCollapsed); }; - expandLegend = () => { - expandButton.click(); + expandButton.onclick = (e) => { + applyLegendCollapsed(false); + void visorState.sendPanelTopRightLegendCollapsedAsync(isCollapsed); }; + applyLegendCollapsed(false); getIsLegendCollapsed = () => { return isCollapsed; }; @@ -322,31 +311,34 @@ export const Panel_TopRight: FC<{ legendTabIconContainer.appendChild(legendTabIcon); let tabIndex = 0; - propertyTabElem.onclick = () => { - propertyTabElem.classList.add('theme-background-1'); - propertyPanelElem.style.removeProperty('display'); - propertyTabTextContainer.style.removeProperty('display'); - legendTabElem.classList.remove('theme-background-1'); - legendPanelElem.style.display = 'none'; - legendTabTextContainer.style.display = 'none'; - tabIndex = 0; - if (sendEnabled) { - void visorState.sendPanelTopRightTabIndexAsync(tabIndex); + const applyTabIndex = (index: number) => { + if (index === 0) { + propertyTabElem.classList.add('theme-background-1'); + propertyPanelElem.style.removeProperty('display'); + propertyTabTextContainer.style.removeProperty('display'); + legendTabElem.classList.remove('theme-background-1'); + legendPanelElem.style.display = 'none'; + legendTabTextContainer.style.display = 'none'; + tabIndex = 0; + } else if (index === 1) { + propertyTabElem.classList.remove('theme-background-1'); + propertyPanelElem.style.display = 'none'; + propertyTabTextContainer.style.display = 'none'; + legendTabElem.classList.add('theme-background-1'); + legendPanelElem.style.removeProperty('display'); + legendTabTextContainer.style.removeProperty('display'); + tabIndex = 1; } }; + propertyTabElem.onclick = () => { + applyTabIndex(0); + void visorState.sendPanelTopRightTabIndexAsync(tabIndex); + }; legendTabElem.onclick = () => { - propertyTabElem.classList.remove('theme-background-1'); - propertyPanelElem.style.display = 'none'; - propertyTabTextContainer.style.display = 'none'; - legendTabElem.classList.add('theme-background-1'); - legendPanelElem.style.removeProperty('display'); - legendTabTextContainer.style.removeProperty('display'); - tabIndex = 1; - if (sendEnabled) { - void visorState.sendPanelTopRightTabIndexAsync(tabIndex); - } + applyTabIndex(1); + void visorState.sendPanelTopRightTabIndexAsync(tabIndex); }; - propertyTabElem.onclick(null!); + applyTabIndex(0); function fixValue(val: number): string { if (val >= 100000 || val <= -100000) { @@ -480,27 +472,15 @@ export const Panel_TopRight: FC<{ // reach this panel. await onSelectionChangeAsync(treeViewUtil.selectedNodes); const util = new Panel_TopRight_Util( - expandPanel, - collapsePanel, - expandLegend, - collapseLegend, + applyPanelCollapsed, + applyLegendCollapsed, + applyTabIndex, getIsPanelCollapsed, getIsLegendCollapsed, - (tabIndex: number) => { - if (tabIndex === 0) { - propertyTabElem.click(); - } else if (tabIndex === 1) { - legendTabElem.click(); - } - }, () => tabIndex, () => onSelectionChangeAsync(treeViewUtil.selectedNodes) ); visorState.setPanelTopRightUtil(util); - // Must stay on the line after the util handoff: it suppresses the three mount writes - // above and is open before any delivered apply awaiting the util promise can click; - // scaffolding, removed when delivery is separated from mutation. - sendEnabled = true; onLoad(util); })(); 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 446547b4..2ba26bd4 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 @@ -1,32 +1,43 @@ export class Panel_TopRight_Util { constructor( - expandPanel: () => void, - collapsePanel: () => void, - expandLegend: () => void, - collapseLegend: () => void, + applyPanelCollapsed: (collapsed: boolean) => void, + applyLegendCollapsed: (collapsed: boolean) => void, + applyTabIndex: (index: number) => void, getIsPanelCollapsed: () => boolean, getIsLegendCollapsed: () => boolean, - selectTab: (tabIndex: number) => void, getTabIndex: () => number, refreshSelectionAsync: () => Promise ) { - this.expandPanel = expandPanel; - this.collapsePanel = collapsePanel; - this.expandLegend = expandLegend; - this.collapseLegend = collapseLegend; + this.#applyPanelCollapsed = applyPanelCollapsed; + this.#applyLegendCollapsed = applyLegendCollapsed; + this.#applyTabIndex = applyTabIndex; this.#getIsPanelCollapsed = getIsPanelCollapsed; this.#getIsLegendCollapsed = getIsLegendCollapsed; - this.selectTab = selectTab; this.#getTabIndex = getTabIndex; this.refreshSelectionAsync = refreshSelectionAsync; } - readonly collapsePanel: () => void; - readonly expandPanel: () => void; - readonly collapseLegend: () => void; - readonly expandLegend: () => void; + readonly #applyPanelCollapsed: (collapsed: boolean) => void; + readonly #applyLegendCollapsed: (collapsed: boolean) => void; + readonly #applyTabIndex: (index: number) => void; readonly #getIsPanelCollapsed: () => boolean; readonly #getIsLegendCollapsed: () => boolean; + readonly #getTabIndex: () => number; + + /** Collapse or expand the panel; send nothing. */ + applyPanelCollapsed(collapsed: boolean): void { + this.#applyPanelCollapsed(collapsed); + } + + /** Collapse or expand the legend overlay; send nothing. */ + applyLegendCollapsed(collapsed: boolean): void { + this.#applyLegendCollapsed(collapsed); + } + + /** Select the tab; send nothing. Other indices do nothing, as today. */ + applyTabIndex(index: number): void { + this.#applyTabIndex(index); + } get isPanelCollapsed() { return this.#getIsPanelCollapsed(); @@ -36,9 +47,6 @@ export class Panel_TopRight_Util { return this.#getIsLegendCollapsed(); } - readonly selectTab: (tabIndex: number) => void; - readonly #getTabIndex: () => number; - get tabIndex() { return this.#getTabIndex(); } diff --git a/src/ansys/visor/visor-client/src/jest-tests/AppStateProjectionLoad.test.ts b/src/ansys/visor/visor-client/src/jest-tests/AppStateProjectionLoad.test.ts index e56dc68e..8d7a2401 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/AppStateProjectionLoad.test.ts +++ b/src/ansys/visor/visor-client/src/jest-tests/AppStateProjectionLoad.test.ts @@ -68,11 +68,16 @@ function makeRendererDouble() { domElement: document.createElement('div'), addCameraSettledListener: jest.fn(() => jest.fn()), addViewerClickedListener: jest.fn(() => jest.fn()), - // The two projection writers. Exactly one of these may be called. + // The projection writers. Only the apply member may be called: the + // setter also sends, and the camera setter leaves the widget flag stale. + applyOrthographicModeAsync: jest.fn(async () => undefined), setOrthographicModeAsync: jest.fn(async () => undefined), setCameraParallelProjectionAsync: jest.fn(async () => undefined), // The other widget toggles, unused by this fixture but part of the // surface `setAppStateAsync` branches over. + applyCrossSectionVisibilityAsync: jest.fn(async () => undefined), + applyEdgeVisibilityGlobalAsync: jest.fn(async () => undefined), + applyBoundingBoxVisibilityAsync: jest.fn(async () => undefined), setCrossSectionVisibilityAsync: jest.fn(async () => undefined), setEdgeVisibilityGlobalAsync: jest.fn(async () => undefined), setBoundingBoxVisibilityAsync: jest.fn(async () => undefined), @@ -105,7 +110,7 @@ function makeFrontend(): { frontend: VisorFrontend; renderer: RendererDouble } { } describe('setAppStateAsync applies projection from the camera alone', () => { - test('a parallel camera with a contradictory orthographicEnabled applies projection once, with true, through setOrthographicModeAsync', async () => { + test('a parallel camera with a contradictory orthographicEnabled applies projection once, with true, through applyOrthographicModeAsync', async () => { const { frontend, renderer } = makeFrontend(); await frontend.setAppStateAsync( @@ -120,8 +125,8 @@ describe('setAppStateAsync applies projection from the camera alone', () => { false ); - expect(renderer.setOrthographicModeAsync).toHaveBeenCalledTimes(1); - expect(renderer.setOrthographicModeAsync).toHaveBeenCalledWith(true); + expect(renderer.applyOrthographicModeAsync.mock.calls).toEqual([[true]]); + expect(renderer.setOrthographicModeAsync).not.toHaveBeenCalled(); expect(renderer.setCameraParallelProjectionAsync).not.toHaveBeenCalled(); }); @@ -143,6 +148,7 @@ describe('setAppStateAsync applies projection from the camera alone', () => { false ); + expect(renderer.applyOrthographicModeAsync).not.toHaveBeenCalled(); expect(renderer.setOrthographicModeAsync).not.toHaveBeenCalled(); expect(renderer.setCameraParallelProjectionAsync).not.toHaveBeenCalled(); }); diff --git a/src/ansys/visor/visor-client/src/jest-tests/DeliveredStateApply.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/DeliveredStateApply.test.tsx index 72c1a8cc..caaff6ff 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/DeliveredStateApply.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/DeliveredStateApply.test.tsx @@ -86,7 +86,10 @@ function makeSceneGraphNode() { }; } -/** Every renderer member the constructor, the part loop and its node methods reach. */ +/** + * Every renderer member the constructor, the part loop, its node methods and + * the widget toggles reach. + */ function makeRendererDouble() { return { attachSceneGraph: jest.fn(), @@ -107,6 +110,12 @@ function makeRendererDouble() { clearColorVariableAsync: jest.fn(async () => undefined), sendClearPartColorVariableAsync: jest.fn(async () => undefined), setScalarRangeAsync: jest.fn(async () => undefined), + applyCrossSectionVisibilityAsync: jest.fn(async () => undefined), + applyEdgeVisibilityGlobalAsync: jest.fn(async () => undefined), + applyBoundingBoxVisibilityAsync: jest.fn(async () => undefined), + setCrossSectionVisibilityAsync: jest.fn(async () => undefined), + setEdgeVisibilityGlobalAsync: jest.fn(async () => undefined), + setBoundingBoxVisibilityAsync: jest.fn(async () => undefined), }; } @@ -219,3 +228,128 @@ describe('the client applies delivered state as given', () => { } }); }); + +/** How many times each of the six per-part send members was called. */ +function sendCallCounts(renderer: ReturnType) { + return { + sendPartVisibilityAsync: renderer.sendPartVisibilityAsync.mock.calls.length, + sendPartOpacityAsync: renderer.sendPartOpacityAsync.mock.calls.length, + sendPartSelectedAsync: renderer.sendPartSelectedAsync.mock.calls.length, + sendPartDiffuseColorAsync: renderer.sendPartDiffuseColorAsync.mock.calls.length, + sendPartColorVariableAsync: renderer.sendPartColorVariableAsync.mock.calls.length, + sendClearPartColorVariableAsync: renderer.sendClearPartColorVariableAsync.mock.calls.length, + }; +} + +const NO_SENDS = { + sendPartVisibilityAsync: 0, + sendPartOpacityAsync: 0, + sendPartSelectedAsync: 0, + sendPartDiffuseColorAsync: 0, + sendPartColorVariableAsync: 0, + sendClearPartColorVariableAsync: 0, +}; + +/** A delivery holding `pressure` and a part A record differing from the fixture's in every field. */ +function deliveryDifferingInEveryField() { + return delivery( + { [PRESSURE_ID]: pressureRecord() }, + deliveredPart(PART_A_ID, { + visible: false, + opacity: 0.25, + selected: true, + diffuseRgb: [0.25, 0.5, 0.75], + colorVariable: { variableId: PRESSURE_ID, variableComponent: -1 }, + }) + ); +} + +describe('delivered state is applied without sending', () => { + test('a delivered record differing in every part field sends no per-part trigger', async () => { + const { frontend, renderer } = makeFrontend(deliveredScene(deliveredPart(PART_A_ID))); + + await frontend.setAppStateAsync(deliveryDifferingInEveryField(), false); + + expect(sendCallCounts(renderer)).toEqual(NO_SENDS); + }); + + test('a delivered record differing in every part field reaches the renderer', async () => { + // Guards the test above: a delivery that applied nothing would also send nothing. + const { frontend, renderer } = makeFrontend(deliveredScene(deliveredPart(PART_A_ID))); + + await frontend.setAppStateAsync(deliveryDifferingInEveryField(), false); + + expect(renderer.setVisibilityAsync.mock.calls).toEqual([[1, false]]); + expect(renderer.setOpacityAsync.mock.calls).toEqual([[1, 0.25]]); + expect(renderer.setDiffuseColorRgbAsync.mock.calls).toEqual([[1, 0.25, 0.5, 0.75, false]]); + expect(renderer.setSelectedAsync.mock.calls).toEqual([[1, true, [0.25, 0.5, 0.75]]]); + expect(renderer.setColorVariableAsync.mock.calls).toEqual([ + [ + 1, + { + variableId: 'POINT::pressure::1', + variableType: 'POINT', + variableName: 'pressure', + component: -1, + min: 2, + max: 8, + }, + ], + ]); + }); + + test('a delivered colorVariable null on a coloured part clears it without sending', async () => { + const { frontend, renderer } = makeFrontend( + deliveredScene( + deliveredPart(PART_A_ID, { + colorVariable: { variableId: PRESSURE_ID, variableComponent: -1 }, + }) + ) + ); + + await frontend.setAppStateAsync( + delivery({ [PRESSURE_ID]: pressureRecord() }, deliveredPart(PART_A_ID)), + false + ); + + expect(renderer.clearColorVariableAsync.mock.calls).toEqual([[1]]); + expect(sendCallCounts(renderer)).toEqual(NO_SENDS); + }); + + test('the three delivered widget toggles are applied through the apply members and not the setters', async () => { + const { frontend, renderer } = makeFrontend(deliveredScene(deliveredPart(PART_A_ID))); + + await frontend.setAppStateAsync( + { scene: { crossSectionEnabled: true, edgesEnabled: false, boundingBoxEnabled: true } }, + false + ); + + expect(renderer.applyCrossSectionVisibilityAsync.mock.calls).toEqual([[true]]); + expect(renderer.applyEdgeVisibilityGlobalAsync.mock.calls).toEqual([[false]]); + expect(renderer.applyBoundingBoxVisibilityAsync.mock.calls).toEqual([[true]]); + expect(renderer.setCrossSectionVisibilityAsync).not.toHaveBeenCalled(); + expect(renderer.setEdgeVisibilityGlobalAsync).not.toHaveBeenCalled(); + expect(renderer.setBoundingBoxVisibilityAsync).not.toHaveBeenCalled(); + }); + + test('setColorVariableAsync on the slot the node holds, with a different range, applies the range and sends nothing', async () => { + // The node is built holding pressure's magnitude slot with no range + // applied; the delivery below holds the record and touches no part. + const { frontend, renderer, partA } = makeFrontend( + deliveredScene( + deliveredPart(PART_A_ID, { + colorVariable: { variableId: PRESSURE_ID, variableComponent: -1 }, + }) + ) + ); + await frontend.setAppStateAsync( + { scene: { variableStates: { [PRESSURE_ID]: pressureRecord() } } }, + false + ); + + await partA.setColorVariableAsync(PRESSURE_ID, -1); + + expect(renderer.setScalarRangeAsync.mock.calls).toEqual([[1, 2, 8]]); + expect(sendCallCounts(renderer)).toEqual(NO_SENDS); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/TreeListenerSends.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/TreeListenerSends.test.tsx new file mode 100644 index 00000000..756de09a --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/TreeListenerSends.test.tsx @@ -0,0 +1,149 @@ +import { render, fireEvent, act } from '@testing-library/react'; +import { VisorFrontend } from '../VisorFrontend'; +import { Panel_TopLeft } from '../components/ui-panels/Panel_TopLeft'; +import { TreeViewUtil } from '../treeview/TreeView'; +import { CreateVisorSceneGraph, VisorSceneNodeExtended } from '../state/VisorSceneGraph.tsx'; +import type { IRenderer } from '../renderer/IRenderer'; +import { deliveredPart, deliveredScene } from './deliveredSceneFixture.ts'; + +/** + * The sends a tree interaction makes through `Panel_TopLeft`'s visibility and + * selection listeners: each part whose value changes sends once, and a part + * whose value does not change sends nothing. + * + * The scene is a root holding a group (parts 1 and 2) and part 3 outside it. + * Each listener is fire-and-forget and ends with `visorState.render()`, so + * every test awaits that call rather than a count of microtasks. + */ + +const GROUP_ID = 10; +const PART_1_ID = 1; +const PART_2_ID = 2; +const PART_3_ID = 3; + +/** The renderer members a part's visibility and selection methods reach. */ +function makeRendererDouble() { + return { + setVisibilityAsync: jest.fn(async () => undefined), + sendPartVisibilityAsync: jest.fn(async () => undefined), + setSelectedAsync: jest.fn(async () => undefined), + sendPartSelectedAsync: jest.fn(async () => undefined), + }; +} + +function makeSceneGraph( + renderer: IRenderer, + part1Overrides: Record = {} +): VisorSceneNodeExtended { + const part = (id: number, name: string) => ({ + id, + dataArrays: [], + name, + isGroupNode: false, + isActorNode: true, + nodeType: 'vtkPolyData', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }); + return CreateVisorSceneGraph( + { + id: 0, + dataArrays: [], + name: '', + isGroupNode: true, + isActorNode: false, + nodeType: 'root', + diffuseColor: [1, 1, 1], + bounds: [], + children: [ + { + id: GROUP_ID, + dataArrays: [], + name: 'group', + isGroupNode: true, + isActorNode: false, + nodeType: 'vtkMultiBlockDataSet', + diffuseColor: [1, 1, 1], + bounds: [], + children: [part(PART_1_ID, 'part 1'), part(PART_2_ID, 'part 2')], + }, + part(PART_3_ID, 'part 3'), + ], + }, + deliveredScene( + deliveredPart(PART_1_ID, part1Overrides), + deliveredPart(PART_2_ID), + deliveredPart(PART_3_ID) + ), + undefined, + renderer + ); +} + +/** + * Mounts `Panel_TopLeft` over a stand-in frontend and returns the tree util, + * the renderer double, and a function returning a promise for the next + * `render()` call. + */ +async function mountPanel(part1Overrides: Record = {}) { + const renderer = makeRendererDouble(); + let treeViewUtil: TreeViewUtil = null!; + let rendered: () => void = () => {}; + const visorState = { + sceneGraph: makeSceneGraph(renderer as unknown as IRenderer, part1Overrides), + render: jest.fn(async () => rendered()), + setTreeViewUtil: jest.fn((u: TreeViewUtil) => { + treeViewUtil = u; + }), + setPanelTopLeftUtil: jest.fn(), + sendPanelTopLeftPanelCollapsedAsync: jest.fn(async () => undefined), + }; + await act(async () => { + render( + {}} /> + ); + }); + const nextRender = () => + new Promise((resolve) => { + rendered = resolve; + }); + return { renderer, treeViewUtil, nextRender }; +} + +describe('tree visibility listener', () => { + test('hiding one part in a multi-part tree sends its visibility once', async () => { + const { renderer, treeViewUtil, nextRender } = await mountPanel(); + const done = nextRender(); + + fireEvent.click(treeViewUtil.rowUtilsMap.get(PART_2_ID)!.hideButton); + await done; + + expect(renderer.sendPartVisibilityAsync.mock.calls).toEqual([[2, false]]); + }); + + test('hiding a group row sends once per visible part in the group and none outside it', async () => { + const { renderer, treeViewUtil, nextRender } = await mountPanel(); + const done = nextRender(); + + fireEvent.click(treeViewUtil.rowUtilsMap.get(GROUP_ID)!.hideButton); + await done; + + expect(renderer.sendPartVisibilityAsync.mock.calls).toEqual([ + [1, false], + [2, false], + ]); + }); +}); + +describe('tree selection listener', () => { + test('adding a part to the selection sends its selection once', async () => { + const { renderer, treeViewUtil, nextRender } = await mountPanel({ selected: true }); + const done = nextRender(); + + fireEvent.click(treeViewUtil.rowUtilsMap.get(PART_3_ID)!.tableElem, { ctrlKey: true }); + await done; + + expect(renderer.sendPartSelectedAsync.mock.calls).toEqual([[3, true]]); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/UiPanelStateSends.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/UiPanelStateSends.test.tsx index 3ffd0e47..77840e4e 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/UiPanelStateSends.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/UiPanelStateSends.test.tsx @@ -1,4 +1,4 @@ -import { render, fireEvent, act } from '@testing-library/react'; +import { render, fireEvent, act, within } from '@testing-library/react'; import { VisorFrontend } from '../VisorFrontend'; import { Panel_TopLeft } from '../components/ui-panels/Panel_TopLeft'; import { Panel_TopRight } from '../components/ui-panels/Panel_TopRight'; @@ -11,10 +11,9 @@ import { deliveredPart, deliveredScene } from './deliveredSceneFixture.ts'; /** * The client half of server-owned UI panel state: the four `VisorFrontend` - * send methods, and the per-panel mount gate that keeps a freshly mounted - * panel silent. + * send methods, and which panel writes send. * - * Two subjects, and they fail differently. + * Three subjects, and they fail differently. * * 1. Each send method puts its own trigger name and the value it was given * on the wire. A name mismatched by one character between the two stacks @@ -22,27 +21,28 @@ import { deliveredPart, deliveredScene } from './deliveredSceneFixture.ts'; * name strings are pinned here as hand-written literals -- as is every * expected payload. * - * 2. Every rebuild remounts every panel, and each mount runs - * `expandButton.click()` (both panels, twice in the top-right) and - * `propertyTabElem.onclick(null!)`. Those writes run *before* the util is - * registered. Were the gate open there, every refresh and every rebuild - * would report the client's mount defaults over the record the server - * just delivered, and a save in that window would record them -- correct + * 2. Only a user click sends. Every rebuild remounts every panel, and each + * mount initializes its controls through the panel's apply methods; a + * delivery through `setAppStateAsync` applies through the same methods. + * Were either to send, every refresh and every rebuild would report the + * client's mount defaults, or echo back the record the server just + * delivered, and a save in that window would record them -- correct * under every other test, and wrong in the running application only. - * Tests 5 and 6 pin the gate closed at mount for each panel separately, - * because the two gate differently: the top-left gate is purely - * synchronous, the top-right gate spans two awaits. + * The mount tests and the delivery test pin that silence, the mount + * tests per panel because the top-left mount is synchronous and the + * top-right mount spans two awaits. * - * Tests 5 and 6 cannot see a gate that never opens -- a panel that reports - * nothing at mount and nothing afterwards passes both. Test 7 is that pin, - * on the top-right panel, whose gate is the one behind the awaits. + * 3. A panel that sends nothing at all passes every test in 2. The click + * tests are that pin, one per control: a user click sends its trigger + * exactly once. * - * Test 8 is a third subject and not a send at all: the top-right panel - * initializes its properties panel from the tree view's current selection, - * rather than from an empty list. A selection delivered before the panel - * mounts reaches the rows and the mesh through `TreeViewUtil.synchronize()`, - * which runs no selection-change listener, so an empty-list initialization - * leaves the properties panel blank until the user clicks a part. + * The last test is a separate subject and not a send at all: the top-right + * panel initializes its properties panel from the tree view's current + * selection, rather than from an empty list. A selection delivered before the + * panel mounts reaches the rows and the mesh through + * `TreeViewUtil.synchronize()`, which runs no selection-change listener, so an + * empty-list initialization leaves the properties panel blank until the user + * clicks a part. * * jsdom has no `ResizeObserver` and jest here runs with no `setupFiles` * (`jest.config.cjs`), so the stub below is this module's own. @@ -81,8 +81,9 @@ function makeSceneGraphNode() { } /** - * Every renderer member `VisorFrontend`'s constructor reaches. The sends are - * the subject; these are here so construction completes. + * Every renderer member `VisorFrontend`'s constructor, `setAppStateAsync` and + * the two mounted panels reach. The sends are the subject; these are here so + * construction and delivery complete. */ function makeRendererDouble() { return { @@ -90,6 +91,8 @@ function makeRendererDouble() { domElement: document.createElement('div'), addCameraSettledListener: jest.fn(() => jest.fn()), addViewerClickedListener: jest.fn(() => jest.fn()), + renderAsync: jest.fn(async () => undefined), + resizeAsync: jest.fn(async () => undefined), }; } @@ -129,7 +132,7 @@ function makeSendDoubles() { * A real scene graph, which `Panel_TopLeft` hands to its `TreeView`. * * The renderer is optional and defaults to absent, which is what the two - * mount-gate tests want: they never reach a node method. A test whose selection + * mount tests want: they never reach a node method. A test whose selection * is non-empty does reach one -- the properties panel clears the colour * variable on a part with no data arrays -- and supplies the double below. */ @@ -179,7 +182,7 @@ function makeNodeRendererDouble(): IRenderer { /** * A `VisorFrontend` stand-in carrying only what `Panel_TopLeft`'s effect * reaches, plus a promise that settles when the panel hands over its util -- - * which is the moment the gate is expected to open. + * which is the moment its mount has finished. */ function makeTopLeftFrontendDouble() { const sends = makeSendDoubles(); @@ -203,7 +206,7 @@ function makeTopLeftFrontendDouble() { * * `selectedNodes` is what the tree view util holds when the panel's effect * reaches it. It defaults to empty -- the state a tree with nothing selected is - * in -- so the two mount-gate tests below are unaffected by its presence. + * in -- so the two mount tests below are unaffected by its presence. */ function makeTopRightFrontendDouble(selectedNodes: VisorSceneNodeExtended[] = []) { const sends = makeSendDoubles(); @@ -277,7 +280,7 @@ describe('VisorFrontend UI panel send surface', () => { }); }); -describe('the panel mount gate', () => { +describe('panel mount', () => { test('Panel_TopLeft sends nothing while mounting', async () => { const { visorState, sends, utilRegistered } = makeTopLeftFrontendDouble(); @@ -291,8 +294,8 @@ describe('the panel mount gate', () => { }); await utilRegistered; - // The mount click has run and the util has been handed over, so the - // gate is open now -- and nothing was reported on the way there. + // Mount has applied its default and handed over the util, and + // reported nothing on the way there. expect(visorState.setPanelTopLeftUtil).toHaveBeenCalledTimes(1); expect(sends.sendPanelTopLeftPanelCollapsedAsync).not.toHaveBeenCalled(); }); @@ -310,13 +313,38 @@ describe('the panel mount gate', () => { }); await utilRegistered; - // Three mount writes here, not one: both blocks' expand clicks and the - // tab call, all before the registration this has now awaited. + // Three mount writes here, not one: the panel, the legend and the + // tab, all before the registration this has now awaited. expect(visorState.setPanelTopRightUtil).toHaveBeenCalledTimes(1); expect(sends.sendPanelTopRightPanelCollapsedAsync).not.toHaveBeenCalled(); expect(sends.sendPanelTopRightLegendCollapsedAsync).not.toHaveBeenCalled(); expect(sends.sendPanelTopRightTabIndexAsync).not.toHaveBeenCalled(); }); +}); + +describe('a user click', () => { + test('a click on the Panel_TopLeft collapse button sends exactly once', async () => { + const { visorState, sends, utilRegistered } = makeTopLeftFrontendDouble(); + const { container } = render( + {}} /> + ); + await act(async () => { + await utilRegistered; + }); + + // Located by its heading: the ids in this component are + // `randomId()`-generated, and the button sits in the cell after + // "Objects". + const collapseButton = within(container) + .getByText('Objects', { selector: 'td' }) + .nextElementSibling!.querySelector('button') as HTMLButtonElement; + expect(collapseButton).not.toBeNull(); + + fireEvent.click(collapseButton); + + expect(sends.sendPanelTopLeftPanelCollapsedAsync).toHaveBeenCalledTimes(1); + expect(sends.sendPanelTopLeftPanelCollapsedAsync).toHaveBeenCalledWith(true); + }); test('a click on a Panel_TopRight control after its util is registered sends exactly once', async () => { const { visorState, sends, utilRegistered } = makeTopRightFrontendDouble(); @@ -341,6 +369,82 @@ describe('the panel mount gate', () => { expect(sends.sendPanelTopRightPanelCollapsedAsync).toHaveBeenCalledTimes(1); expect(sends.sendPanelTopRightPanelCollapsedAsync).toHaveBeenCalledWith(true); }); + + test('a click on the Panel_TopRight legend collapse button sends exactly once', async () => { + const { visorState, sends, utilRegistered } = makeTopRightFrontendDouble(); + const { container } = render( + {}} /> + ); + await act(async () => { + await utilRegistered; + }); + + // Located by its heading: the button sits in the cell after the + // overlay's "Legend" cell. + const collapseButton = within(container) + .getByText('Legend', { selector: 'td' }) + .nextElementSibling!.querySelector('button') as HTMLButtonElement; + expect(collapseButton).not.toBeNull(); + + fireEvent.click(collapseButton); + + expect(sends.sendPanelTopRightLegendCollapsedAsync).toHaveBeenCalledTimes(1); + expect(sends.sendPanelTopRightLegendCollapsedAsync).toHaveBeenCalledWith(true); + }); + + test('a click on the Panel_TopRight legend tab sends exactly once', async () => { + const { visorState, sends, utilRegistered } = makeTopRightFrontendDouble(); + const { container } = render( + {}} /> + ); + await act(async () => { + await utilRegistered; + }); + + // The tab's label; the click bubbles to the tab element's handler. + fireEvent.click(within(container).getByText('Legend Settings')); + + expect(sends.sendPanelTopRightTabIndexAsync).toHaveBeenCalledTimes(1); + expect(sends.sendPanelTopRightTabIndexAsync).toHaveBeenCalledWith(1); + }); +}); + +describe('delivered panel state', () => { + test('setAppStateAsync applies delivered panel state and sends no panel trigger', async () => { + const { frontend, triggerSender } = makeFrontend(); + await act(async () => { + render( + <> + {}} /> + {}} /> + + ); + }); + const panelTopLeft = await frontend.panelTopLeftUtilPromise; + const panelTopRight = await frontend.panelTopRightUtilPromise; + triggerSender.mockClear(); + + // Every value differs from its mount default. + await frontend.setAppStateAsync( + { + ui: { + panelTopLeftPanelCollapsed: true, + panelTopRightPanelCollapsed: true, + panelTopRightLegendCollapsed: true, + panelTopRightTabIndex: 1, + }, + }, + false + ); + + // The delivery reached the panels, so the silence below is not that + // of a branch that never ran. + expect(panelTopLeft.isPanelCollapsed).toBe(true); + expect(panelTopRight.isPanelCollapsed).toBe(true); + expect(panelTopRight.isLegendCollapsed).toBe(true); + expect(panelTopRight.tabIndex).toBe(1); + expect(triggerSender.mock.calls.map((call) => call[0])).toEqual([]); + }); }); describe('the properties panel at mount', () => { 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 c5584c58..87bae09b 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 @@ -167,6 +167,10 @@ function makeRendererDouble() { setCrossSectionVisibilityAsync: jest.fn(async () => undefined), setEdgeVisibilityGlobalAsync: jest.fn(async () => undefined), setBoundingBoxVisibilityAsync: jest.fn(async () => undefined), + applyOrthographicModeAsync: jest.fn(async () => undefined), + applyCrossSectionVisibilityAsync: jest.fn(async () => undefined), + applyEdgeVisibilityGlobalAsync: jest.fn(async () => undefined), + applyBoundingBoxVisibilityAsync: jest.fn(async () => undefined), setCameraPositionAsync: jest.fn(async () => undefined), setCameraFocalPointAsync: jest.fn(async () => undefined), setCameraViewUpAsync: jest.fn(async () => undefined), @@ -456,10 +460,8 @@ describe('setAppStateAsync holds a delivered variable block', () => { noop, noop, noop, - noop, () => false, () => false, - noop, () => 0, async () => { order.push('refresh'); diff --git a/src/ansys/visor/visor-client/src/jest-tests/WasmRendererWidgetTriggers.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/WasmRendererWidgetTriggers.test.tsx index 89be55cf..040b6630 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/WasmRendererWidgetTriggers.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/WasmRendererWidgetTriggers.test.tsx @@ -263,6 +263,37 @@ describe('WasmRenderer widget sends: no sender, and a failing sender', () => { }); }); +describe('WasmRenderer widget apply methods write the widget and send nothing', () => { + test('the four apply methods send nothing', async () => { + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.applyCrossSectionVisibilityAsync(true); + await renderer.applyEdgeVisibilityGlobalAsync(true); + await renderer.applyBoundingBoxVisibilityAsync(true); + await renderer.applyOrthographicModeAsync(true); + + expect(sender).not.toHaveBeenCalled(); + }); + + test('the four apply methods leave each widget reading the applied value', async () => { + // Every widget starts at `false`, so `true` is a change on each. + const { renderer } = await makeRenderer(makeSender()); + + await renderer.applyCrossSectionVisibilityAsync(true); + await renderer.applyEdgeVisibilityGlobalAsync(true); + await renderer.applyBoundingBoxVisibilityAsync(true); + await renderer.applyOrthographicModeAsync(true); + + expect({ + crossSection: renderer.isCrossSectionVisible(), + edges: renderer.areEdgesVisibleGlobally(), + boundingBox: renderer.isBoundingBoxVisible(), + orthographic: renderer.isOrthographicEnabled(), + }).toEqual({ crossSection: true, edges: true, boundingBox: true, orthographic: true }); + }); +}); + describe('WasmRenderer edge fan-out reaches every actor on every call', () => { test('setEdgeVisibilityGlobalAsync issues one per-actor call per actor on every call, not only when the value changes', async () => { // The per-node cache that D1 deleted lived on the scene-graph node diff --git a/src/ansys/visor/visor-client/src/renderer/IRenderer.ts b/src/ansys/visor/visor-client/src/renderer/IRenderer.ts index c992f736..c776bf31 100644 --- a/src/ansys/visor/visor-client/src/renderer/IRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/IRenderer.ts @@ -222,6 +222,8 @@ export interface IRenderer { // server's store. setCrossSectionVisibilityAsync(visible?: boolean): Promise; + /** Show or hide the widget; send nothing. */ + applyCrossSectionVisibilityAsync(visible: boolean): Promise; /** Whether the cross-section plane is shown. See contract note above. */ isCrossSectionVisible(): boolean; // cached bool, sync updateCrossSectionBoundsAsync(): Promise; @@ -231,11 +233,15 @@ export interface IRenderer { setCrossSectionNormalAsync(normal: readonly number[]): Promise; setBoundingBoxVisibilityAsync(visible?: boolean): Promise; + /** Show or hide the widget; send nothing. */ + applyBoundingBoxVisibilityAsync(visible: boolean): Promise; /** Whether the bounding-box outline is shown. See contract note above. */ isBoundingBoxVisible(): boolean; updateBoundingBoxBoundsAsync(): Promise; setOrthographicModeAsync(enable?: boolean): Promise; + /** Show or hide the widget; send nothing. */ + applyOrthographicModeAsync(enable: boolean): Promise; /** * Whether the view is in parallel (orthographic) projection. See contract * note above; additionally, the server-delivered value is seeded from the @@ -245,6 +251,8 @@ export interface IRenderer { isOrthographicEnabled(): boolean; setEdgeVisibilityGlobalAsync(visible?: boolean): Promise; + /** Show or hide the widget; send nothing. */ + applyEdgeVisibilityGlobalAsync(visible: boolean): Promise; /** Whether edges are shown on every part in the scene. See contract note above. */ areEdgesVisibleGlobally(): boolean; diff --git a/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts b/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts index 78c88f4d..efbdc0a8 100644 --- a/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts @@ -217,6 +217,10 @@ export class NullRenderer implements IRenderer { return false; } + async applyCrossSectionVisibilityAsync(_visible: boolean): Promise { + // no-op + } + async updateCrossSectionBoundsAsync(): Promise { // no-op } @@ -245,6 +249,10 @@ export class NullRenderer implements IRenderer { return false; } + async applyBoundingBoxVisibilityAsync(_visible: boolean): Promise { + // no-op + } + async updateBoundingBoxBoundsAsync(): Promise { // no-op } @@ -257,6 +265,10 @@ export class NullRenderer implements IRenderer { return false; } + async applyOrthographicModeAsync(_enable: boolean): Promise { + // no-op + } + async setEdgeVisibilityGlobalAsync(_visible?: boolean): Promise { // no-op } @@ -265,6 +277,10 @@ export class NullRenderer implements IRenderer { return false; } + async applyEdgeVisibilityGlobalAsync(_visible: boolean): Promise { + // no-op + } + async toggleFullScreenAsync(): Promise { // no-op } diff --git a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts index fa89b42c..cb04ebd7 100644 --- a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts @@ -564,6 +564,10 @@ export class WasmRenderer implements IRenderer { return this.#crossSectionWidget.enabled; } + async applyCrossSectionVisibilityAsync(visible: boolean): Promise { + await this.#crossSectionWidget.setVisibilityAsync(visible); + } + async updateCrossSectionBoundsAsync(): Promise { await this.#crossSectionWidget.updateBoundsAsync(); } @@ -597,6 +601,11 @@ export class WasmRenderer implements IRenderer { return this.#boundingBoxWidget!.enabled; } + async applyBoundingBoxVisibilityAsync(visible: boolean): Promise { + this.#requireSceneGraphAttached('applyBoundingBoxVisibilityAsync'); + await this.#boundingBoxWidget!.setVisibilityAsync(visible); + } + async updateBoundingBoxBoundsAsync(): Promise { this.#requireSceneGraphAttached('updateBoundingBoxBoundsAsync'); await this.#boundingBoxWidget!.updateBoundsAsync(); @@ -613,6 +622,10 @@ export class WasmRenderer implements IRenderer { return this.#orthographicWidget.enabled; } + async applyOrthographicModeAsync(enable: boolean): Promise { + await this.#orthographicWidget.setOrthographicModeAsync(enable); + } + async setEdgeVisibilityGlobalAsync(visible?: boolean): Promise { this.#requireSceneGraphAttached('setEdgeVisibilityGlobalAsync'); await this.#edgesWidget!.setEdgesVisibleAsync(visible); @@ -626,6 +639,11 @@ export class WasmRenderer implements IRenderer { return this.#edgesWidget!.enabled; } + async applyEdgeVisibilityGlobalAsync(visible: boolean): Promise { + this.#requireSceneGraphAttached('applyEdgeVisibilityGlobalAsync'); + await this.#edgesWidget!.setEdgesVisibleAsync(visible); + } + async toggleFullScreenAsync(): Promise { await this.#fullscreenWidget.setFullScreenAsync(); } diff --git a/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx b/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx index ce3bfb37..d6dec36e 100644 --- a/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx +++ b/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx @@ -6,7 +6,8 @@ import { } from './VisorVariableManager.tsx'; import VisorVtkDataArray from './appstate/vtkInfo/VisorVtkDataArray.tsx'; import VisorSceneState from './appstate/VisorSceneState.tsx'; -import { IRenderer } from '../renderer/IRenderer'; +import type { ColorVariableReference } from './appstate/VisorPartState.tsx'; +import type { ColorVariableDescriptor, IRenderer } from '../renderer/IRenderer'; export type VisorSceneNodeSimple = Readonly<{ id: number; @@ -45,6 +46,21 @@ export type VisorSceneNodeExtended = Readonly<{ setVisibilityAsync: (visible: boolean) => Promise; setSelectedAsync: (selected: boolean) => Promise; setOpacityAsync: (opacity: number) => Promise; + /** Write to node and renderer; send nothing. + * False when the node already held the value. */ + applyVisibilityAsync: (visible: boolean) => Promise; + /** Write to node and renderer; send nothing. + * False when the node already held the value. */ + applyOpacityAsync: (opacity: number) => Promise; + /** Write to node and renderer; send nothing. + * False when the node already held the value. */ + applySelectedAsync: (selected: boolean) => Promise; + /** Write to node and renderer; send nothing. + * False when the node already held the value. */ + applyDiffuseColorRgbAsync: (r: number, g: number, b: number) => Promise; + /** Color by the reference, or clear on null; send nothing. + * True when the renderer was given a new reference or had one cleared. */ + applyColorVariableAsync: (reference: ColorVariableReference | null) => Promise; /** * Removes the part's colour variable. A part is always coloured by its * diffuse colour, and a colour variable overrides it while set; clearing @@ -171,6 +187,25 @@ export const CreateVisorSceneGraph = (() => { // replaced on every delivery. const variableCollection = variableManager.getPartVariableCollection(nodeId); + /** The descriptor of the variable slot applied to the part, or null when none is. */ + const appliedDescriptor = (): ColorVariableDescriptor | null => { + if (_variableId === null) { + return null; + } + const variable = variableCollection.getVariable(_variableId); + if (variable == null) { + return null; + } + return { + variableId: _variableId, + variableType: variable.type, + variableName: variable.name, + component: _variableComponent, + min: _variableMin, + max: _variableMax, + }; + }; + const node: VisorSceneNodeExtended = { id: simpleNode.id, get rootNode() { @@ -226,9 +261,14 @@ export const CreateVisorSceneGraph = (() => { await renderer!.sendPartDiffuseColorAsync(nodeId, rgbNormalized); }, async setDiffuseColorRgbAsync(r, g, b) { + if (await node.applyDiffuseColorRgbAsync(r, g, b)) { + await renderer!.sendPartDiffuseColorAsync(nodeId, [r, g, b]); + } + }, + async applyDiffuseColorRgbAsync(r, g, b) { const current = customDiffuseColor.rgbNormalized; if (current[0] === r && current[1] === g && current[2] === b) { - return; + return false; } customDiffuseColor.setRgb(r, g, b); const rgbNormalized = customDiffuseColor.rgbNormalized; @@ -239,20 +279,36 @@ export const CreateVisorSceneGraph = (() => { rgbNormalized[2], _selected ); - await renderer!.sendPartDiffuseColorAsync(nodeId, rgbNormalized); + return true; }, async clearColorVariableAsync() { - if (_variableId === null) { - return; + if (await node.applyColorVariableAsync(null)) { + await renderer!.sendClearPartColorVariableAsync(nodeId); } - _variableId = null; - _variableComponent = -1; - _variableMin = -1; - _variableMax = -1; - await renderer!.clearColorVariableAsync(nodeId); - await renderer!.sendClearPartColorVariableAsync(nodeId); }, async setColorVariableAsync(id, component) { + if (component == null) { + return; + } + const reference = { variableId: id, variableComponent: component }; + if (await node.applyColorVariableAsync(reference)) { + await renderer!.sendPartColorVariableAsync(nodeId, appliedDescriptor()!); + } + }, + async applyColorVariableAsync(reference) { + if (reference === null) { + if (_variableId === null) { + return false; + } + _variableId = null; + _variableComponent = -1; + _variableMin = -1; + _variableMax = -1; + await renderer!.clearColorVariableAsync(nodeId); + return true; + } + const id = reference.variableId; + const component = reference.variableComponent; if (_variableId === id && _variableComponent === component) { // Already coloured by this slot: only a held range that // differs from the one last applied reaches the actor. @@ -261,15 +317,13 @@ export const CreateVisorSceneGraph = (() => { if (variableCollection.getVariable(id) == null) { warnVariableNotHeld(id); } - return; + return false; } const [heldMin, heldMax] = held.customRange; if (heldMin !== _variableMin || heldMax !== _variableMax) { await node.setScalarRangeAsync(heldMin, heldMax); } - return; - } else if (component == null) { - return; + return false; } const variable = variableCollection.getVariable(id); if (variable == null) { @@ -278,28 +332,18 @@ export const CreateVisorSceneGraph = (() => { _variableId = id; _variableComponent = component; warnVariableNotHeld(id); - return; + return false; } const rangeInfo = variable.getRangeInfo(component); if (rangeInfo == null) { - return; + return false; } - const min = rangeInfo.customRange[0]; - const max = rangeInfo.customRange[1]; _variableId = id; _variableComponent = component; - _variableMin = min; - _variableMax = max; - const descriptor = { - variableId: id, - variableType: variable.type, - variableName: variable.name, - component, - min, - max, - }; - await renderer!.setColorVariableAsync(nodeId, descriptor); - await renderer!.sendPartColorVariableAsync(nodeId, descriptor); + _variableMin = rangeInfo.customRange[0]; + _variableMax = rangeInfo.customRange[1]; + await renderer!.setColorVariableAsync(nodeId, appliedDescriptor()!); + return true; }, async setScalarRangeAsync(min, max) { _variableMin = min; @@ -307,46 +351,99 @@ export const CreateVisorSceneGraph = (() => { await renderer!.setScalarRangeAsync(nodeId, min, max); }, async setVisibilityAsync(visible) { - if (_visible === visible) { + if (node.isGroupNode) { + if (_visible === visible) { + return; + } + _visible = visible; + const promises = []; + for (const n of node.descendantActorNodesOrSelfArray) { + promises.push(n.setVisibilityAsync(visible)); + } + await Promise.all(promises); return; } + if (await node.applyVisibilityAsync(visible)) { + await renderer!.sendPartVisibilityAsync(nodeId, visible); + } + }, + async applyVisibilityAsync(visible) { + if (_visible === visible) { + return false; + } _visible = visible; if (node.isGroupNode) { const promises = []; for (const n of node.descendantActorNodesOrSelfArray) { - promises.push(n.setVisibilityAsync(visible)); + promises.push(n.applyVisibilityAsync(visible)); } await Promise.all(promises); - return; + return true; } await renderer!.setVisibilityAsync(nodeId, visible); - await renderer!.sendPartVisibilityAsync(nodeId, visible); + return true; }, async setSelectedAsync(selected) { - if (_selected === selected) { + if (node.isGroupNode) { + if (_selected === selected) { + return; + } + _selected = selected; + const promises = []; + for (const n of node.descendantActorNodesOrSelfArray) { + promises.push(n.setSelectedAsync(selected)); + } + await Promise.all(promises); return; } + if (await node.applySelectedAsync(selected)) { + // The trigger carries no colour: the server reads the part's + // stored colour from its own record. + await renderer!.sendPartSelectedAsync(nodeId, selected); + } + }, + async applySelectedAsync(selected) { + if (_selected === selected) { + return false; + } _selected = selected; if (node.isGroupNode) { const promises = []; for (const n of node.descendantActorNodesOrSelfArray) { - promises.push(n.setSelectedAsync(selected)); + promises.push(n.applySelectedAsync(selected)); } await Promise.all(promises); - return; + return true; } await renderer!.setSelectedAsync( nodeId, _selected, customDiffuseColor.rgbNormalized ); - // The trigger carries no colour: the server reads the part's - // stored colour from its own record. - await renderer!.sendPartSelectedAsync(nodeId, _selected); + return true; }, async setOpacityAsync(opacity: number) { - if (_opacity === opacity) { + if (node.isGroupNode) { + if (_opacity === opacity) { + return; + } else if (opacity < 0 || opacity > 1) { + throw new Error(`opacity must be a number between 0 and 1 (${opacity})`); + } + _opacity = opacity; + const promises = []; + for (const n of node.descendantActorNodesOrSelfArray) { + promises.push(n.setOpacityAsync(opacity)); + } + await Promise.all(promises); return; + } + if (await node.applyOpacityAsync(opacity)) { + await renderer!.sendPartOpacityAsync(nodeId, opacity); + } + }, + async applyOpacityAsync(opacity: number) { + if (_opacity === opacity) { + return false; } else if (opacity < 0 || opacity > 1) { throw new Error(`opacity must be a number between 0 and 1 (${opacity})`); } @@ -354,13 +451,13 @@ export const CreateVisorSceneGraph = (() => { if (node.isGroupNode) { const promises = []; for (const n of node.descendantActorNodesOrSelfArray) { - promises.push(n.setOpacityAsync(opacity)); + promises.push(n.applyOpacityAsync(opacity)); } await Promise.all(promises); - return; + return true; } await renderer!.setOpacityAsync(nodeId, opacity); - await renderer!.sendPartOpacityAsync(nodeId, opacity); + return true; }, get variableId() { return _variableId; diff --git a/src/ansys/visor/visor-client/src/state/appstate/VisorPartState.tsx b/src/ansys/visor/visor-client/src/state/appstate/VisorPartState.tsx index c878c97c..43f584b0 100644 --- a/src/ansys/visor/visor-client/src/state/appstate/VisorPartState.tsx +++ b/src/ansys/visor/visor-client/src/state/appstate/VisorPartState.tsx @@ -9,6 +9,9 @@ import { StateInput, } from './VisorStateCommon.tsx'; +/** A delivered part's colour-variable reference. */ +export type ColorVariableReference = Readonly<{ variableId: string; variableComponent: number }>; + export default class VisorPartState { private _id: string = ''; private _keyedByName: boolean = false; @@ -153,6 +156,11 @@ export default class VisorPartState { } } + /** The part's colour-variable reference: absent, null, or the reference. */ + get colorVariable(): ColorVariableReference | null | undefined { + return VisorPartState.colorVariableOf(this._variableId, this._variableComponent); + } + copy( state: StateInput = null, replace = false, @@ -261,7 +269,7 @@ export default class VisorPartState { static colorVariableOf( variableId: string | null | undefined, variableComponent: number | undefined - ): JsonDict | null | undefined { + ): ColorVariableReference | null | undefined { if (variableId === null) { return null; }