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/161.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Remote rendering 3.5c] Server creates color lookup table and client stops creating its own
7 changes: 2 additions & 5 deletions src/ansys/visor/viewer/app/trame/local_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,6 @@ class SetPartColorVariablePayload(BaseModel):
association: VisorVtkVariableType
array_name: str = Field(alias="arrayName")
component: int
min_val: float = Field(alias="min")
max_val: float = Field(alias="max")


class ClearPartColorVariablePayload(BaseModel):
Expand Down Expand Up @@ -449,9 +447,8 @@ def set_part_color_variable(self, payload) -> None:
name. ``variableId`` is forwarded verbatim and is never parsed by
the server.

``min`` and ``max`` are still required on the wire and appear on the
arrival line, but are not forwarded: the server applies its own
record's effective range for the referenced slot.
The range is not carried: the server applies its own record's
effective range for the referenced slot.
"""
api = self._mutation_api("set_part_color_variable", payload)
if api is None:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,64 +1,23 @@
import json
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator

from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState

logger = VisorDefaultLogger(__name__)


class VisorSaveStateResponse(BaseModel):
"""
Model for the response from the client to receive the runtime
viewer state, which is in turn used by the server to save the persisted state of the
Visor viewer.

The server owns the variable records, so any ``appState.scene.variableStates`` the
browser sends is discarded before validation (see :meth:`_discard_browser_variable_states`).
"""
model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True)

# A unique identifier for the load state request.
request_id: int = Field(alias="requestId")
app_state: RuntimeAppState = Field(alias="appState")

# Guards a client that still sends variableStates in the pre-record shape.
@model_validator(mode="before")
@classmethod
def _discard_browser_variable_states(cls, data: Any) -> Any:
"""Drop ``appState.scene.variableStates`` from the browser's reply.

The browser's entries lack the record's default fields and are not the
authority; ``get_state`` takes the variables from the server's record.
"""
if not isinstance(data, dict):
return data
key = "appState" if "appState" in data else ("app_state" if "app_state" in data else None)
if key is None:
return data
app_state = data[key]
if isinstance(app_state, str):
try:
app_state = json.loads(app_state)
except (TypeError, ValueError):
return data
if not isinstance(app_state, dict):
return data
scene = app_state.get("scene")
if not isinstance(scene, dict):
return data
discarded = False
for scene_key in ("variableStates", "variable_states"):
if scene_key in scene:
discarded = True
if not discarded:
return data
scene = {k: v for k, v in scene.items() if k not in ("variableStates", "variable_states")}
logger.debug("browser variableStates discarded")
return {**data, key: {**app_state, "scene": scene}}

@field_validator("app_state", mode="before")
@classmethod
def _coerce_app_state(cls, v: Any) -> Any:
Expand Down
17 changes: 0 additions & 17 deletions src/ansys/visor/viewer/renderer/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,23 +162,6 @@ def clear_color_variable(self, node_id: int) -> None:
write, and the client is not notified.
"""

@abstractmethod
def refresh_color_variable_range(
self,
node_id: int,
variable_id: str,
array_type: str,
array_name: str,
component: int,
) -> None:
"""
Update only the mapper's scalar range for *node_id* from the current
input data, without calling ``pipeline.base_algorithm.Update()``.

Safe to call after in-place data modifications where VTK's pipeline
MTime is stale.
"""

# ------------------------------------------------------------------------
# Camera
# ------------------------------------------------------------------------
Expand Down
10 changes: 0 additions & 10 deletions src/ansys/visor/viewer/renderer/local_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,16 +309,6 @@ def _serialize_part_state(self, node_id: int) -> None:
return
self._object_manager.UpdateStateFromObject(mapper_id)

def refresh_color_variable_range(
self,
node_id: int,
variable_id: str,
array_type: str,
array_name: str,
component: int,
) -> None:
"""No-op in Story 1.2. Phase 3 populates."""

# ------------------------------------------------------------------
# IRenderer: camera
# ------------------------------------------------------------------
Expand Down
44 changes: 38 additions & 6 deletions src/ansys/visor/viewer/vtk/node_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@

A self-contained VTK rendering pipeline for a single scene-graph leaf node.

Owns the three VTK objects that turn a raw dataset into something the
Owns the four VTK objects that turn a raw dataset into something the
renderer can display:

* ``base_algorithm`` -- geometry extraction (``vtkGeometryFilter`` for
unstructured grids; ``vtkAppendPolyData`` for poly data).
* ``mapper`` -- ``vtkPolyDataMapper`` connected to the algorithm output.
* ``actor`` -- ``vtkActor`` connected to the mapper.
* ``lookup_table`` -- ``vtkLookupTable`` set on the mapper.

