Skip to content

Commit 7dfd23f

Browse files
chore: seed PartIndex positionally from VisorSceneBase.add_dataset's part-node list (#66)
Co-authored-by: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com>
1 parent a2eeb1e commit 7dfd23f

14 files changed

Lines changed: 305 additions & 80 deletions

File tree

‎doc/changelog.d/66.maintenance.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Seed PartIndex positionally from VisorSceneBase.add_dataset's part-node list

‎src/ansys/visor/viewer/vtk/datasets/part_index.py‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
"""
22
PartIndex: owns stable runtime part-ID assignment for a dataset.
33
4-
Built once from a vtkDataObject at add_dataset time. IDs are seeded from
5-
the scene-graph node IDs (same JavaScript-safe integer convention) so that
4+
Built once from a vtkDataObject at add_dataset time. IDs are seeded
5+
positionally: the seed is a sequence of scene-graph node IDs (same
6+
JavaScript-safe integer convention) indexed by flat_index, so that
67
part_id == scene-graph node ID -- the contract the frontend relies on to
78
apply per-part state (opacity, visibility, color) to the right VTK actor.
8-
If no seed is provided for a given name, a fresh random ID is generated as
9-
a fallback. IDs are session-scoped -- they do not survive a server restart.
9+
A position with no seed entry takes a fresh random ID as a fallback.
10+
IDs are session-scoped -- they do not survive a server restart.
1011
The name <-> id bridge exists so that VisorStateMapper can translate between
1112
the runtime layer (IDs) and the persistence layer (names) for
1213
save_state / load_state.
1314
"""
1415
from __future__ import annotations
1516

17+
from collections.abc import Sequence
1618
from dataclasses import dataclass
1719

1820
from vtkmodules.vtkCommonDataModel import vtkCompositeDataSet, vtkDataObject
@@ -43,16 +45,20 @@ class PartIndex:
4345
--------
4446
- part_id values are assigned once and never change for the lifetime
4547
of the dataset.
48+
- part_id is seeded positionally from the scene-graph node ID at the
49+
same flat_index, so part_id == scene-graph node ID. Part names play
50+
no part in runtime identity.
4651
- Part names must be unique within a dataset for save_state / load_state
4752
round-trips to be reliable (the same constraint already applies to
4853
dataset names in the registry).
4954
- Part IDs are NOT stable across server restarts.
5055
"""
5156

52-
def __init__(self, data: vtkDataObject, dataset_name: str = "", seed_ids: dict[str, int] | None = None):
57+
def __init__(self, data: vtkDataObject, dataset_name: str = "", seed_ids: Sequence[int] | None = None):
5358
self._parts: dict[int, PartEntry] = {} # part_id -> entry
5459
self._name_to_id: dict[str, int] = {}
55-
self._seed_ids: dict[str, int] = seed_ids or {}
60+
# Positional seed: scene-graph node IDs in leaf order, indexed by flat_index.
61+
self._seed_ids: tuple[int, ...] | None = tuple(seed_ids) if seed_ids is not None else None
5662
self._build(data, dataset_name)
5763

5864
# ------------------------------------------------------------------
@@ -63,6 +69,7 @@ def _build(self, data: vtkDataObject, dataset_name: str) -> None:
6369
"""Build the part IDs and names."""
6470
if not is_composite_dataset(data):
6571
self._add_entry(name=dataset_name, flat_index=0)
72+
leaf_count = 1
6673
else:
6774
composite = data # type: vtkCompositeDataSet
6875
it = composite.NewIterator()
@@ -80,16 +87,29 @@ def _build(self, data: vtkDataObject, dataset_name: str) -> None:
8087
logger.warning(
8188
f"Duplicate part name '{name}' detected in dataset. "
8289
f"The name→id map will only retain the last part with this name, "
83-
f"which means save_state/load_state will be unreliable for these parts. "
84-
f"Part IDs remain unique and all runtime operations are unaffected."
90+
f"which means save_state/load_state will be unreliable for these parts."
8591
)
8692
self._add_entry(name=name, flat_index=flat_idx)
8793
flat_idx += 1
8894
it.GoToNextItem()
95+
leaf_count = flat_idx
96+
97+
# A seed that does not cover exactly one position per leaf means the seed
98+
# source and this traversal disagree; that is a broken invariant.
99+
if self._seed_ids is not None and len(self._seed_ids) != leaf_count:
100+
logger.error(
101+
f"Part ID seed length {len(self._seed_ids)} does not match leaf count {leaf_count} "
102+
f"for dataset '{dataset_name}'. Unseeded positions were assigned random part IDs, "
103+
f"so part_id == scene-graph node ID does not hold for them."
104+
)
89105

90106
def _add_entry(self, name: str, flat_index: int) -> PartEntry:
91107
"""Add a new entry to the part IDs and names."""
92-
pid = self._seed_ids.get(name) or get_random_javascript_safe_id()
108+
seed = self._seed_ids
109+
if seed is not None and 0 <= flat_index < len(seed):
110+
pid = seed[flat_index] # 0 is a valid JavaScript-safe ID; do not use `or`
111+
else:
112+
pid = get_random_javascript_safe_id()
93113
entry = PartEntry(part_id=pid, name=name, flat_index=flat_index)
94114
self._parts[pid] = entry
95115
self._name_to_id[name] = pid # last writer wins on collision
@@ -133,7 +153,7 @@ def get_leaf_block(self, part_id: int, data: vtkDataObject) -> vtkDataObject | N
133153

134154
@property
135155
def name_to_id_map(self) -> dict[str, int]:
136-
"""Return a dictionary that maps part IDs to ids."""
156+
"""Return a dictionary that maps part names to part IDs (last writer wins)."""
137157
return dict(self._name_to_id)
138158

139159
@property

‎src/ansys/visor/viewer/vtk/datasets/visor_dataset.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Class representing a Visor dataset."""
22

