Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changelog.d/169.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Normalize VTKHDF long arrays to fixed width for the wasm client
158 changes: 158 additions & 0 deletions src/ansys/visor/viewer/vtk/io/client_dataset_normalizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Normalize platform-width VTK arrays before a dataset is served to the wasm client.

WORKAROUND for an upstream VTK serialization defect: ``vtkObjectManager``
serializes ``vtkLongArray`` / ``vtkUnsignedLongArray`` by class name only.
On an LP64 server (Linux, macOS) ``long`` is 8 bytes; in the wasm32
vtkWebAssembly client it is 4 bytes, so the client rebuilds those arrays
with the wrong element width and reads every 64-bit value as two 32-bit
halves. ``vtkHDFReader`` returns 64-bit integer data, including cell
connectivity and offsets, as ``vtkLongArray`` on LP64 hosts, so a dataset
read back from VTKHDF (for example a ``load_state`` snapshot) renders empty
or crashes the client with ``std::bad_alloc``.

Scope: applied only to datasets Visor itself reads from VTKHDF, in
``file_to_dataset``. Those objects are owned by Visor, so they are converted
in place. In-memory datasets supplied by a caller are deliberately not
converted (that would mutate objects the caller still holds); long-backed
in-memory data remains affected by the upstream defect.

Remove this module, and its call in ``file_to_dataset``, once the VTK version
Visor depends on serializes platform-width arrays with a fixed element width.
"""

from vtkmodules.vtkCommonCore import (
vtkAbstractArray,
vtkDataArray,
vtkTypeInt64Array,
vtkTypeUInt64Array,
)
from vtkmodules.vtkCommonDataModel import (
vtkCellArray,
vtkCompositeDataSet,
vtkDataObject,
vtkDataSet,
vtkDataSetAttributes,
vtkFieldData,
vtkPolyData,
vtkUnstructuredGrid,
)

# Matched by exact class name, not isinstance: whether the fixed-width
# classes derive from the platform-width ones depends on the VTK build.
_FIXED_WIDTH_REPLACEMENTS = {
"vtkLongArray": vtkTypeInt64Array,
"vtkUnsignedLongArray": vtkTypeUInt64Array,
}


class ClientDatasetNormalizer:
"""Rewrites platform-width VTK array classes into fixed-width ones before a dataset reaches the wasm client.

Temporary workaround for the upstream VTK serialization defect described
in this module's docstring; remove once that defect is fixed upstream.
"""

@staticmethod
def normalize_dataset_for_client(dataset: vtkDataObject) -> int:
"""Normalize every leaf of *dataset* in place; return the number of arrays replaced (0 means nothing changed).

Values, array names, array order and active-attribute roles are
preserved; only the array classes change. Objects that are neither a
``vtkDataSet`` nor a ``vtkCompositeDataSet`` are left untouched.
"""
if isinstance(dataset, vtkCompositeDataSet):
replaced = ClientDatasetNormalizer._normalize_field_data(dataset.GetFieldData())
iterator = dataset.NewIterator()
iterator.SkipEmptyNodesOn()
iterator.InitTraversal()
while not iterator.IsDoneWithTraversal():
leaf = iterator.GetCurrentDataObject()
if isinstance(leaf, vtkDataSet):
replaced += ClientDatasetNormalizer._normalize_leaf(leaf)
iterator.GoToNextItem()
return replaced
if isinstance(dataset, vtkDataSet):
return ClientDatasetNormalizer._normalize_leaf(dataset)
return 0

@staticmethod
def _normalize_leaf(leaf: vtkDataSet) -> int:
"""Normalize one non-composite dataset's cell arrays and its point, cell and field data."""
replaced = sum(
ClientDatasetNormalizer._normalize_cell_array(cells)
for cells in ClientDatasetNormalizer._cell_arrays_of(leaf)
)
for field_data in (leaf.GetPointData(), leaf.GetCellData(), leaf.GetFieldData()):
replaced += ClientDatasetNormalizer._normalize_field_data(field_data)
if replaced:
leaf.Modified()
return replaced

@staticmethod
def _cell_arrays_of(leaf: vtkDataSet) -> list[vtkCellArray]:
"""Return the cell arrays a leaf carries: verts/lines/polys/strips for polydata, cells for an unstructured grid."""
if isinstance(leaf, vtkPolyData):
candidates = [leaf.GetVerts(), leaf.GetLines(), leaf.GetPolys(), leaf.GetStrips()]
elif isinstance(leaf, vtkUnstructuredGrid):
candidates = [leaf.GetCells()]
else:
candidates = []
return [cells for cells in candidates if cells is not None]

@staticmethod
def _normalize_cell_array(cells: vtkCellArray) -> bool:
"""Re-seat generic-storage offsets and connectivity as vtkTypeInt64Array; True if changed."""
if not cells.IsStorageGeneric():
return False
offsets = vtkTypeInt64Array()
offsets.DeepCopy(cells.GetOffsetsArray())
connectivity = vtkTypeInt64Array()
connectivity.DeepCopy(cells.GetConnectivityArray())
cells.SetData(offsets, connectivity)
return True

@staticmethod
def _normalize_field_data(field_data: vtkFieldData | None) -> int:
"""Replace vtkLongArray/vtkUnsignedLongArray with fixed-width copies, keeping array order, names and active-attribute roles."""
if field_data is None:
return 0
arrays = [field_data.GetAbstractArray(i) for i in range(field_data.GetNumberOfArrays())]
replacements = [ClientDatasetNormalizer._fixed_width_copy(array) for array in arrays]
changed = [i for i, replacement in enumerate(replacements) if replacement is not None]
if not changed:
return 0

