11"""Model for persisted scene state (cross-session)."""
22
3- from typing import Dict
3+ from typing import Any , Dict , Mapping
44
5- from pydantic import BaseModel , ConfigDict , Field
5+ from pydantic import BaseModel , ConfigDict , Field , field_validator
66
77from ansys .visor .viewer .models .common .visor_camera_state import VisorCameraState
88from ansys .visor .viewer .models .common .visor_cross_section_state import VisorCrossSectionState
99from ansys .visor .viewer .models .common .visor_variable_state import VisorVariableState
1010from ansys .visor .viewer .models .persist .dataset .persisted_dataset_state import PersistedDatasetState
1111
12+ #: Separator used by the client when it mints a variable identifier. See
13+ #: ``VisorSpectrumManager.tryAddSpectrumInfo``, which builds the identifier as
14+ #: ``` `${type}::${name}::${numComponents}` ```.
15+ _IDENTIFIER_SEPARATOR = "::"
16+
17+ def derive_variable_fields_from_identifier (identifier : Any ) -> Dict [str , Any ] | None :
18+ """Recover ``type``, ``array_name`` and ``num_components`` from a variable identifier.
19+
20+ This is the inverse of the client's creation of the variable ID, and the only place the
21+ identifier is parsed. The array name is in the middle field and is user data, so it may
22+ contain the separator: the split is anchored at both ends rather than left to right, and
23+ ``"POINT::stress::yy::3"`` yields the array name ``"stress::yy"``.
24+
25+ Returns ``None`` when the identifier does not spell out three usable fields, meaning
26+ "cannot be derived" rather than "derived to a default". Callers leave such an
27+ entry untouched so ordinary validation reports the missing fields.
28+ """
29+ if not isinstance (identifier , str ):
30+ return None
31+
32+ # Strip out the last field, which is the number of components.
33+ # e.g. "POINT::stress::yy::3".rpartition("::") -> ("POINT::stress::yy", "::", "3")
34+ head , separator , num_components_str = identifier .rpartition (_IDENTIFIER_SEPARATOR )
35+ if not separator :
36+ return None
37+
38+ try :
39+ num_components = int (num_components_str )
40+ except (TypeError , ValueError ):
41+ return None
42+
43+ # Strip out the first field, which is the variable type. The remainder is the array name,
44+ # which may contain the separator.
45+ # e.g. "POINT::stress::yy".partition("::") -> ("POINT", "::", "stress::yy")
46+ variable_type , separator , array_name = head .partition (_IDENTIFIER_SEPARATOR )
47+ if not separator :
48+ return None
49+
50+ if not variable_type or not array_name :
51+ return None
52+
53+ return {
54+ "type" : variable_type ,
55+ "array_name" : array_name ,
56+ "num_components" : num_components ,
57+ }
58+
1259
1360class PersistedSceneState (BaseModel ):
1461 """Persisted (cross-session) scene state.
@@ -32,3 +79,56 @@ class PersistedSceneState(BaseModel):
3279 dataset_states : Dict [str , "PersistedDatasetState" ] = Field (default_factory = dict )
3380 variable_states : Dict [str , "VisorVariableState" ] = Field (default_factory = dict )
3481 model_config = ConfigDict (arbitrary_types_allowed = True )
82+
83+ @field_validator ("variable_states" , mode = "before" )
84+ @classmethod
85+ def _derive_missing_variable_identity_fields (cls , value : Any ) -> Any :
86+ """Fill absent identity fields on read, from the variable identifier.
87+
88+ ``array_name``, ``type``, and ``num_components`` are required and stay
89+ required: the model is shared with ``RuntimeSceneState.spectrum_states``,
90+ so relaxing them would also relax the save-path coercion in
91+ ``VisorSaveStateResponse._coerce_app_state``. Tolerance for older save
92+ files lives here, on the container, and applies to the ingest boundary only.
93+ """
94+ if not isinstance (value , Mapping ):
95+ return value
96+
97+ out : Dict [Any , Any ] = {}
98+ for key , entry in value .items ():
99+ out [key ] = cls ._derive_missing_identity_fields_for_entry (key , entry )
100+ return out
101+
102+ @staticmethod
103+ def _derive_missing_identity_fields_for_entry (key : Any , entry : Any ) -> Any :
104+ """Return *entry* with any absent identity fields derived from its identifier."""
105+ if isinstance (entry , VisorVariableState ) or not isinstance (entry , Mapping ):
106+ return entry
107+
108+ # A value that is present but null counts as present and is left
109+ # to fail validation.
110+ missing = [
111+ field
112+ for field , alias in (
113+ ("array_name" , "arrayName" ),
114+ ("type" , "type" ),
115+ ("num_components" , "numComponents" ),
116+ )
117+ if field not in entry and alias not in entry
118+ ]
119+ if not missing :
120+ return entry
121+
122+ identifier = entry .get ("id" )
123+ if not isinstance (identifier , str ) or not identifier :
124+ identifier = key
125+
126+ derived = derive_variable_fields_from_identifier (identifier )
127+ if derived is None :
128+ return entry
129+
130+ filled = dict (entry )
131+ for field in missing :
132+ filled [field ] = derived [field ]
133+ return filled
134+
0 commit comments