33
import copy
4+
from collections.abc import Sequence
45
from typing import Dict, List
56

67
import numpy as np
@@ -29,15 +30,17 @@
2930
class VisorDataset:
3031
"""Base class for Visor datasets."""
3132

32-
def __init__(self, id: int, name: str, data: vtkDataObject, part_name_to_id: Dict[str, int], metadata: ExtendedMetadata):
33+
def __init__(self, id: int, name: str, data: vtkDataObject, node_ids: Sequence[int] | None,
34+
metadata: ExtendedMetadata):
3335
self.id: int = id
3436
self.data: vtkDataObject = data
3537

3638
# PartIndex is the single source of truth for part topology and IDs.
3739
# It is built directly from the VTK data object — no scene graph required.
38-
# seed_ids pins part IDs to the scene-graph node IDs so the frontend can
39-
# correlate part_states entries with VTK actor properties.
40-
self.part_index: PartIndex = PartIndex(data, name, seed_ids=part_name_to_id)
40+
# node_ids is the positional seed (scene-graph node IDs in leaf order); it
41+
# pins part IDs to those node IDs so the frontend can correlate part_states
42+
# entries with VTK actor properties.
43+
self.part_index: PartIndex = PartIndex(data, name, seed_ids=node_ids)
4144

4245
# Get the dataset info from metadata
4346
self.info: VisorDatasetInfo = self._build_dataset_info(id, name, metadata)