# Active-attribute roles, as (attribute type, array position).
roles: list[tuple[int, int]] = []
if isinstance(field_data, vtkDataSetAttributes):
for attribute_type in range(vtkDataSetAttributes.NUM_ATTRIBUTES):
active = field_data.GetAbstractAttribute(attribute_type)
for position, array in enumerate(arrays):
if active is not None and active is array:
roles.append((attribute_type, position))

# vtkFieldData cannot replace an array in place, so the tail from the
# first changed position is removed and re-appended in order.
first = changed[0]
for position in reversed(range(first, len(arrays))):
field_data.RemoveArray(position)
for position in range(first, len(arrays)):
replacement = replacements[position]
field_data.AddArray(replacement if replacement is not None else arrays[position])
for attribute_type, position in roles:
field_data.SetActiveAttribute(position, attribute_type)
return len(changed)

@staticmethod
def _fixed_width_copy(array: vtkAbstractArray | None) -> vtkDataArray | None:
"""Return a vtkTypeInt64Array/vtkTypeUInt64Array deep copy of a long array, or None if no conversion is needed."""
if array is None:
return None
replacement_class = _FIXED_WIDTH_REPLACEMENTS.get(array.GetClassName())
if replacement_class is None:
return None
replacement = replacement_class()
replacement.DeepCopy(array)
replacement.SetName(array.GetName())
return replacement

15 changes: 15 additions & 0 deletions src/ansys/visor/viewer/vtk/io/file_to_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
)

from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
from ansys.visor.viewer.vtk.io.client_dataset_normalizer import ClientDatasetNormalizer

"""Logging configuration"""
logger = VisorDefaultLogger(__name__)
Expand Down Expand Up @@ -70,6 +71,20 @@ def file_to_dataset(file_path: str) -> vtkDataSet | vtkCompositeDataSet:
# Release the reader and its file handle
del reader

if is_hdf:
# WORKAROUND (upstream VTK defect, see ClientDatasetNormalizer):
# vtkHDFReader returns 64-bit integers as platform-width long
# arrays on LP64, which the wasm32 client misreads. *copy* is
# owned by Visor, so converting it in place is safe. Remove this
# block with ClientDatasetNormalizer once fixed upstream.
replaced = ClientDatasetNormalizer.normalize_dataset_for_client(copy)
if replaced:
logger.info(
"Normalized %d array(s) of VTKHDF dataset '%s' to fixed-width types for the client.",
replaced,
file_path,
)

logger.debug(f"Finished creating dataset from file: {file_path}")
return copy
except RuntimeError as e:
Expand Down
36 changes: 27 additions & 9 deletions tests/e2e/regressions/test_save_load_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
"""

import json
import sys
from pathlib import Path

import pytest
Expand Down Expand Up @@ -41,6 +40,21 @@ def _save_state_sync(visor, state_dir: str, force_snapshot: bool = False) -> Non
runner.run_coroutine_block(visor.save_state(state_dir), False)


def _served_connectivity_states(visor) -> list[dict]:
"""Return the serialized state of each pipeline's polygon connectivity array.

This is the state the wasm client receives. A ``vtkLongArray`` here is
misread by the wasm32 client on LP64 servers (#122).
"""
renderer = visor._scene._renderer
object_manager = renderer._object_manager
states = []
for pipe in renderer._pipelines.values():
connectivity = pipe.mapper.GetInput().GetPolys().GetConnectivityArray()
states.append(json.loads(object_manager.GetState(object_manager.GetId(connectivity))))
return states


@pytest.mark.regression
class TestSaveLoadState:
"""Regression tests for save/load state functionality."""
Expand Down Expand Up @@ -143,14 +157,6 @@ def test_save_load_state_file_content_valid(self, visor_server, page, tmp_path):
f"Dataset '{name}' has empty serialized_dataset_path"
)

@pytest.mark.xfail(
sys.platform != "win32",
run=False,
reason="#122: load_state into an empty scene does not render on "
"Linux, and the shared server is not recoverable afterwards, "
"so stopping this test from running there. The post-load checks "
"pass on the failing state, which is why this was not caught earlier."
)
def test_load_state_into_empty_scene(self, visor_server, page, tmp_path):
"""Loading state into an empty scene should restore datasets from snapshots.

Expand All @@ -160,6 +166,9 @@ def test_load_state_into_empty_scene(self, visor_server, page, tmp_path):
state_dir = str(tmp_path / "state")
Path(state_dir).mkdir()

console_errors = []
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)

# Frontend must be connected for save_state
goto_and_wait(page, visor_server.url)
wait_for_canvas(page)
Expand Down Expand Up @@ -188,3 +197,12 @@ def test_load_state_into_empty_scene(self, visor_server, page, tmp_path):
wait_for_canvas(page)
wait_for_webgl(page, timeout_ms=15_000)
assert_canvas_alive(page, "after load_state into empty scene")

# The canvas checks above also pass on an empty canvas (#122), so check
# that geometry reached the client in a type wasm32 reads correctly.
served = _served_connectivity_states(visor_server)
assert len(served) > 0, "No pipelines after load_state into empty scene"
for state in served:
assert state["ClassName"] not in ("vtkLongArray", "vtkUnsignedLongArray"), state
assert state["NumberOfTuples"] > 0, state
assert [e for e in console_errors if "bad_alloc" in e] == []
Loading
Loading