Created when a leaf node is registered with the renderer and destroyed when
that node is deregistered. The scene-graph node itself owns only the raw
Expand All @@ -20,6 +21,7 @@
from dataclasses import dataclass
from typing import Callable, Optional

from vtkmodules.vtkCommonCore import vtkLookupTable
from vtkmodules.vtkCommonDataModel import vtkDataObject, vtkPlane, vtkPolyData, vtkUnstructuredGrid
from vtkmodules.vtkCommonExecutionModel import vtkPolyDataAlgorithm
from vtkmodules.vtkFiltersCore import vtkAppendPolyData
Expand All @@ -32,6 +34,9 @@

logger = VisorDefaultLogger(__name__)

LOOKUP_TABLE_HUE_RANGE: tuple[float, float] = (0.667, 0.0)
"""Blue (low) to red (high)."""


@dataclass
class VtkNodePipeline:
Expand All @@ -45,19 +50,23 @@ class VtkNodePipeline:
The ``vtkPolyDataMapper`` driving the actor.
base_algorithm:
The geometry-extraction algorithm whose output feeds the mapper.
lookup_table:
The part's only table: MAGNITUDE over ``LOOKUP_TABLE_HUE_RANGE``,
built, set on ``mapper``, and never reconfigured.
"""

actor: vtkActor
mapper: vtkPolyDataMapper
base_algorithm: vtkPolyDataAlgorithm
lookup_table: vtkLookupTable

# ------------------------------------------------------------------
# Factories
# ------------------------------------------------------------------

@classmethod
def from_dataset(cls, dataset: vtkDataObject) -> "VtkNodePipeline":
"""Build a complete pipeline from ``dataset``.
"""Build a complete pipeline from ``dataset``, its mapper holding a built lookup table.

Parameters
----------
Expand All @@ -69,13 +78,20 @@ def from_dataset(cls, dataset: vtkDataObject) -> "VtkNodePipeline":

mapper = vtkPolyDataMapper()
mapper.SetInputConnection(base_algorithm.GetOutputPort())
lookup_table = cls._create_lookup_table()
mapper.SetLookupTable(lookup_table)

actor = vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetDiffuseColor(VisorColors.DefaultMeshColor)
actor.GetProperty().SetInterpolation(0) # flat shading

return cls(actor=actor, mapper=mapper, base_algorithm=base_algorithm)
return cls(
actor=actor,
mapper=mapper,
base_algorithm=base_algorithm,
lookup_table=lookup_table,
)


# ------------------------------------------------------------------
Expand Down Expand Up @@ -204,9 +220,8 @@ def set_color_variable(
) -> None:
"""Colour this part by a scalar array, over an explicit range.

Configures the mapper only. No lookup table is authored here: the
table belongs to a later story, and until then a reference resolves
against the client-held default table.
Configures the mapper only. Colours through ``lookup_table``; the
component stays on the mapper (-1 is magnitude).

``association`` is compared by identity against
:class:`VisorVtkVariableType`; it is never parsed, upper-cased or
Expand Down Expand Up @@ -262,6 +277,23 @@ def clear_color_variable(self) -> None:
# Private helpers
# ------------------------------------------------------------------

@staticmethod
def _create_lookup_table() -> vtkLookupTable:
"""A MAGNITUDE table over LOOKUP_TABLE_HUE_RANGE, built, its Table array marked modified.

Created before the pipeline is registered, so the first serialize
ships it built. Any later write to this table must configure it,
call ``Build()`` and ``GetTable().Modified()``, then re-serialize the
mapper id, the table id and the ``Table`` array id inside the scene
lock, without notifying.
"""
table = vtkLookupTable()
table.SetHueRange(*LOOKUP_TABLE_HUE_RANGE)
table.SetVectorModeToMagnitude()
table.Build()
table.GetTable().Modified()
return table

@staticmethod
def _create_base_algorithm(dataset: vtkDataObject) -> vtkPolyDataAlgorithm:
if isinstance(dataset, vtkUnstructuredGrid):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,15 @@ describe('WasmRenderer per-part sends: trigger name and payload', () => {
association: 'POINT',
arrayName: 'pressure',
component: 0,
min: 2,
max: 8,
});
const payload = sender.mock.calls[0][1] as Record<string, unknown>;
expect(Object.keys(payload).sort()).toEqual([
'arrayName',
'association',
'component',
'nodeId',
'variableId',
]);
});

test('sendPartColorVariableAsync sends CELL as the association for a cell array', async () => {
Expand All @@ -257,9 +263,15 @@ describe('WasmRenderer per-part sends: trigger name and payload', () => {
association: 'CELL',
arrayName: 'temperature',
component: -1,
min: -1.5,
max: 4.5,
});
const payload = sender.mock.calls[0][1] as Record<string, unknown>;
expect(Object.keys(payload).sort()).toEqual([
'arrayName',
'association',
'component',
'nodeId',
'variableId',
]);
});

