From 38c5e9e4cbe52c8693b11608cd850385716845e1 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Fri, 28 Aug 2026 12:11:51 -0700 Subject: [PATCH 1/4] add per-part write path to the dataset registry --- .../viewer/vtk/datasets/visor_dataset.py | 14 +- .../vtk/datasets/visor_dataset_registry.py | 180 ++++++++++++++++- .../datasets/test_visor_dataset_registry.py | 190 ++++++++++++++++++ 3 files changed, 382 insertions(+), 2 deletions(-) diff --git a/src/ansys/visor/viewer/vtk/datasets/visor_dataset.py b/src/ansys/visor/viewer/vtk/datasets/visor_dataset.py index 8ba4b8d3..b91ff35a 100644 --- a/src/ansys/visor/viewer/vtk/datasets/visor_dataset.py +++ b/src/ansys/visor/viewer/vtk/datasets/visor_dataset.py @@ -88,7 +88,19 @@ def list_variables(self) -> List[VisorPartVariables]: return result def set_state(self, new_state: PersistedDatasetState) -> None: - """Set the state of the dataset.""" + """ + Set the state of the dataset from persisted, name-keyed part state. + + This is the persisted-state conversion path: it converts + new_state.parts (keyed by part name) into this dataset's runtime + state (keyed by part ID) via persisted_to_runtime_state. + + Note: a second, id-keyed replacement path also exists, on the + registry rather than here: VisorDatasetRegistry.replace_part_states + replaces a dataset's .state directly with an already-runtime, + id-keyed RuntimeDatasetState, without going through this method or + its name-to-id conversion. + """ self.state = self.persisted_to_runtime_state(new_state.parts) def mark_clean(self) -> None: diff --git a/src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py b/src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py index 0b7e4d90..1ada79a5 100644 --- a/src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py +++ b/src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py @@ -6,7 +6,10 @@ from ansys.visor.viewer.core.metadata import ExtendedMetadata from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger from ansys.visor.viewer.core.visor_types import VisorDatasetType -from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import RuntimeDatasetState +from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( + RuntimeDatasetState, + RuntimePartProperties, +) from ansys.visor.viewer.vtk.datasets.visor_dataset import VisorDataset from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables from ansys.visor.viewer.vtk.variables.visor_variable_update import VisorVariableUpdate @@ -106,6 +109,181 @@ def remove(self, dataset_id: int) -> None: if dataset_id in self.datasets: self.datasets.pop(dataset_id) + # ------------------------------------------------------------------ + # Per-part state: write path + # ------------------------------------------------------------------ + + def find_dataset_id_for_part(self, part_id: int) -> int | None: + """ + Find the ID of the dataset that owns the given part. + + Args: + part_id (int): The scene-graph node ID identifying the part. + + Returns: + int | None: The dataset ID that owns the part, or None if no + dataset in the registry has this part_id in its PartIndex. + """ + for dataset_id, dataset in self.datasets.items(): + if part_id in dataset.part_index.part_ids: + return dataset_id + return None + + def get_part_state(self, part_id: int) -> RuntimePartProperties | None: + """ + Get the current runtime state record for a single part. + + This is a read-only lookup: unlike the setters, it does not upsert + a record for a part that has no state yet. + + Args: + part_id (int): The scene-graph node ID identifying the part. + + Returns: + RuntimePartProperties | None: The live state record for the + part, or None if part_id resolves to no dataset, or the owning + dataset has no recorded state for it. + """ + dataset_id = self.find_dataset_id_for_part(part_id) + if dataset_id is None: + return None + return self.datasets[dataset_id].state.part_states.get(part_id) + + def _get_or_create_part_state(self, part_id: int) -> RuntimePartProperties | None: + """ + Resolve the live part-state record for part_id, upserting if needed. + + If the owning dataset has no part_states entry yet for part_id (e.g. + a freshly added dataset with no persisted part state), a + RuntimePartProperties(id=part_id) record is created and inserted + into the dataset's part_states dict first. This is mandatory: a + freshly added dataset has an empty part_states dict, so without + this upsert every setter would silently no-op for it. + + Returns: + RuntimePartProperties | None: The live state record, or None if + part_id resolves to no dataset. + """ + dataset_id = self.find_dataset_id_for_part(part_id) + if dataset_id is None: + return None + dataset = self.datasets[dataset_id] + part_state = dataset.state.part_states.get(part_id) + if part_state is None: + part_state = RuntimePartProperties(id=part_id) + dataset.state.part_states[part_id] = part_state + return part_state + + def set_part_visibility(self, part_id: int, visible: bool) -> bool: + """ + Set whether a part is visible. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.visible = visible + return True + + def set_part_opacity(self, part_id: int, opacity: float) -> bool: + """ + Set a part's opacity. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.opacity = opacity + return True + + def set_part_diffuse_color(self, part_id: int, diffuse_rgb: list[float] | None) -> bool: + """ + Set a part's custom diffuse colour, or clear it with None. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.diffuse_rgb = diffuse_rgb + return True + + def set_part_selected(self, part_id: int, selected: bool) -> bool: + """ + Set whether a part is selected. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.selected = selected + return True + + def set_part_color_variable(self, part_id: int, variable_id: str, component: int | None) -> bool: + """ + Set the variable a part is coloured by, and its component. + + variable_id and component are set together, atomically, in this one + call, mirroring clear_part_color_variable's atomic clear. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.spectrum_id = variable_id + part_state.spectrum_component = component + return True + + def clear_part_color_variable(self, part_id: int) -> bool: + """ + Clear the variable a part is coloured by. + + Sets spectrum_id and spectrum_component to None together, in one + call — the compound class is only ever set or cleared atomically, + never field by field. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.spectrum_id = None + part_state.spectrum_component = None + return True + + def replace_part_states(self, dataset_states: Dict[int, RuntimeDatasetState]) -> None: + """ + Replace the runtime state of registered datasets in bulk. + + For each dataset ID present in both dataset_states and the + registry, that dataset's .state is replaced directly with the + supplied RuntimeDatasetState (already-runtime, id-keyed input). + Dataset IDs in dataset_states that are not present in the registry + are skipped silently; processing continues for the remaining + entries. Never raises. + """ + for dataset_id, runtime_state in dataset_states.items(): + dataset = self.datasets.get(dataset_id) + if dataset is None: + continue + dataset.state = runtime_state + def _get_unique_dataset_name(self, name: str) -> str: """ Rename the dataset by appending a suffix to ensure uniqueness. diff --git a/tests/unit/vtk/datasets/test_visor_dataset_registry.py b/tests/unit/vtk/datasets/test_visor_dataset_registry.py index d55ee2d6..15296e9e 100644 --- a/tests/unit/vtk/datasets/test_visor_dataset_registry.py +++ b/tests/unit/vtk/datasets/test_visor_dataset_registry.py @@ -3,6 +3,10 @@ import pytest +from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( + RuntimeDatasetState, + RuntimePartProperties, +) from ansys.visor.viewer.vtk.datasets.visor_dataset_registry import VisorDatasetRegistry @@ -23,6 +27,19 @@ def make_mock_dataset(name="ds", info_dict=None, parts=None, variables=None): return mock +def make_part_dataset(dataset_id, part_ids, part_states=None): + """ + Build a dataset stand-in with a real PartIndex.part_ids list and a real + RuntimeDatasetState (not MagicMock), so the per-part write path can + mutate and be read back through actual object identity. + """ + dataset = MagicMock() + dataset.part_index = MagicMock() + dataset.part_index.part_ids = list(part_ids) + dataset.state = RuntimeDatasetState(id=dataset_id, part_states=part_states or {}) + return dataset + + def test_count_returns_number_of_datasets(registry): """Verify that count returns the number of registered datasets.""" assert registry.count == 0 @@ -180,3 +197,176 @@ def test_update_unit_matches_keeps_unit(registry): registry._update_unit(metadata) assert registry.unit == "m" + + +# ================================================================== # +# Per-part write path (Story 3.1, increment I1) +# ================================================================== # + +def test_find_dataset_id_for_part_selects_between_multiple_datasets(registry): + """Verify the correct dataset id is returned when several datasets are registered.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10, 11]) + ds_b = make_part_dataset(dataset_id=2, part_ids=[20, 21]) + registry.datasets = {1: ds_a, 2: ds_b} + + assert registry.find_dataset_id_for_part(20) == 2 + assert registry.find_dataset_id_for_part(11) == 1 + + +def test_find_dataset_id_for_part_not_found_returns_none(registry): + """Verify an unknown part_id resolves to no dataset.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10, 11]) + registry.datasets = {1: ds_a} + + assert registry.find_dataset_id_for_part(999999) is None + + +def test_get_part_state_returns_existing_record(registry): + """Verify get_part_state returns the recorded state for a known part.""" + existing = RuntimePartProperties(id=10, opacity=0.7) + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: existing}) + registry.datasets = {1: ds_a} + + result = registry.get_part_state(10) + assert result is existing + assert result.opacity == 0.7 + + +def test_get_part_state_known_dataset_missing_part_returns_none(registry): + """Verify get_part_state does not upsert: a part with no record returns None.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10]) + registry.datasets = {1: ds_a} + + assert registry.get_part_state(10) is None + assert ds_a.state.part_states == {} + + +def test_get_part_state_unknown_part_id_returns_none(registry): + """Verify get_part_state returns None, without raising, for an unknown part_id.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10]) + registry.datasets = {1: ds_a} + + assert registry.get_part_state(999999) is None + + +def test_set_part_visibility_mutates_record_and_rejects_unknown_part(registry): + """Verify set_part_visibility writes True/False, and False + no raise for unknown part_id.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: RuntimePartProperties(id=10)}) + registry.datasets = {1: ds_a} + + assert registry.set_part_visibility(10, False) is True + assert registry.get_part_state(10).visible is False + + assert registry.set_part_visibility(999999, True) is False + + +def test_set_part_opacity_mutates_record_and_rejects_unknown_part(registry): + """Verify set_part_opacity writes the value, and False + no raise for unknown part_id.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: RuntimePartProperties(id=10)}) + registry.datasets = {1: ds_a} + + assert registry.set_part_opacity(10, 0.4) is True + assert registry.get_part_state(10).opacity == 0.4 + + assert registry.set_part_opacity(999999, 0.4) is False + + +def test_set_part_diffuse_color_mutates_and_clears(registry): + """Verify set_part_diffuse_color writes an RGB list, clears with None, and rejects unknown parts.""" + ds_a = make_part_dataset( + dataset_id=1, part_ids=[10], + part_states={10: RuntimePartProperties(id=10, diffuse_rgb=[1.0, 0.0, 0.0])}, + ) + registry.datasets = {1: ds_a} + + assert registry.set_part_diffuse_color(10, [0.0, 1.0, 0.0]) is True + assert registry.get_part_state(10).diffuse_rgb == [0.0, 1.0, 0.0] + + assert registry.set_part_diffuse_color(10, None) is True + assert registry.get_part_state(10).diffuse_rgb is None + + assert registry.set_part_diffuse_color(999999, [1.0, 1.0, 1.0]) is False + + +def test_set_part_selected_mutates_record_and_rejects_unknown_part(registry): + """Verify set_part_selected writes the value, and False + no raise for unknown part_id.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: RuntimePartProperties(id=10)}) + registry.datasets = {1: ds_a} + + assert registry.set_part_selected(10, True) is True + assert registry.get_part_state(10).selected is True + + assert registry.set_part_selected(999999, True) is False + + +def test_set_part_color_variable_sets_id_and_component_together(registry): + """Verify set_part_color_variable sets spectrum_id and spectrum_component in one call.""" + seed = RuntimePartProperties(id=10) + assert seed.spectrum_id is None + assert seed.spectrum_component is None + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: seed}) + registry.datasets = {1: ds_a} + + assert registry.set_part_color_variable(10, "POINT::pressure::1", 2) is True + + result = registry.get_part_state(10) + assert result.spectrum_id == "POINT::pressure::1" + assert result.spectrum_component == 2 + + assert registry.set_part_color_variable(999999, "POINT::x::1", 0) is False + + +def test_clear_part_color_variable_clears_id_and_component_together(registry): + """Verify clear_part_color_variable clears spectrum_id and spectrum_component in one call.""" + seed = RuntimePartProperties(id=10, spectrum_id="POINT::pressure::1", spectrum_component=2) + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: seed}) + registry.datasets = {1: ds_a} + + assert registry.clear_part_color_variable(10) is True + + result = registry.get_part_state(10) + assert result.spectrum_id is None + assert result.spectrum_component is None + + assert registry.clear_part_color_variable(999999) is False + + +def test_setter_upserts_part_state_when_part_id_known_but_absent_from_part_states(registry): + """ + Verify the upsert path: a part_id present in PartIndex.part_ids but absent + from part_states gets a RuntimePartProperties(id=part_id) created on first + write, rather than silently no-op-ing. + """ + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={}) + registry.datasets = {1: ds_a} + assert ds_a.state.part_states == {} + + assert registry.set_part_opacity(10, 0.6) is True + + created = ds_a.state.part_states.get(10) + assert created is not None + assert isinstance(created, RuntimePartProperties) + assert created.id == 10 + assert created.opacity == 0.6 + + +def test_replace_part_states_replaces_known_and_skips_unknown_without_aborting(registry): + """ + Verify replace_part_states replaces the state of a known dataset id and + silently skips an unknown dataset id in the same call, without aborting + partway (the known replacement still lands). + """ + ds_a = make_part_dataset(dataset_id=1, part_ids=[10]) + original_state = ds_a.state + registry.datasets = {1: ds_a} + + new_state_for_known = RuntimeDatasetState(id=1, part_states={10: RuntimePartProperties(id=10, opacity=0.9)}) + new_state_for_unknown = RuntimeDatasetState(id=999, part_states={}) + + registry.replace_part_states({1: new_state_for_known, 999: new_state_for_unknown}) + + assert ds_a.state is new_state_for_known + assert ds_a.state is not original_state + assert 999 not in registry.datasets + + From ac71d50afe0ec18df6e323b1d079a49cf318e00b Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Fri, 28 Aug 2026 12:44:15 -0700 Subject: [PATCH 2/4] remove story/increment reference in comment --- tests/unit/vtk/datasets/test_visor_dataset_registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/vtk/datasets/test_visor_dataset_registry.py b/tests/unit/vtk/datasets/test_visor_dataset_registry.py index 15296e9e..19047df9 100644 --- a/tests/unit/vtk/datasets/test_visor_dataset_registry.py +++ b/tests/unit/vtk/datasets/test_visor_dataset_registry.py @@ -200,7 +200,7 @@ def test_update_unit_matches_keeps_unit(registry): # ================================================================== # -# Per-part write path (Story 3.1, increment I1) +# Per-part write path # ================================================================== # def test_find_dataset_id_for_part_selects_between_multiple_datasets(registry): From ee0b843048017803824b01dd20354cff01e7082d Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:46:24 +0000 Subject: [PATCH 3/4] chore: adding changelog file 50.added.md [dependabot-skip] --- doc/changelog.d/50.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/50.added.md diff --git a/doc/changelog.d/50.added.md b/doc/changelog.d/50.added.md new file mode 100644 index 00000000..947c8ced --- /dev/null +++ b/doc/changelog.d/50.added.md @@ -0,0 +1 @@ +Remote rendering 3.1 - add per-part write path to the dataset registry From ff688aa122dd96fe2b38e8ea8dc8d951e7cc4457 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:02:36 +0000 Subject: [PATCH 4/4] chore: adding changelog file 50.added.md [dependabot-skip] --- doc/changelog.d/50.added.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changelog.d/50.added.md b/doc/changelog.d/50.added.md index 947c8ced..86f36f04 100644 --- a/doc/changelog.d/50.added.md +++ b/doc/changelog.d/50.added.md @@ -1 +1 @@ -Remote rendering 3.1 - add per-part write path to the dataset registry +Remote rendering 3.1a - add per-part write path to the dataset registry