diff --git a/doc/changelog.d/67.fixed.md b/doc/changelog.d/67.fixed.md new file mode 100644 index 00000000..92d598fb --- /dev/null +++ b/doc/changelog.d/67.fixed.md @@ -0,0 +1 @@ +Apply selected to the scene-graph node and seed tree rows from node state diff --git a/src/ansys/visor/visor-client/src/VisorFrontend.tsx b/src/ansys/visor/visor-client/src/VisorFrontend.tsx index 56860b5f..d29e1c92 100644 --- a/src/ansys/visor/visor-client/src/VisorFrontend.tsx +++ b/src/ansys/visor/visor-client/src/VisorFrontend.tsx @@ -343,7 +343,6 @@ export class VisorFrontend { panelTopRight.selectTab(uiState.panelTopRightTabIndex); } })(); - const selectedNodeIds: number[] = []; const promises: Promise[] = []; const sceneState = appState.scene; if (sceneState.unit !== undefined) { @@ -425,15 +424,30 @@ export class VisorFrontend { node = sceneGraph.descendantActorNodesOrSelfDictionary[part_state.id]; } if (node != null) { - if (part_state.selected === true) { - const idNumber = parseInt(part_state.id); - isNaN(idNumber) || selectedNodeIds.push(idNumber); - } + // Narrow once: `node` is a `let`, so the null check + // above does not survive into the callbacks below. + const partNode = node; + // The diffuse-colour path and the selection path both + // 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(); if (part_state.diffuseRgb !== undefined) { const [r, g, b] = part_state.diffuseRgb; - const promise = node.setDiffuseColorRgbAsync(r, g, b); - promises.push(promise); + partPromise = partPromise.then(() => + partNode.setDiffuseColorRgbAsync(r, g, b) + ); } + // `selected` is applied to the node, like every other + // part field, so the node owns the value and both the + // renderer and the tree read it from there. `=== true` + // holds the current semantics: a part state carrying + // null (never touched) deselects, and the node always + // receives a boolean. + partPromise = partPromise.then(() => + partNode.setSelectedAsync(part_state.selected === true) + ); + promises.push(partPromise); if (part_state.opacity !== undefined) { const promise = node.setOpacityAsync(part_state.opacity); promises.push(promise); @@ -483,7 +497,8 @@ export class VisorFrontend { await panelTopRightUtilPromise; const treeViewUtil: TreeViewUtil = await treeViewUtilPromise; - treeViewUtil.setSelection(selectedNodeIds); + // Selection and visibility are both applied to the nodes + // above; the tree derives its rows from them here. treeViewUtil.synchronize(); } await renderer.resizeAsync(); diff --git a/src/ansys/visor/visor-client/src/jest-tests/TreeView.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/TreeView.test.tsx index b4762fac..a2693174 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/TreeView.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/TreeView.test.tsx @@ -5,6 +5,7 @@ import { VisorSceneNodeExtended, VisorSceneNodeSimple, } from '../state/VisorSceneGraph.tsx'; +import { NullRenderer } from '../renderer/NullRenderer.ts'; describe('TreeView', () => { it('renders without a scene graph', () => { @@ -69,6 +70,42 @@ describe('TreeView', () => { fireEvent.click(rootRow.hideButton); expect(rootRow.visible).toBe(false); }); + test('a row seeds its icon from a node that is already hidden', async () => { + const sceneGraph = getLargeSceneGraphWithRenderer(); + const meshA = sceneGraph.descendantActorNodesOrSelfDictionaryByPartName['mesh A']; + await meshA.setVisibilityAsync(false); + let util: TreeViewUtil = null!; + render( (util = u)} />); + const meshARow = util.rowUtilsMap.get(meshA.id)!; + // showButton carries the crossed-out eye ("click to show"), so its + // presence in the rendered row is the hidden icon. + expect(meshARow.tableElem.contains(meshARow.showButton)).toBe(true); + expect(meshARow.tableElem.contains(meshARow.hideButton)).toBe(false); + expect(meshARow.visible).toBe(false); + }); + test('sibling rows stay visible when one node is hidden', async () => { + const sceneGraph = getLargeSceneGraphWithRenderer(); + const meshA = sceneGraph.descendantActorNodesOrSelfDictionaryByPartName['mesh A']; + const meshB = sceneGraph.descendantActorNodesOrSelfDictionaryByPartName['mesh B']; + await meshA.setVisibilityAsync(false); + let util: TreeViewUtil = null!; + render( (util = u)} />); + const meshBRow = util.rowUtilsMap.get(meshB.id)!; + expect(meshBRow.visible).toBe(true); + // The group above them still has a visible descendant, so it stays shown. + expect(util.rows[0].visible).toBe(true); + }); + test('a row seeds its selection from a node that is already selected', async () => { + const sceneGraph = getLargeSceneGraphWithRenderer(); + const meshC = sceneGraph.descendantActorNodesOrSelfDictionaryByPartName['mesh C']; + await meshC.setSelectedAsync(true); + let util: TreeViewUtil = null!; + render( (util = u)} />); + const meshCRow = util.rowUtilsMap.get(meshC.id)!; + expect(meshCRow.selected).toBe(true); + expect(meshCRow.tableElem.classList.contains('theme-selected-background')).toBe(true); + expect(util.selectedNodes).toContain(meshC); + }); }); function getSmallSceneGraph(): VisorSceneNodeExtended { @@ -99,98 +136,106 @@ function getSmallSceneGraph(): VisorSceneNodeExtended { function getLargeSceneGraph(): VisorSceneNodeExtended { return CreateVisorSceneGraph(getSceneNodeSimple()); +} - function getSceneNodeSimple(): VisorSceneNodeSimple { - return { - id: 0, - dataArrays: [], - name: '', - isGroupNode: true, - isActorNode: false, - nodeType: 'root', - diffuseColor: [1, 1, 1], - bounds: [], - children: [ - { - id: 1, - dataArrays: [], - name: 'some-multiblock-file.vtm', - isGroupNode: true, - isActorNode: false, - nodeType: 'vtkMultiBlockDataSet', - diffuseColor: [1, 1, 1], - bounds: [], - children: [ - { - id: 2, - dataArrays: [], - name: 'PolyData Container', - isGroupNode: true, - isActorNode: false, - nodeType: 'vtkMultiBlockDataSet', - diffuseColor: [1, 1, 1], - bounds: [], - children: [ - { - id: 3, - dataArrays: [], - name: 'mesh A', - isGroupNode: false, - isActorNode: true, - nodeType: 'vtkPolyData', - diffuseColor: [1, 1, 1], - bounds: [], - children: [], - }, - { - id: 4, - dataArrays: [], - name: 'mesh B', - isGroupNode: false, - isActorNode: true, - nodeType: 'vtkPolyData', - diffuseColor: [1, 1, 1], - bounds: [], - children: [], - }, - ], - }, - { - id: 5, - dataArrays: [], - name: 'mesh C', - isGroupNode: false, - isActorNode: true, - nodeType: 'vtkUnstructuredGrid', - diffuseColor: [1, 1, 1], - bounds: [], - children: [], - }, - { - id: 6, - dataArrays: [], - name: 'mesh D', - isGroupNode: false, - isActorNode: true, - nodeType: 'vtkUnstructuredGrid', - diffuseColor: [1, 1, 1], - bounds: [], - children: [], - }, - { - id: 7, - dataArrays: [], - name: 'mesh E', - isGroupNode: false, - isActorNode: true, - nodeType: 'vtkUnstructuredGrid', - diffuseColor: [1, 1, 1], - bounds: [], - children: [], - }, - ], - }, - ], - }; - } +/** + * The same graph, built with a real no-op renderer so that the node's own + * setVisibilityAsync/setSelectedAsync can run in a test. + */ +function getLargeSceneGraphWithRenderer(): VisorSceneNodeExtended { + return CreateVisorSceneGraph(getSceneNodeSimple(), undefined, new NullRenderer()); +} + +function getSceneNodeSimple(): VisorSceneNodeSimple { + return { + id: 0, + dataArrays: [], + name: '', + isGroupNode: true, + isActorNode: false, + nodeType: 'root', + diffuseColor: [1, 1, 1], + bounds: [], + children: [ + { + id: 1, + dataArrays: [], + name: 'some-multiblock-file.vtm', + isGroupNode: true, + isActorNode: false, + nodeType: 'vtkMultiBlockDataSet', + diffuseColor: [1, 1, 1], + bounds: [], + children: [ + { + id: 2, + dataArrays: [], + name: 'PolyData Container', + isGroupNode: true, + isActorNode: false, + nodeType: 'vtkMultiBlockDataSet', + diffuseColor: [1, 1, 1], + bounds: [], + children: [ + { + id: 3, + dataArrays: [], + name: 'mesh A', + isGroupNode: false, + isActorNode: true, + nodeType: 'vtkPolyData', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + { + id: 4, + dataArrays: [], + name: 'mesh B', + isGroupNode: false, + isActorNode: true, + nodeType: 'vtkPolyData', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + ], + }, + { + id: 5, + dataArrays: [], + name: 'mesh C', + isGroupNode: false, + isActorNode: true, + nodeType: 'vtkUnstructuredGrid', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + { + id: 6, + dataArrays: [], + name: 'mesh D', + isGroupNode: false, + isActorNode: true, + nodeType: 'vtkUnstructuredGrid', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + { + id: 7, + dataArrays: [], + name: 'mesh E', + isGroupNode: false, + isActorNode: true, + nodeType: 'vtkUnstructuredGrid', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + ], + }, + ], + }; } diff --git a/src/ansys/visor/visor-client/src/treeview/TreeView.tsx b/src/ansys/visor/visor-client/src/treeview/TreeView.tsx index 44259f1e..fa797b47 100644 --- a/src/ansys/visor/visor-client/src/treeview/TreeView.tsx +++ b/src/ansys/visor/visor-client/src/treeview/TreeView.tsx @@ -14,6 +14,8 @@ export interface ITreeViewNode> { name: string; /** Whether the node should currently be visible in the underlying scene. */ visible: boolean; + /** Whether the node is currently selected in the underlying scene. */ + selected: boolean; /** Whether the node acts as a group that may contain child rows. */ isGroupNode: boolean; /** Child nodes rendered beneath this node. */ @@ -177,6 +179,10 @@ export const TreeView = >(props: TreeViewProps) => // IIFE so we don't pollute the outer scope const rootRow = createRowUtil(sceneGraph, 0, null); treeContainer.appendChild(rootRow.nodeElem); + // Rows seed themselves from their node, but group rows are derived + // from their descendants, so reconcile once the tree is built. + updateVisibleNodesArray(null); + updateSelectedNodesArray(null); const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.placeholder = 'Search...'; @@ -297,12 +303,17 @@ export const TreeView = >(props: TreeViewProps) => } } - /** Synchronizes every row's visibility from its source node. */ + /** Synchronizes every row's visibility and selection from its source node. */ function synchronize() { rowUtilsArr.forEach((u) => { - u.node.visible ? u.show() : u.hide(); + // The node owns the value; the row derives from it. `=== false` + // and `=== true` keep a node that omits the field on the + // previous defaults (visible, not selected). + u.node.visible === false ? u.hide() : u.show(); + u.node.selected === true ? u.selectRow() : u.deselectRow(); }); updateVisibleNodesArray(null); + updateSelectedNodesArray(null); } /** @@ -615,7 +626,13 @@ export const TreeView = >(props: TreeViewProps) => runVisibilityChangeListeners(visibleNodes); e.stopImmediatePropagation(); }; - rowUtil.show(); + // Seed the row from the node, which owns the value. Testing for + // `=== false` / `=== true` rather than truthiness keeps a node that + // omits the field on the previous defaults (visible, not selected). + node.visible === false ? rowUtil.hide() : rowUtil.show(); + if (node.selected === true) { + rowUtil.selectRow(); + } rowUtilsArr.push(rowUtil); cells[1].style.paddingLeft = `${fontSize / 4}px`; depth++;