Skip to content

Commit e07a020

Browse files
committed
source per-part state from the registry on save and restore it on load
1 parent 5fc3c70 commit e07a020

5 files changed

Lines changed: 985 additions & 5 deletions

File tree

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

Lines changed: 266 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,36 @@ async def get_state(self, timeout: float) -> PersistedViewerStateV1:
150150
Capture the current viewer state and return it as a
151151
:class:`PersistedViewerStateV1`.
152152
153-
The renderer-specific part of state capture is handled by
154-
:meth:`_get_runtime_state_async`.
153+
The frontend round trip (:meth:`_get_runtime_state_async`) remains the
154+
only source for everything the browser owns — UI panels, unit, the
155+
display toggles, camera, cross-section and the per-variable ranges.
156+
**Per-part state is not taken from it.** The dataset registry is the
157+
server-side authority for that, so ``scene.dataset_states`` is replaced
158+
wholesale with the registry's own runtime state before the persisted
159+
mapping runs. The browser is no longer consulted for per-part state.
160+
161+
The registry is read under ``_vtk_lock`` and each dataset state is
162+
deep-copied out of it. The registry hands out its live
163+
``RuntimeDatasetState`` objects by reference, and the per-part setters
164+
mutate the records they contain in place from the trame daemon thread;
165+
without the snapshot the persist mapper would be an unlocked reader of
166+
records that are being written concurrently. The lock is taken *after*
167+
the ``await`` has completed and released before the mapping, so it is
168+
never held across an ``await``. Named cost: this is the only place a
169+
coroutine acquires ``_vtk_lock``, so a save issued while a long
170+
main-thread VTK call holds the lock waits for that call. It cannot
171+
deadlock: the outbound bridge call made under the lock reaches
172+
``loop.call_later`` and returns without waiting on the event loop.
155173
"""
156174
runtime_state = await self._get_runtime_state_async(timeout)
175+
176+
with self._vtk_lock:
177+
registry_dataset_states = {
178+
dataset_id: dataset_state.model_copy(deep=True)
179+
for dataset_id, dataset_state in self._dataset_registry.runtime_state_dict.items()
180+
}
181+
runtime_state.scene.dataset_states = registry_dataset_states
182+
157183
persisted = self._state_mapper.runtime_to_persisted(runtime_state)
158184
return persisted
159185

@@ -166,7 +192,9 @@ def apply_state(self, state: PersistedViewerStateV1):
166192
:meth:`_apply_runtime_state_to_render`.
167193
168194
Holds ``_vtk_lock`` for the whole body: the delegated step mutates
169-
VTK and pushes to the frontend.
195+
VTK and pushes to the frontend. The critical section deliberately
196+
spans the outbound bridge call and the flush that follows it — the
197+
unit the lock protects is the compound sequence, not the VTK work.
170198
"""
171199
with self._vtk_lock:
172200
# Apply UI settings
@@ -175,10 +203,18 @@ def apply_state(self, state: PersistedViewerStateV1):
175203
# Transform the frontend PersistedViewerStateV1 -> RuntimeAppState
176204
runtime_app_state = self._state_mapper.persisted_to_runtime(state)
177205

206+
# Restore per-part state server-side: write the registry, then apply
207+
# every part to this process's VTK pipeline. The browser is not
208+
# asked to do this, and is not trusted to have done it.
209+
self._restore_part_states_from_runtime(runtime_app_state)
210+
178211
# Renderer-specific: flush VTK window and notify frontend (wasm), or
179212
# push camera to vtkCamera (RCA), or no-op (headless).
180213
self._apply_runtime_state_to_render(runtime_app_state)
181214

215+
# Note: There is intentionally no wasm flush here: the bridge call is fire-and-forget, so a flush
216+
# at this point races the client's rebuild against a half-written object graph.
217+
182218
def get_scene_details(self) -> VisorSceneDetails:
183219
"""Return the VisorState."""
184220
if self._scene_graph is None:
@@ -495,6 +531,233 @@ def clear_part_color_variable(self, node_id: int) -> None:
495531
return
496532
self._renderer.clear_color_variable(node_id)
497533