‎src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Registry for managing multiple Visor datasets within a scene."""
22

33
import re
4+
from collections.abc import Sequence
45
from typing import Dict, List
56

67
from ansys.visor.viewer.core.metadata import ExtendedMetadata
@@ -85,7 +86,7 @@ def add(self,
8586
dataset_id: int,
8687
dataset_name: str,
8788
input: VisorDatasetType,
88-
part_name_to_id: Dict[str, int],
89+
node_ids: Sequence[int] | None,
8990
metadata: ExtendedMetadata,
9091
) -> VisorDataset:
9192
"""
@@ -94,8 +95,8 @@ def add(self,
9495
self._update_unit(metadata)
9596

9697
# VisorDataset builds its own PartIndex directly from the VTK data object,
97-
# seeded with the scene-graph node IDs so part_id == scene-graph node ID.
98-
dataset = VisorDataset(dataset_id, dataset_name, input, part_name_to_id, metadata)
98+
# seeded positionally with the scene-graph node IDs so part_id == node ID.
99+
dataset = VisorDataset(dataset_id, dataset_name, input, node_ids, metadata)
99100

100101
# Store the dataset in the registry
101102
self.datasets[dataset_id] = dataset

‎src/ansys/visor/viewer/vtk/scene/base.py‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -298,17 +298,22 @@ def add_dataset(self, input: VisorDatasetType, metadata: ExtendedMetadata) -> in
298298
# Load the dataset into the scene graph
299299
dataset_id = self._scene_graph.load_dataset(input, dataset_name)
300300

301-
# Register each leaf's pipeline with the renderer.
301+
# Register each leaf's pipeline with the renderer. The same list object
302+
# is the positional seed below, so the PartIndex entries and the
303+
# renderer's pipeline keys are the same node IDs by construction.
304+
node_ids: list[int] | None = None
302305
subtree = self._scene_graph.get_descendant_node(dataset_id, include_self=True)
303306
if subtree is not None:
304-
for leaf in subtree.get_descendant_part_nodes(include_self=True):
307+
part_nodes = subtree.get_descendant_part_nodes(include_self=True)
308+
for leaf in part_nodes:
305309
self._renderer.register_node(leaf, leaf.dataset)
306310

307-
# Seed PartIndex with scene-graph node IDs so that part_id == scene-graph node ID,
308-
# which is the contract the frontend relies on to apply per-part state (opacity etc.).
309-
part_name_to_id = self._scene_graph.get_part_name_to_id_map(dataset_id)
311+
# Seed PartIndex positionally with scene-graph node IDs so that
312+
# part_id == scene-graph node ID, which is the contract the frontend
313+
# relies on to apply per-part state (opacity etc.).
314+
node_ids = [leaf.id for leaf in part_nodes]
310315

311-
self._dataset_registry.add(dataset_id, dataset_name, input, part_name_to_id, metadata)
316+
self._dataset_registry.add(dataset_id, dataset_name, input, node_ids, metadata)
312317

313318
return dataset_id
314319

‎src/ansys/visor/viewer/vtk/scene_graph/base_node.py‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -179,15 +179,6 @@ def descendant_part_count(self, include_self=False) -> int:
179179
"""Return the number of descendant part nodes (optionally including self)."""
180180
return len(self.get_descendant_part_nodes(include_self=include_self))
181181

182-
def get_descendant_node_name_to_id_map(self) -> dict[str, int]:
183-
"""
184-
Get a mapping of part names to their corresponding node IDs
185-
for a specific dataset.
186-
187-
Returns:
188-
dict[str, int]: A dictionary mapping part names to node IDs.
189-
"""
190-
return {node.name: id for id, node in self._get_descendant_node_dict(include_self=True).items() if node.name is not None}
191182

192183
@staticmethod
193184
def get_node(

‎src/ansys/visor/viewer/vtk/scene_graph/root_node.py‎

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -117,18 +117,3 @@ def remove_dataset(self, dataset_id: int):
117117
self._dataset_bounds.remove(dataset_id)
118118
logger.debug(f"Removed dataset with ID: {dataset_id}")
119119

120-
def get_part_name_to_id_map(self, dataset_id: int) -> dict[str, int]:
121-
"""
122-
Get a mapping of part names to their corresponding node IDs
123-
for a specific dataset.
124-
125-
Args:
126-
dataset_id (int): The ID of the dataset.
127-
Returns:
128-
dict[str, int]: A dictionary mapping part names to node IDs.
129-
"""
130-
dataset_node = self.get_descendant_node(dataset_id)
131-
if not dataset_node:
132-
logger.warning(f"Dataset with ID {dataset_id} not found in scene graph.")
133-
return {}
134-
return dataset_node.get_descendant_node_name_to_id_map()

‎tests/integration/test_input_data.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -426,9 +426,9 @@ def test_vtkhdf_multiblock_block_names_are_preserved():
426426
root_node.load_dataset(dataset, None)
427427

428428
file_node = root_node.children[0]
429-
name_to_id = file_node.get_descendant_node_name_to_id_map()
429+
node_names = {node.name for node in file_node.get_descendant_nodes(include_self=True)}
430430
# simple_multiblock.vtkhdf was written with a block named "sphere"
431-
assert "sphere" in name_to_id
431+
assert "sphere" in node_names
432432

433433

434434
def test_vtkhdf_polydata_scene_graph_bounds_are_non_trivial():
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# python
2+
# File: `tests/integration/test_part_identity_many_blocks.py`
3+
"""Part identity over an unnamed, ragged multiblock.
4+
5+
``many_blocks.vtm`` is 66 vtkPolyData leaves nested at four different depths,
6+
and not one of its 66 ``<DataSet>`` elements carries a ``name`` attribute, so
7+
every leaf takes the ``"untitled"`` fallback. Under the old name-keyed seed
8+
the whole file collapsed to a single part. These tests pin the positional
9+
seed: one part per leaf, part_id == scene-graph node ID == renderer pipeline
10+
key, and flat_index order equal to part-node order on object identity.
11+
12+
All expected counts are hand-written literals, never derived from the VTK
13+
object under test.
14+
"""
15+
import os
16+
17+
import pytest
18+
from trame.app import get_server
19+
20+
from ansys.visor.viewer.core.metadata import ExtendedMetadata
21+
from ansys.visor.viewer.vtk.io.file_to_dataset import file_to_dataset
22+
from ansys.visor.viewer.vtk.scene.local_scene import VisorLocalScene
23+
24+
# many_blocks.vtm holds exactly 66 non-empty leaf blocks.
25+
MANY_BLOCKS_LEAF_COUNT = 66
26+
27+
28+
@pytest.fixture
29+
def pipeline_instance():
30+
"""PyTest fixture for pipeline instance."""
31+
server = get_server()
32+
assert server is not None, "get_server() returned None"
33+
scene = VisorLocalScene(server)
34+
yield scene
35+
try:
36+
scene.clear()
37+
scene.cleanup_state()
38+
except Exception:
39+
pass
40+
41+
42+
def get_many_blocks_file():
43+
"""Get the path to the ragged, unnamed multiblock test file."""
44+
return os.path.join(os.path.dirname(__file__), "..", "files", "many_blocks", "many_blocks.vtm")
45+
46+
47+
@pytest.fixture
48+
def loaded_many_blocks(pipeline_instance):
49+
"""Load many_blocks.vtm through the real scene and return the pieces under test."""
50+
dataset = file_to_dataset(get_many_blocks_file())
51+
metadata = ExtendedMetadata(name="many_blocks", unit="m")
52+
dataset_id = pipeline_instance.add_dataset(dataset, metadata)
53+
54+
subtree = pipeline_instance._scene_graph.get_descendant_node(dataset_id, include_self=True)
55+
part_nodes = subtree.get_descendant_part_nodes(include_self=True)
56+
part_index = pipeline_instance.datasets[dataset_id].part_index
57+
58+
return pipeline_instance, dataset, part_nodes, part_index
59+
60+
61+
def test_part_index_length_matches_leaf_count(loaded_many_blocks):
62+
"""One part per non-empty leaf, all IDs distinct.
63+
64+
Before the positional seed this was 1: all 66 leaves are named "untitled",
65+
so the name-keyed seed handed the same ID to every one of them.
66+
"""
67+
_scene, _data, part_nodes, part_index = loaded_many_blocks
68+
69+
assert len(part_index.part_ids) == MANY_BLOCKS_LEAF_COUNT
70+
assert len(set(part_index.part_ids)) == MANY_BLOCKS_LEAF_COUNT
71+
assert len(part_nodes) == MANY_BLOCKS_LEAF_COUNT
72+
73+
74+
def test_flat_index_order_matches_part_node_order_by_object_identity(loaded_many_blocks):
75+
"""Composite-iterator order and part-node order refer to the same leaves.
76+
77+
The discriminator is Python object identity of the leaf ``vtkDataObject``,
78+
not bounds: two leaves sharing a bounding box would match by accident.
79+
"""
80+
_scene, data, part_nodes, part_index = loaded_many_blocks
81+
82+
assert len(part_nodes) == MANY_BLOCKS_LEAF_COUNT
83+
for node in part_nodes:
84+
assert part_index.get_leaf_block(node.id, data) is node.dataset
85+
86+
87+
def test_part_ids_equal_node_ids_equal_pipeline_keys(loaded_many_blocks):
88+
"""part_id == scene-graph node ID == renderer pipeline key, as three equal sets."""
89+
scene, _data, part_nodes, part_index = loaded_many_blocks
90+
91+
part_ids = set(part_index.part_ids)
92+
node_ids = {node.id for node in part_nodes}
93+
pipeline_keys = set(scene._renderer._pipelines.keys())
94+
95+
assert len(part_ids) == MANY_BLOCKS_LEAF_COUNT
96+
assert part_ids == node_ids
97+
assert part_ids == pipeline_keys
98+

0 commit comments

Comments
 (0)