2929from ansys .visor .viewer .core .visor_colors import VisorColors
3030from ansys .visor .viewer .core .visor_logging import VisorDefaultLogger
3131from ansys .visor .viewer .models .common .visor_camera_state import VisorCameraState
32+ from ansys .visor .viewer .models .common .visor_cross_section_state import VisorCrossSectionState
3233from ansys .visor .viewer .models .runtime .vtk .renderer_annotation import (
3334 WasmNodeHandles ,
3435 WasmRendererAnnotation ,
@@ -62,12 +63,14 @@ class VisorLocalRenderer(IRenderer):
6263 # Increment I5; mutated exclusively via register_node / deregister_node.
6364 _pipelines : dict [int , VtkNodePipeline ]
6465 _last_camera_state : Optional ["VisorCameraState" ]
66+ _last_cross_section_state : Optional ["VisorCrossSectionState" ]
6567
6668 def __init__ (self , server : Server ):
6769 """Build the full local-mode VTK infrastructure and seed wasm state."""
6870 self ._server = server
6971 self ._pipelines = {}
7072 self ._last_camera_state = None
73+ self ._last_cross_section_state = None
7174
7275 self ._vtk_renderer = self ._initialize_vtk_renderer ()
7376 self ._render_window = self ._initialize_render_window ()
@@ -401,7 +404,47 @@ def set_cross_section_visibility(self, visible: bool) -> None:
401404 def sync_cross_section_plane (
402405 self , origin : list [float ], normal : list [float ]
403406 ) -> None :
404- """No-op in Story 1.2. Phase 3 populates."""
407+ """See :meth:`IRenderer.sync_cross_section_plane`.
408+
409+ Writes the record, then both VTK objects. ``set_origin`` and
410+ ``set_normal`` each write the plane *and* the representation, so the
411+ clip function and the draggable handle cannot diverge on this path --
412+ which is the defect the client's own set/get asymmetry has.
413+ """
414+ self ._last_cross_section_state = VisorCrossSectionState (
415+ origin = list (origin ), normal = list (normal )
416+ )
417+ self ._cross_section_widget .set_origin (origin )
418+ self ._cross_section_widget .set_normal (normal )
419+
420+ def get_cross_section_plane (self ) -> Optional ["VisorCrossSectionState" ]:
421+ """See :meth:`IRenderer.get_cross_section_plane`."""
422+ return self ._last_cross_section_state
423+
424+ def serialize_cross_section_state (self ) -> None :
425+ """See :meth:`IRenderer.serialize_cross_section_state`.
426+
427+ Two ids, named one at a time, each derived the way
428+ :meth:`serialize_camera_state` derives the camera's: ``GetId`` on the
429+ object itself, not the id ``register_vtk_object`` returned. Deriving
430+ it this way means the equivalence of those two id spaces never has to
431+ be established.
432+
433+ The plane is the clip function every pipeline holds; the
434+ representation is the visible handle and is also what the client
435+ reads on save. A plane-only re-serialise leaves the client saving a
436+ stale handle.
437+
438+ The widget id is deliberately not named: ``set_origin`` and
439+ ``set_normal`` do not write the widget, whose changing state is
440+ enablement. See MC-6.
441+ """
442+ self ._object_manager .UpdateStateFromObject (
443+ self ._object_manager .GetId (self ._cross_section_widget .plane )
444+ )
445+ self ._object_manager .UpdateStateFromObject (
446+ self ._object_manager .GetId (self ._cross_section_widget .plane_representation )
447+ )
405448
406449 def set_bounding_box_visibility (self , visible : bool ) -> None :
407450 """No-op in Story 1.2. Phase 3 populates."""
@@ -411,9 +454,40 @@ def set_bounding_box_visibility(self, visible: bool) -> None:
411454 # ------------------------------------------------------------------
412455
413456 def update_bounds (self , bounds : list [float ]) -> None :
414- """See :meth:`IRenderer.update_bounds`."""
457+ """See :meth:`IRenderer.update_bounds`.
458+
459+ Seeds the cross-section record from the values the widget's own
460+ ``update_bounds`` just wrote through ``set_origin``/``set_normal``, so
461+ the record is never ``None`` once a scene has been populated and
462+ ``get_state`` can assign it unconditionally, as it does the camera's.
463+
464+ **The seed runs only when there is no record.** This method is not
465+ reached once per scene: ``set_part_visibility`` fans out to
466+ ``_update_widget_bounds``, so a part toggle or a dataset add reaches
467+ here too. Seeding unconditionally would therefore discard a plane the
468+ user had dragged, on the next toggle, and the widget's own
469+ ``update_bounds`` would already have overwritten both VTK objects with
470+ the default plane. With a record present this writes the record
471+ *back* to the widget instead -- after the widget's own call, never
472+ before -- and leaves the record itself untouched.
473+
474+ Ordering is load-bearing in both branches. Seeded before the widget
475+ call, the record holds the previous plane and is one populate behind;
476+ written back before it, the widget's defaults win and the drag is
477+ lost. Either compiles, and either passes any test that does not
478+ assert the order.
479+ """
415480 self ._cross_section_widget .update_bounds (bounds )
416481 self ._bounding_box_widget .update_bounds (bounds )
482+ if self ._last_cross_section_state is None :
483+ plane = self ._cross_section_widget .plane
484+ self ._last_cross_section_state = VisorCrossSectionState (
485+ origin = list (plane .GetOrigin ()),
486+ normal = list (plane .GetNormal ()),
487+ )
488+ else :
489+ self ._cross_section_widget .set_origin (self ._last_cross_section_state .origin )
490+ self ._cross_section_widget .set_normal (self ._last_cross_section_state .normal )
417491
418492 def update_actor_count (self , count : int ) -> None :
419493 """See :meth:`IRenderer.update_actor_count`."""
0 commit comments