534+
def _restore_part_states_from_runtime(self, runtime_app_state: "RuntimeAppState") -> None:
535+
"""
536+
Restore per-part state from a runtime app state, on the load path.
537+
538+
Two halves, in this order: the registry is replaced wholesale from the
539+
supplied dataset states (``replace_part_states``), then every part is
540+
applied to this process's VTK pipeline through ``IRenderer.apply_*``.
541+
Either half can silently do nothing while the other succeeds, which is
542+
why they are separate steps and separately asserted.
543+
544+
Takes no lock of its own: it is only ever called from inside
545+
:meth:`apply_state`'s critical section, and acquiring here would read
546+
as though the method were independently safe to call.
547+
548+
Every failure is a logged no-op, per the posture used at every other
549+
layer: an unregistered dataset id, an unresolvable part, a malformed
550+
colour, a half-set variable reference, an unknown variable identifier,
551+
an unknown array, and an array whose width disagrees with the stored
552+
one are each skipped without raising.
553+
"""
554+
dataset_states = runtime_app_state.scene.dataset_states or {}
555+
variable_states = runtime_app_state.scene.spectrum_states or {}
556+
557+
# Store half. Unknown dataset ids are skipped by the registry.
558+
self._dataset_registry.replace_part_states(dataset_states)
559+
560+
# Apply half. The dataset id is resolved once per dataset, from the
561+
# loop key, and the skip is explicit and logged: replace_part_states
562+
# skips unknown ids silently, so without this the store half and the
563+
# apply half could disagree about which datasets exist.
564+
for dataset_id, dataset_state in dataset_states.items():
565+
dataset = self._dataset_registry.datasets.get(dataset_id)
566+
if dataset is None:
567+
logger.warning(
568+
"_restore_part_states_from_runtime: dataset %s is not registered; "
569+
"its part state was not applied to the pipeline.", dataset_id
570+
)
571+
continue
572+
573+
# Per-part variable metadata, keyed by part id — one entry per
574+
# non-empty leaf. Built once per dataset rather than per part.
575+
variables_by_part = {
576+
entry.part_id: entry.variables for entry in dataset.list_variables()
577+
}
578+
579+
for part_id, part_state in dataset_state.part_states.items():
580+
self._restore_one_part_state(
581+
part_id, part_state, variable_states, variables_by_part.get(part_id)
582+
)
583+
584+
def _restore_one_part_state(
585+
self,
586+
part_id: int,
587+
part_state,
588+
variable_states: dict,
589+
part_variables,
590+
) -> None:
591+
"""
592+
Apply one restored part record to the pipeline.
593+
594+
Absent fields are absent: a ``None`` is "this record says nothing about
595+
that property", not "reset it to the default", so nothing is applied
596+
for it. **Recorded divergence:** :meth:`apply_state` does not
597+
guarantee freshly built pipelines — ``load_state`` rebuilds datasets
598+
only when the scene is empty — so a part whose stored ``diffuse_rgb``
599+
is ``None`` keeps whatever colour the pipeline already carries while
600+
the registry records no custom colour.
601+
602+
The colour is validated to exactly three elements here; a malformed
603+
colour is a logged no-op and the same guard covers the colour the
604+
selection branch reads. The registry keeps the malformed value it was
605+
loaded with — requiring on load would make the next save silently
606+
rewrite the user's file.
607+
"""
608+
if part_state.visible is not None:
609+
self._renderer.apply_visibility(part_id, part_state.visible)
610+
611+
if part_state.opacity is not None:
612+
self._renderer.apply_opacity(part_id, part_state.opacity)
613+
614+
stored_rgb = part_state.diffuse_rgb
615+
valid_rgb = None
616+
if stored_rgb is not None:
617+
if len(stored_rgb) == 3:
618+
valid_rgb = stored_rgb
619+
else:
620+
logger.warning(
621+
"_restore_one_part_state: part %s has a diffuse colour of %s elements, "
622+
"not 3; leaving the pipeline colour unchanged.", part_id, len(stored_rgb)
623+
)
624+
625+
if valid_rgb is not None:
626+
self._renderer.apply_diffuse_color(part_id, valid_rgb[0], valid_rgb[1], valid_rgb[2])
627+
628+
if part_state.selected is not None:
629+
selection_rgb = (
630+
valid_rgb if valid_rgb is not None else list(VisorColors.DefaultMeshColor)
631+
)
632+
self._renderer.apply_selected(part_id, part_state.selected, selection_rgb)
633+
634+
self._restore_part_color_variable(part_id, part_state, variable_states, part_variables)
635+
636+
def _restore_part_color_variable(
637+
self,
638+
part_id: int,
639+
part_state,
640+
variable_states: dict,
641+
part_variables,
642+
) -> None:
643+
"""
644+
Restore one part's colour-variable reference, or clear it.
645+
646+
The variable reference is a compound value that is only ever set or
647+
cleared atomically, so a half-set pair — an identifier with no
648+
component, or a component with no identifier — is not a state this can
649+
act on and is a logged no-op in both directions. A component with no
650+
identifier in particular must not fall through to the clear branch.
651+
652+
The range is resolved from the persisted variable entry, whose index
653+
convention is the opposite of the client's internal table: a stored
654+
component of ``-1`` reads ``magnitude_range`` and a component of 0 or
655+
greater reads ``ranges[component]``. The branch is three-way with no
656+
fallthrough — a negative component other than ``-1`` is neither the
657+
magnitude sentinel nor an index, and returns before any mutation.
658+
659+
The array is resolved against the server's **per-part** variable
660+
metadata and its component count is checked against the stored one:
661+
two parts can carry same-named arrays of different widths, which the
662+
application treats as different quantities, so name and association
663+
alone could colour a part by the wrong quantity with no error anywhere.
664+
"""
665+
variable_id = part_state.spectrum_id
666+
component = part_state.spectrum_component
667+
668+
if variable_id is None:
669+
if component is not None:
670+
logger.warning(
671+
"_restore_part_color_variable: part %s stores component %s with no variable "
672+
"identifier; not clearing and not applying.", part_id, component
673+
)
674+
return
675+
self._renderer.clear_color_variable(part_id)
676+
return
677+
678+
if component is None:
679+
logger.warning(
680+
"_restore_part_color_variable: part %s stores variable '%s' with no component; "
681+
"skipping.", part_id, variable_id
682+
)
683+
return
684+
685+
variable_state = variable_states.get(variable_id)
686+
if variable_state is None:
687+
logger.warning(
688+
"_restore_part_color_variable: no variable entry for '%s' (part %s); skipping.",
689+
variable_id, part_id
690+
)
691+
return
692+
693+
if component == -1:
694+
value_range = variable_state.magnitude_range
695+
elif component >= 0:
696+
if component >= len(variable_state.ranges):
697+
logger.warning(
698+
"_restore_part_color_variable: component %s is outside the %s stored ranges "
699+
"for '%s' (part %s); skipping.",
700+
component, len(variable_state.ranges), variable_id, part_id
701+
)
702+
return
703+
value_range = variable_state.ranges[component]
704+
else:
705+
logger.warning(
706+
"_restore_part_color_variable: component %s for '%s' (part %s) is neither the "
707+
"magnitude sentinel (-1) nor a component index; skipping.",
708+
component, variable_id, part_id
709+
)
710+
return
711+
712+
if value_range is None:
713+
logger.warning(
714+
"_restore_part_color_variable: no stored range for component %s of '%s' "
715+
"(part %s); skipping.", component, variable_id, part_id
716+
)
717+
return
718+
719+
if part_variables is None:
720+
logger.warning(
721+
"_restore_part_color_variable: no variable metadata for part %s; skipping.",
722+
part_id
723+
)
724+
return
725+
726+
resolved = next(
727+
(
728+
variable for variable in part_variables
729+
if variable.name == variable_state.array_name
730+
and variable.type is variable_state.type
731+
),
732+
None,
733+
)
734+
if resolved is None:
735+
logger.warning(
736+
"_restore_part_color_variable: array '%s' (%s) not found on part %s; skipping.",
737+
variable_state.array_name, variable_state.type, part_id
738+
)
739+
return
740+
741+
if resolved.num_components != variable_state.num_components:
742+
logger.warning(
743+
"_restore_part_color_variable: array '%s' on part %s has %s components, the "
744+
"stored variable has %s; the part does not participate in this variable.",
745+
variable_state.array_name, part_id,
746+
resolved.num_components, variable_state.num_components
747+
)
748+
return
749+
750+
min_val, max_val = value_range
751+
self._renderer.apply_color_variable(
752+
part_id,
753+
variable_id,
754+
variable_state.type,
755+
variable_state.array_name,
756+
component,
757+
min_val,
758+
max_val,
759+
)
760+
498761
# ------------------------------------------------------------------
499762
# Internal helpers
500763
# ------------------------------------------------------------------

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ def persisted_to_runtime(self, state: PersistedViewerStateV1) -> RuntimeAppState
7373
"""Convert PersistedViewerStateV1 back to runtime RuntimeAppState.
7474
7575
Logic parity with ``VisorScene._persisted_to_runtime``.
76+
77+
This is **not**, and never has been, the registry-population path. It
78+
builds runtime dataset states and returns them on the RuntimeAppState;
79+
it never assigns its result back onto ``VisorDataset.state``, so no
80+
registry record is written by calling it.
81+
:meth:`VisorSceneBase._restore_part_states_from_runtime` now populates
82+
the registry explicitly, from the state this method returns.
7683
"""
7784
# UI settings
7885
ui_state = state.ui

0 commit comments

Comments
 (0)