@@ -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 # ------------------------------------------------------------------
0 commit comments