test('sendPartColorVariableAsync forwards variableId verbatim without parsing it', async () => {
Expand Down Expand Up @@ -332,7 +344,7 @@ describe('WasmRenderer per-part applies still mutate their wasm objects', () =>
expect(property.SetDiffuseColor).toHaveBeenCalledWith(1, 0, 0);
});

test('setColorVariableAsync still configures the mapper and the default table', async () => {
test('setColorVariableAsync configures the mapper and authors no table', async () => {
const { renderer, mapper, lut } = await makeRenderer(makeSender());

await renderer.setColorVariableAsync(NODE_ID, {
Expand All @@ -349,9 +361,10 @@ describe('WasmRenderer per-part applies still mutate their wasm objects', () =>
expect(mapper.SetColorModeToMapScalars).toHaveBeenCalled();
expect(mapper.ColorByArrayComponent).toHaveBeenCalledWith('pressure', 0);
expect(mapper.SetScalarVisibility).toHaveBeenCalledWith(1);
expect(mapper.CreateDefaultLookupTable).toHaveBeenCalled();
expect(lut.SetHueRange).toHaveBeenCalledWith(0.667, 0.0);
expect(lut.SetVectorModeToMagnitude).toHaveBeenCalled();
expect(mapper.CreateDefaultLookupTable).not.toHaveBeenCalled();
expect(mapper.GetLookupTable).not.toHaveBeenCalled();
expect(lut.SetHueRange).not.toHaveBeenCalled();
expect(lut.SetVectorModeToMagnitude).not.toHaveBeenCalled();
});

test('clearColorVariableAsync still turns scalar visibility off', async () => {
Expand Down
11 changes: 3 additions & 8 deletions src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ export class WasmRenderer implements IRenderer {
await wasmProperty.SetOpacity(opacity);
}

/** Configure the part's mapper to colour by the descriptor's array; it maps through the table served with the mapper. */
async setColorVariableAsync(
nodeId: NodeId,
descriptor: ColorVariableDescriptor
Expand All @@ -401,11 +402,6 @@ export class WasmRenderer implements IRenderer {
await wasmMapper.SetColorModeToMapScalars();
await wasmMapper.ColorByArrayComponent(descriptor.variableName, descriptor.component);
await wasmMapper.SetScalarVisibility(1);
// Force creation of LUT if not already done. (Alternatively, after 9.5.20250802.dev0, you can call mapper.SetLookupTable(null))
await wasmMapper.CreateDefaultLookupTable();
const lut = await wasmMapper.GetLookupTable();
await lut.SetHueRange(0.667, 0.0);
await lut.SetVectorModeToMagnitude();
}

async clearColorVariableAsync(nodeId: NodeId): Promise<void> {
Expand Down Expand Up @@ -527,15 +523,14 @@ export class WasmRenderer implements IRenderer {
// `variableId` is the client-built opaque token, forwarded verbatim;
// nothing on either side parses it. The association travels as its own
// typed 'POINT'|'CELL' field, and the array name as its own field,
// precisely so that no one has to.
// precisely so that no one has to. The range is not sent: the server
// applies its own record's range for the referenced slot.
await this.#sendTriggerAsync('set_part_color_variable', nodeId, {
nodeId,
variableId: descriptor.variableId,
association: descriptor.variableType,
arrayName: descriptor.variableName,
component: descriptor.component,
min: descriptor.min,
max: descriptor.max,
});
}

Expand Down
20 changes: 18 additions & 2 deletions tests/unit/app/test_local_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,7 @@ def test_set_part_selected_carries_no_colour(app, api):
def test_set_part_color_variable_delegates_payload_values(app, api):
"""Every colour-variable field but the range reaches the coordinator in contract order.

``min`` and ``max`` are still required on the wire but are not forwarded:
the server applies its own record's range.
A payload still carrying ``min`` and ``max`` parses; neither is forwarded.
"""
app.set_part_color_variable(
{
Expand All @@ -195,6 +194,23 @@ def test_set_part_color_variable_delegates_payload_values(app, api):
)


def test_set_part_color_variable_delegates_without_min_and_max(app, api):
"""A payload with no range delegates once, with the five identity arguments."""
app.set_part_color_variable(
{
"nodeId": 7,
"variableId": "POINT::pressure::1",
"association": "POINT",
"arrayName": "pressure",
"component": 0,
}
)

api.set_part_color_variable.assert_called_once_with(
7, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0
)


def test_set_part_color_variable_parses_point_association_to_the_enum_member(app, api):
"""'POINT' arrives as the POINT member itself, not as a string."""
app.set_part_color_variable(
Expand Down
Loading
Loading