# Copyright (c) 2024-2026 Karana Dynamics Pty Ltd. All rights reserved.
#
# NOTICE TO USER:
#
# This source code and/or documentation (the "Licensed Materials") is
# the confidential and proprietary information of Karana Dynamics Inc.
# Use of these Licensed Materials is governed by the terms and conditions
# of a separate software license agreement between Karana Dynamics and the
# Licensee ("License Agreement"). Unless expressly permitted under that
# agreement, any reproduction, modification, distribution, or disclosure
# of the Licensed Materials, in whole or in part, to any third party
# without the prior written consent of Karana Dynamics is strictly prohibited.
#
# THE LICENSED MATERIALS ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
# KARANA DYNAMICS DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
# BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, NON-INFRINGEMENT, AND
# FITNESS FOR A PARTICULAR PURPOSE.
#
# IN NO EVENT SHALL KARANA DYNAMICS BE LIABLE FOR ANY DAMAGES WHATSOEVER,
# INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, OR USE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGES, WHETHER IN CONTRACT, TORT,
# OR OTHERWISE ARISING OUT OF OR IN CONNECTION WITH THE LICENSED MATERIALS.
#
# U.S. Government End Users: The Licensed Materials are a "commercial item"
# as defined at 48 C.F.R. 2.101, and are provided to the U.S. Government
# only as a commercial end item under the terms of this license.
#
# Any use of the Licensed Materials in individual or commercial software must
# include, in the user documentation and internal source code comments,
# this Notice, Disclaimer, and U.S. Government Use Provision.
"""Contains the MultibodyWebUI class."""
from typing import Optional, Literal, TYPE_CHECKING, Sequence, cast
from pathlib import Path
import Karana.Dynamics as kd
import Karana.Core as kc
import Karana.Scene as ks
import Karana.WebUI as kw
import Karana.Frame as kf
import Karana.Models as kmdl
import Karana.Math as km
import Karana.KUtils.visjs as vjs
import numpy as np
try:
from Karana.Adams import SimpleSpdp
except:
SimpleSpdp = None
from ._infopanel import InfoPanel, GuiContext, createPauseCb
from ._treeview import (
SubTreeTreeViewOptions,
createSubTreeTreeView,
createSubTreeBodiesTreeView,
createSubTreesTreeView,
createScenesTreeView,
createFramesTreeView,
createStatePropagatorTreeView,
createActiveContactNodesTreeView,
createBilateralConstraintsTreeView,
)
from ._effects import (
EffectManager,
EffectItem,
OutlineParams,
OutlineLevel,
AxesParams,
GraphEdgeParams,
)
from ._helpers import widgetArray
from ._notify import Notifier
from ._worker import AsyncWorker
__all__ = ["MultibodyWebUI"]
if TYPE_CHECKING:
from Karana.KUtils.Sim import Sim
[docs]
class MultibodyWebUI:
"""MultibodyWebUI class.
This class creates a Multibody-centric web-based GUI
"""
def __init__(
self,
mbody: kd.Multibody,
*,
port: int = 29534,
stick_parts: Literal["auto", "always", "never"] = "auto",
stick_parts_config: Optional[kd.StickPartsConfig] = None,
graphics_origin_frame: kf.Frame | None = None,
name_to_label_map: dict[int | str, str] | None = None,
sp: kd.StatePropagator | None = None,
sim: "Sim | None" = None,
time_display_period: float | None = 0.01,
):
"""Create a MultibodyWebUI instance.
Parameters
----------
mbody: Multibody
The Multibody to create a GUI for
port: int
The port to run the GUI server on. Defaults to 29534.
stick_parts : Literal["auto", "always", "never"] = "auto"
Policy for creating stick parts. Defaults to "auto".
"auto": create stick parts if the ProxyScene is empty
"always": unconditionally create stick parts
"never": unconditionally don't create stick parts
stick_parts_config : StickPartsConfig
Configuration parameters for the stick parts.
graphics_origin_frame: kf.Frame | None
If given, and the GUI needs to setup graphics, use this as
the origin frame instead of the multibody vroot.
name_to_label_map: dict[str, str] | None
Dictionary defining the labels to use for each body in the visjs graphs
display
sp: StatePropagator | None
The state propagator to use if available.
sim : Sim | None
The Sim instance if available
time_display_period: float | None
If not None and sp is given, add a time display refreshing
with this period in simulation time. Defaults to 0.01.
"""
self.sim = sim
# the Sim instance alread has sp and mbody members, and if
# the sim is specified, use the members
if sim:
if sp and not sp.id() == sim.sp.id():
raise ValueError(
"The sim state propagator and state propagator given are not consistent."
)
self.sp = sim.sp
if mbody.id() != sim.mb.id():
raise ValueError("The sim multibody and multibody given are not consistent.")
self.mbody = sim.mb
else:
self.mbody = mbody
self.sp = sp
self.cleanups = kc.VoidCallbackRegistry()
# setup thread for long-running async callbacks
self.worker = AsyncWorker()
self.cleanups["closeWorker"] = self.worker.close
# main server setup
self.server = self._setupServer(port=port)
self.router = kw.Router(self.server)
# Inject custom CSS into the document
self.router.channel(css=Path(__file__).parent / "static" / "style.css")
# Setup error notifier popup
self.error_count = 0
self.notifier = Notifier(self.router)
self.server.setOnError(self._handleServerError)
# Shared state for the selected item(s)
self.selection: kw.State = kw.State(self.router)
# Shared state for the hovered item
self.hovered: kw.State = kw.State(self.router)
# can be one of ScenePart, Frame, Body to control what gets
# selected when a geometry is clicked on
self.webscene_selection_mode = "Body"
# 3d graphics setup
self.scene: ks.ProxyScene = self._ensureScene()
self.graphics: ks.WebScene = self._ensureGraphics(graphics_origin_frame)
self.graphics_frame = kw.IFrame(self.router, self.graphics.server().guessUrl())
# create stick parts if requested
should_create_stick_parts = False
if stick_parts == "auto":
should_create_stick_parts = self.scene.empty()
elif stick_parts == "always":
should_create_stick_parts = True
if should_create_stick_parts:
self.mbody.createStickParts(stick_parts_config or kd.StickPartsConfig())
# Effect helpers setup
self.effects = EffectManager(graphics=self.graphics, scene=self.scene)
self.cleanups["cleanupEffectManager"] = self.effects.close
# visjs frame layer viz setup
self.visjs_frames_server: vjs.GraphServer = self._setupFramesVisjs(
self.mbody.frameContainer()
)
self.visjs_frames_iframe = kw.IFrame(self.router, self.visjs_frames_server.getUrl())
# if non-null, invoke this callback instead of selecting when a
# frame node is clicked
# self.visjs_frames_noselect_cb = None
# visjs graph viz setup
self.visjs_label_map = name_to_label_map
self.visjs_server: vjs.MultibodyGraphServer = self._setupVisjs(self.mbody)
self.visjs_iframe = kw.IFrame(self.router, self.visjs_server.getUrl())
# keep track of all SubTree graph servers along with the status
# of whether constraints are on or off
self.visjs_servers: dict[int, tuple[vjs.MultibodyGraphServer, bool]] = {
self.mbody.id(): (self.visjs_server, True)
}
# tree view setup
self.mbody_tree_view = self._setupMultibodyTreeView()
self.subtrees_tree_view = self._setupSubTreesTreeView()
if self.sp:
self.sp_tree_view = self._setupStatePropagatorTreeView(
sp=self.sp,
options=SubTreeTreeViewOptions(
subtree_bodies=True,
subtree_subtrees=True,
body_nodes=True,
body_scene_parts=True,
loop_constraints=True,
),
)
"""
self.all_tree_view = self._setupSubTreeTreeView(
options=SubTreeTreeViewOptions(
subtree_bodies=True,
subtree_subtrees=True,
body_nodes=True,
body_scene_parts=True,
loop_constraints=True,
collapse_depth=1,
)
)
"""
# TODO - for some reason we need to create this for the menu
# SubTree option to work properly. If we do not call the below,
# selections made from the menu created SubTree tree view seg
# fault
if self.mbody.childrenSubTrees():
self._setupSubTreeTreeView(options=SubTreeTreeViewOptions(subtree_subtrees=True))
"""
# TODO - we should really use the following since it has refresh
# capability, but it is segfaulting for some reason
self._setupSubTreeTreeView(
options=SubTreeTreeViewOptions(subtree_bodies=True)
)
"""
"""
self.subtrees_tree_view = self._setupSubTreesTreeView()
"""
"""
if self.mbody.getScene():
self.scenes_tree_view = self._setupScenesTreeView()
self.contact_nodes_tree_view = None
if self.mbody.getScene():
css = self.mbody.getScene().clientScenes()
from Karana.Scene import CoalScene
for cs in css:
if isinstance(cs, CoalScene):
self.contact_nodes_tree_view = self._setupActiveContactNodesTreeView(self.mbody)
break
self.biconstraints_tree_view = None
if len(self.mbody.enabledConstraints()) > 0:
self.biconstraints_tree_view = self._setupBilateralConstraintsTreeView(self.mbody)
if sp is not None:
self.sp_tree_view = self._setupStatePropagatorTreeView(
sp=sp,
options=SubTreeTreeViewOptions(
subtree_bodies=True,
subtree_subtrees=True,
body_nodes=True,
body_scene_parts=True,
loop_constraints=True,
),
)
self.frames_tree_view = self._setupFramesTreeView()
"""
self.wmain = kw.Layout(
self.router, style={"display": "flex", "flexDirection": "column", "height": "100vh"}
)
# ------------------------------------
# menu in toolbar to add different TreeViews
# self.treeviews_options = ['SubTrees', 'Scenes', 'ALL', 'Frames', 'StatePropagator', 'Constraints', 'Contact nodes']
self.treeviews_options = []
if 1 or self.mbody.childrenSubTrees():
self.treeviews_options.append("SubTrees")
self.treeviews_options = [
"Frames",
]
if self.sim:
self.treeviews_options.append("Sim")
if sp:
self.treeviews_options.append("StatePropagator")
if self.mbody.enabledConstraints():
self.treeviews_options.append("Constraints")
if ps := self.mbody.getScene():
self.treeviews_options.append("Scenes")
self.treeviews_options.append("FrameSceneParts")
# add contact nodes option if CoalScene is registered
css = ps.clientScenes()
from Karana.Scene import CoalScene
for cs in css:
if isinstance(cs, CoalScene):
self.treeviews_options.append("Contact nodes")
break
self.treeviews_options.extend(["ALL"])
self.wtreeviews = kw.Dropdown(
self.router,
"Tree Views",
self.treeviews_options,
lambda val: self.treeviews(self.treeviews_options[int(val)]),
)
self.wtreeviews.setTooltip("Select the type of TreeView to create")
# ---------------------
webscene_selection_modes = ["Frame", "Body", "ScenePart"]
# callback for the 3D pick mode menu
def _websceneSelectionModeCB(val):
self.webscene_selection_mode = webscene_selection_modes[val]
self.wwebscene_selection_mode = kw.Dropdown(
self.router,
"3D Selection",
webscene_selection_modes,
lambda val: _websceneSelectionModeCB(val),
)
self.wwebscene_selection_mode.setIndex(1)
self.wwebscene_selection_mode.setTooltip(
"Select 3D graphics pick mode - closest body, frame or scene part"
)
# ---------------------
webscene_view_modes = ["Free", "+X", "+Y", "+Z", "-X", "-Y", "-Z"]
self.webscene_view_mode = "pers_free"
# callback for the 3D pick mode menu
def _websceneViewModeCB(val):
mode = self.webscene_view_mode = webscene_view_modes[val]
if self.webscene_view_mode == "Free":
projection = ks.PerspectiveProjection()
else:
projection = ks.OrthographicProjection()
target = [0, 0, 0]
camera = self.scene.graphics().defaultCamera()
if mode == "Free":
offset = [3, 3, 3]
up = [0, 0, 1]
elif mode == "+X":
offset = [5, 0, 0]
up = [0, 0, 1]
elif mode == "+Y":
offset = [0, 5, 0]
up = [0, 0, 1]
elif mode == "+Z":
offset = [0, 0, 5]
up = [0, 1, 0]
elif mode == "-X":
offset = [-5, 0, 0]
up = [0, 0, 1]
elif mode == "-Y":
offset = [0, -5, 0]
up = [0, 0, 1]
elif mode == "-Z":
offset = [0, 0, -5]
up = [0, 1, 0]
camera.pointCameraAt(offset=offset, target=target, up=up)
self.wwebscene_view_mode = kw.Dropdown(
self.router,
"3D View",
webscene_view_modes,
lambda val: _websceneViewModeCB(val),
)
self.wwebscene_view_mode.setIndex(0)
self.wwebscene_view_mode.setTooltip(
"Select 3D graphics camera perspective and orthographic views"
)
# ------------------------------------
self.wdeselect = kw.Button(self.router, "Deselect", lambda: self.deselect())
self.wreset_viz = kw.Button(self.router, "Clear Viz Effects", lambda: self.effects.clear())
# ---------------------------------
"""
# slider to scale the stick parts
slider_opts = kw.SliderOptions()
slider_opts.min = 0.01
slider_opts.max = 1
slider_opts.step = 0.01
"""
self.waxes_scale = kw.FloatInput(
self.router,
"Axes scale",
on_change=lambda scale: self.setAxesGlobalScale(float(scale), float(scale)),
rapid_submit=True,
)
self.waxes_scale.setTooltip("The global scaling factor for frame axes in 3D graphics")
self.waxes_scale.setStep(0.01)
self.waxes_scale.setMin(0)
self.waxes_scale.setMax(1)
self.waxes_scale.setValue(self.effects.frame_axes.part_scale)
self.waxes_scale.setSizeClass(kw.SizeClass.NARROW)
def _setCameraMask(cstate, mask):
graphics = self.graphics
camera = graphics.defaultCamera()
if not camera:
return
cmask = camera.getMask()
if cstate:
cmask |= mask
if mask == ks.LAYER_STICK_FIGURE:
if not self.scene.getSceneParts(ks.LAYER_STICK_FIGURE):
self.mbody.createStickParts()
else:
cmask ^= mask
camera.setMask(cmask)
self.wcamera_physical = kw.Toggle(
self.router,
"Physical",
on_toggle=lambda cstate: _setCameraMask(cstate, ks.LAYER_PHYSICAL_GRAPHICS),
tooltip="Show/hide the physical meshes in 3D graphics",
render_as_button=True,
)
self.wcamera_physical.setValue(True)
self.wcamera_collision = kw.Toggle(
self.router,
"Collision",
on_toggle=lambda cstate: _setCameraMask(cstate, ks.LAYER_COLLISION),
tooltip="Show/hide the collision meshes in 3D graphics",
render_as_button=True,
)
self.wcamera_stick = kw.Toggle(
self.router,
"Stick",
on_toggle=lambda cstate: _setCameraMask(cstate, ks.LAYER_STICK_FIGURE),
tooltip="Show/hide the stick parts in 3D graphics",
render_as_button=True,
)
def _shadowsCB(cstate):
radius = 1 if cstate else 0
pixels = 2048 if cstate else 0
self.graphics._setShadows(np.array([0, 0, -1]), radius=radius, pixels=pixels)
self.wshadows = kw.Toggle(
self.router,
"Shadows",
on_toggle=lambda cstate: _shadowsCB(cstate),
tooltip="Turn shadows on/off",
render_as_button=True,
)
self.wlayout_3d = widgetArray(
self.router,
label="3D meshes",
children=[
self.wcamera_physical,
self.wcamera_collision,
self.wcamera_stick,
self.wshadows,
],
kind="inputgroup",
)
self.wlayout_3d.setTooltip("Select the meshes to display in 3D graphics")
def _sceneCB():
self.scene.update()
self.wscene = kw.Button(
self.router,
text="Update scene",
on_press=lambda: _sceneCB(),
)
self.wtoolbar = widgetArray(
self.router,
children=[
self.wdeselect,
self.wreset_viz,
self.wtreeviews,
self.wwebscene_selection_mode,
self.wwebscene_view_mode,
self.waxes_scale,
self.wlayout_3d,
self.wscene,
],
)
if sp is not None:
def _stopCB():
cast(kd.StatePropagator, self.sp).stop()
self.wstop = kw.Button(
self.router,
text="\u23f9",
on_press=lambda: _stopCB(),
)
self.wpause = kw.Button(
self.router, "\u23f8/\u25b6", createPauseCb(cast(kd.StatePropagator, self.sp))
)
self.wtoolbar.addChild(self.wpause)
self.wtoolbar.addChild(self.wstop)
# dock layout setup
self.dock = kw.Dock(self.router, style={"flexGrow": "1"})
self.dock.addChild(
title="3D View",
widget=self.graphics_frame,
)
self.dock.addChild(
title="Multibody",
widget=self.mbody_tree_view,
relative_to=self.graphics_frame,
direction="right",
)
self.dock.addChild(
title=self.mbody.frameContainer().name(),
widget=self.visjs_frames_iframe,
relative_to=self.graphics_frame,
direction="below",
)
self.dock.addChild(
title=self.mbody.name(),
widget=self.visjs_iframe,
relative_to=self.visjs_frames_iframe,
direction="within",
)
# selection info panel setup
self.info_panel = self._setupInfoPanel(self.selection)
self.selection.onChange(lambda x: self.updateInfoPanel(x, self.info_panel))
self.dock.addChild(
title="Selection Info",
widget=self.info_panel.wroot,
relative_to=self.mbody_tree_view,
direction="below",
)
self.dock.addChild(
title="SubTrees",
widget=self.subtrees_tree_view,
relative_to=self.mbody_tree_view,
direction="within",
)
if self.sp:
self.dock.addChild(
title="StatePropagator",
widget=self.sp_tree_view,
relative_to=self.mbody_tree_view,
direction="within",
)
"""
self.dock.addChild(
title="ALL",
widget=self.all_tree_view,
relative_to=self.mbody_tree_view,
direction="within",
)
"""
self.wmain.addChild(self.notifier.wroot)
self.wmain.addChild(self.wtoolbar)
self.wmain.addChild(self.dock)
self.wmain.addToDomRoot()
self.cleanups["clobberFields"] = self._clobberFields
if sp:
self._setupModels(sp, time_display_period)
if not any(km.isNotReadyNaN(x) for x in mbody.getQ()):
self.scene.update()
[docs]
def setAxesGlobalScale(self, part: float, line: float | None = None):
"""Set the global scaling factor for axes."""
self.effects.frame_axes.part_scale = part
if not line:
line = part
self.effects.frame_axes.line_scale = line
[docs]
def deselect(self):
"""Make it so nothing is selected."""
self.selection.set(kw.Selection().dump())
[docs]
def treeviews(self, selected_label: str):
"""Create the selected TreeView."""
tv = None
if selected_label == "SubTrees":
"""
tv = self._setupSubTreeTreeView(
options=SubTreeTreeViewOptions(subtree_subtrees=True)
)
"""
tv = self._setupSubTreesTreeView()
elif selected_label == "Scenes":
tv = self._setupScenesTreeView()
elif selected_label == "ALL":
tv = self._setupSubTreeTreeView(
options=SubTreeTreeViewOptions(
subtree_bodies=True,
subtree_subtrees=True,
body_nodes=True,
body_scene_parts=True,
loop_constraints=True,
collapse_depth=1,
)
)
elif selected_label == "Frames":
tv = self._setupFramesTreeView()
elif selected_label == "FrameSceneParts":
from ._treeview import createFrameScenePartsTreeView
tv = createFrameScenePartsTreeView(
router=self.router,
f=self.mbody.frameContainer().root(),
scene=self.scene,
gui_selection_state=self.selection,
)
elif selected_label == "Sim":
assert self.sim
from ._treeview import createSimTreeView
tv = createSimTreeView(
router=self.router, sim=self.sim, gui_selection_state=self.selection
)
elif selected_label == "StatePropagator":
tv = self._setupStatePropagatorTreeView(
sp=cast(kd.StatePropagator, self.sp),
options=SubTreeTreeViewOptions(
subtree_bodies=True,
subtree_subtrees=True,
body_nodes=True,
body_scene_parts=True,
loop_constraints=True,
),
)
elif selected_label == "Constraints":
tv = self._setupBilateralConstraintsTreeView(self.mbody)
elif selected_label == "Contact nodes":
tv = self._setupActiveContactNodesTreeView(self.mbody)
else:
print("MMM", selected_label)
if tv:
self.dock.addChild(
title=selected_label, # "Bilateral constraints",
widget=tv,
relative_to=self.mbody_tree_view,
direction="within",
)
tv.refresh()
def _clobberFields(self):
"""Drop references to fields so they can be discarded."""
del (
self.mbody_tree_view,
# self.subtrees_tree_view,
# self.all_tree_view,
self.info_panel,
self.visjs_frames_iframe,
self.visjs_iframe,
self.router,
self.mbody,
self.server,
self.graphics,
self.scene,
self.dock,
self.graphics_frame,
self.visjs_frames_server,
self.visjs_server,
self.wtoolbar,
self.wmain,
self.sp,
self.sim,
)
if hasattr(self, "wpause"):
del self.wpause
import gc
gc.collect()
def _setupServer(self, port: int) -> kw.HttpWsServer:
# main server setup
server = kw.HttpWsServer(port=port, display_name="Karana Viewer")
frontend = kc.findShareDir() / "WebUI" / "frontend"
server.serveFile("/", frontend / "index.html")
server.serveFile("/main.js", frontend / "router-main-bundle.js")
server.serveFile("/style.css", frontend / "style.css")
# Disabling this. If outside code adds extra widgets not
# managed by this class, they'll need the server to keep
# running.
# self.cleanups["cleanupServer"] = server.close
return server
def _ensureScene(self) -> ks.ProxyScene:
mbody = self.mbody
if not mbody:
raise ValueError("GUI has been closed")
scene = mbody.getScene()
if scene is None:
# Scene doesn't already exist, so we will create it
scene = ks.ProxyScene("webui_auto_scene", mbody.virtualRoot())
# Add a callback to cleanup the Scene we created
def clearScene():
mbody.setScene(None)
kc.discard(scene)
self.cleanups["clearScene"] = clearScene
# Add the new scene to the multibody
mbody.setScene(scene)
return scene
def _ensureGraphics(self, origin_frame: kf.Frame | None) -> ks.WebScene:
if not self.scene or not self.mbody:
raise ValueError("GUI has been closed")
mbid = self.mbody.id()
# Scan for an existing WebScene instances
for client in self.scene.clientScenes():
if isinstance(client, ks.WebScene):
break
else:
# No existing WebScene, so we create one
client = ks.WebScene("webui_auto_web_scene", kw.Server(port=0))
# Add the WebScene as a client of ProxyScene
self.scene.registerClientScene(client, origin_frame or self.mbody.virtualRoot())
scene = self.scene
def clearGraphics():
scene.unregisterClientScene(client)
kc.discard(client)
self.cleanups["clearGraphics"] = clearGraphics
def onPick(part: ks.WebScenePart | None):
# If nothing was picked, clear the selection state
if not part:
self.selection.set(kw.Selection().dump())
return
proxy_part = self.scene.lookupProxyFromImpl(part)
if self.webscene_selection_mode == "Frame":
obj = proxy_part.ancestorFrame()
elif self.webscene_selection_mode == "Body":
frame = proxy_part.ancestorFrame()
nd = self.mbody.getNodeAncestor(frame)
if not nd:
obj = self.mbody.virtualRoot()
else:
obj = nd.parentBody()
elif self.webscene_selection_mode == "ScenePart":
obj = proxy_part
else:
raise ValueError("Cannot determine webscene selection mode.")
selection = kw.Selection(
items=[kw.Selection.Item(id=obj.id(), context={"subtree_id": mbid})]
)
self.selection.set(selection.dump())
def onHover(part: ks.WebScenePart | None):
# If nothing was hovered, clear the hovered state
if not part:
self.hovered.set(kw.Selection().dump())
return
proxy_part = self.scene.lookupProxyFromImpl(part)
if self.webscene_selection_mode == "Frame":
obj = proxy_part.ancestorFrame()
elif self.webscene_selection_mode == "Body":
frame = proxy_part.ancestorFrame()
nd = self.mbody.getNodeAncestor(frame)
if not nd:
obj = self.mbody.virtualRoot()
else:
obj = nd.parentBody()
elif self.webscene_selection_mode == "ScenePart":
obj = proxy_part
else:
raise ValueError("Cannot determine webscene selection mode.")
self.hovered.set(
kw.Selection(
items=[kw.Selection.Item(id=obj.id(), context={"subtree_id": mbid})]
).dump()
)
client.setOnPick(onPick)
client.setOnHover(onHover)
# Create a weak ref for the closure to simplify cleanup
client_weakref = kc.CppWeakRef(client)
hover_overlay_id = client.addOverlayText("", 0.5, 0.05, ks.Alignment.CENTER)
def _handleHoverChange(raw: kw.Json):
# Display the name of the currently hovered item as overlay text
selection = kw.Selection.parse(raw)
if not selection.items:
if client := client_weakref():
client.setOverlayText(hover_overlay_id, "")
client.pushChanges()
return
item = selection.items[0]
base_container = kc.BaseContainer.singleton()
try:
obj = base_container.at(item.id)
except IndexError:
return
if client := client_weakref():
client.setOverlayText(hover_overlay_id, f"Hover: {obj.name()}")
client.pushChanges()
self.hovered.onChange(_handleHoverChange)
def _bodyOutlineEffects(bd: kd.PhysicalBody, level: OutlineLevel = "primary"):
# helper method to get list of outline effect items for a
# physical body that include stick parts as well
if bd.isRootBody():
return []
outline_effects = [
EffectItem(obj=bd, params=OutlineParams(level=level)),
# include the below so stick parts also get
# outline since they are connected to the pnode
EffectItem(obj=bd.pnode(), params=OutlineParams(level=level)),
]
# add these to include stick parts attached to the body nodes
for nd in bd.nodeList():
outline_effects.append(EffectItem(obj=nd, params=OutlineParams(level=level)))
for nd in bd.constraintNodeList():
outline_effects.append(EffectItem(obj=nd, params=OutlineParams(level=level)))
return outline_effects
def _update(raw: kw.Json):
# TODO maybe this should be move somewhere after both the
# effects helpers and graphics are setup
selection = kw.Selection.parse(raw)
if not selection.items:
self.effects.part_outliner.clear()
self.effects.frame_axes.clear()
for item in selection.items:
# FIXME: this will currently clobber effects for all but the last selected item
base_container = kc.BaseContainer.singleton()
try:
obj = base_container.at(item.id)
except IndexError:
return
# add appropriate effects based on object type
if isinstance(obj, kd.PhysicalBody):
outline_effects = _bodyOutlineEffects(obj, "primary")
self.effects.frame_outliner.set(outline_effects)
frm = obj if obj.isRootBody() else obj.pnode()
self.effects.frame_axes.set([EffectItem(obj=frm, params=AxesParams())])
elif isinstance(obj, kf.Frame):
# show the frame axes
self.effects.frame_outliner.clear()
self.effects.frame_axes.set([EffectItem(obj=obj, params=AxesParams())])
elif isinstance(obj, ks.WebScenePart):
# show the outline of this scene part
# graphics = self.scene.graphics()
self.effects.part_outliner.set(
[EffectItem(obj=obj, params=OutlineParams(level="primary"))]
)
psp = obj.getManager()
frame = psp.ancestorFrame()
self.effects.frame_axes.set([EffectItem(obj=frame, params=AxesParams())])
elif isinstance(obj, ks.WebSceneFileObject):
# show the outline of this scene part
# graphics = obj.graphics()
frame = obj.getManager().ancestorFrame()
if 0:
# TODO - enable once outline() method is available for SFOs
self.effects.part_outliner.set(
[EffectItem(obj=graphics, params=OutlineParams(level="primary"))]
)
else:
bd = cast(kd.PhysicalBody, frame)
self.effects.frame_outliner.set(_bodyOutlineEffects(bd, level="primary"))
self.effects.frame_axes.set([EffectItem(obj=frame, params=AxesParams())])
elif isinstance(obj, ks.ProxyScenePart):
# show the outline of this scene part
frame = obj.ancestorFrame()
self.effects.frame_axes.set([EffectItem(obj=frame, params=AxesParams())])
elif isinstance(obj, ks.ProxySceneFileObject):
# show the outline of this scene part
graphics = obj.graphics()
frame = obj.ancestorFrame()
if isinstance(graphics, ks.WebSceneFileObject):
if 0:
# TODO - enable once outline() method is available for SFOs
self.effects.part_outliner.set(
[EffectItem(obj=graphics, params=OutlineParams(level="primary"))]
)
else:
bd = cast(kd.PhysicalBody, frame)
self.effects.frame_outliner.set(
_bodyOutlineEffects(bd, level="primary")
)
self.effects.frame_axes.set([EffectItem(obj=frame, params=AxesParams())])
elif isinstance(obj, kd.SubTree):
# show the outline for all bodies in the subtree (TODO)
outline_effects = []
for bd in obj.sortedPhysicalBodiesList():
outline_effects.extend(_bodyOutlineEffects(bd, level="primary"))
self.effects.frame_outliner.set(outline_effects)
self.effects.frame_axes.clear()
elif isinstance(obj, kd.CompoundBody):
# show the outline for all bodies in the compound
# body's physical bodies subtree
outline_effects = []
for bd in obj.physicalBodiesTree().sortedPhysicalBodiesList():
outline_effects.extend(_bodyOutlineEffects(bd, level="primary"))
self.effects.frame_outliner.set(outline_effects)
self.effects.frame_axes.clear()
elif isinstance(obj, kd.LoopConstraintBase):
# highlight the physical bodies involved
src_node = obj.sourceNode()
tgt_node = obj.targetNode()
if src_node is not None:
mb = src_node.parentBody().multibody()
else:
mb = cast(kd.ConstraintNode, tgt_node).parentBody().multibody()
src_bd = mb.virtualRoot() if not src_node else src_node.parentBody()
tgt_bd = mb.virtualRoot() if not tgt_node else tgt_node.parentBody()
outline_effects = _bodyOutlineEffects(
src_bd, level="primary"
) + _bodyOutlineEffects(tgt_bd, level="secondary")
self.effects.frame_outliner.set(outline_effects)
# show axes for the frames involved
cf2f = obj.constraintFrameToFrame()
self.effects.frame_axes.set(
[
EffectItem(
obj=cf2f.oframe(), params=AxesParams(style="lines", scale=1)
),
EffectItem(
obj=cf2f.pframe(), params=AxesParams(style="lines", scale=0.75)
),
]
)
elif isinstance(obj, kd.CoordinateConstraint):
# highlight the physical bodies involved
oshg = obj.osubhinge()
src_bd = cast(kd.PhysicalHinge, oshg.parentHinge()).pnode().parentBody()
pshg = obj.psubhinge()
tgt_bd = cast(kd.PhysicalHinge, pshg.parentHinge()).pnode().parentBody()
outline_effects = _bodyOutlineEffects(
src_bd, level="primary"
) + _bodyOutlineEffects(tgt_bd, level="secondary")
self.effects.frame_outliner.set(outline_effects)
# show axes for the frames involved
self.effects.frame_axes.set(
[
EffectItem(obj=oshg.pframe(), params=AxesParams()),
EffectItem(obj=pshg.pframe(), params=AxesParams()),
]
)
elif isinstance(obj, kd.PhysicalSubhinge):
# highlight the physical bodies involved
fhge = obj.parentHinge()
if isinstance(fhge, kd.PhysicalHinge):
hge = cast(kd.PhysicalHinge, fhge)
outline_effects = _bodyOutlineEffects(
hge.onode().parentBody(), "primary"
) + _bodyOutlineEffects(hge.pnode().parentBody(), "secondary")
self.effects.frame_outliner.set(outline_effects)
# highlight the frames involved
self.effects.frame_axes.set(
[
EffectItem(obj=obj.oframe(), params=AxesParams()),
EffectItem(obj=obj.pframe(), params=AxesParams()),
]
)
elif isinstance(obj, kmdl.BaseKModel):
# show the outline for all bodies involved with the KModel, and axes for all the nodes
outline_effects = []
axes_effects = []
for bd in obj.multibodyObjs().physical_bodies:
outline_effects.append(
EffectItem(obj=bd, params=OutlineParams(level="primary"))
)
for nd in obj.multibodyObjs().nodes:
axes_effects.append(EffectItem(obj=nd, params=AxesParams()))
outline_effects.append(
EffectItem(obj=nd.parentBody(), params=OutlineParams(level="primary"))
)
for sh in obj.multibodyObjs().physical_subhinges:
outline_effects.append(
EffectItem(
obj=cast(kd.PhysicalHinge, sh.parentHinge()).pnode().parentBody(),
params=OutlineParams(level="primary"),
)
)
for st in obj.multibodyObjs().subtrees:
for bd in st.sortedPhysicalBodiesList():
outline_effects.append(
EffectItem(obj=bd, params=OutlineParams(level="primary"))
)
self.effects.frame_outliner.set(outline_effects)
self.effects.frame_axes.set(axes_effects)
self.selection.onChange(_update)
return client
def _setupFramesVisjs(
self, fc: kf.FrameContainer, use_id_labels: bool = False
) -> vjs.GraphServer:
if not self.mbody:
raise ValueError("GUI has been closed")
selection_state = self.selection
class ServerWithSelectCallback(vjs.GraphServer):
def onClickNode(self, client_id: int, node_id: int | str):
try:
int_id = int(node_id)
except ValueError:
return
# print("JJJ", self.visjs_frames_noselect_cb)
if self.visjs_frames_noselect_cb:
self.visjs_frames_noselect_cb(int_id)
else:
selection = kw.Selection(items=[kw.Selection.Item(id=int_id)])
selection_state.set(selection.dump())
def onClickEdge(self, client_id: int, edge_id: int | str):
try:
int_id = int(edge_id)
except ValueError:
return
selection = kw.Selection(items=[kw.Selection.Item(id=int_id)])
selection_state.set(selection.dump())
graph = vjs.framesToGraph(
fc,
extra_chains=self.mbody.enabledConstraints(),
use_id_labels=use_id_labels,
)
server = ServerWithSelectCallback(graph=graph, port=0)
server.visjs_frames_noselect_cb = None
# register this graph server with the graph effects helpers
self.effects.registerGraphServer(server)
# Add a callback to cleanup the visjs web server
def cleanupFramesVisjsServer():
self.effects.unregisterGraphServer(server)
server.close()
self.cleanups["cleanupFramesVisjsServer"] = cleanupFramesVisjsServer
return server
def _setupVisjs(self, st: kd.SubTree, use_id_labels: bool = False) -> vjs.MultibodyGraphServer:
if not self.mbody:
raise ValueError("GUI has been closed")
selection_state = self.selection
st_id = st.id()
class ServerWithSelectCallback(vjs.MultibodyGraphServer):
def onClickNode(self, client_id: int, node_id: int | str):
try:
int_id = int(node_id)
except ValueError:
return
# print("PPP", self.visjs_noselect_cb)
if self.visjs_noselect_cb:
self.visjs_noselect_cb(int_id)
else:
selection = kw.Selection(
items=[kw.Selection.Item(id=int_id, context={"subtree_id": st_id})]
)
selection_state.set(selection.dump())
def onClickEdge(self, client_id: int, edge_id: int | str):
try:
int_id = int(edge_id)
except ValueError:
return
selection = kw.Selection(
items=[kw.Selection.Item(id=int_id, context={"subtree_id": st_id})]
)
selection_state.set(selection.dump())
label_map = self.visjs_label_map
if use_id_labels:
label_map = {}
for bd in st.sortedBodiesList():
label_map[bd.id()] = str(bd.id())
server = ServerWithSelectCallback(
subtree=st,
label_map=label_map, # self.visjs_label_map,
buttons=None,
extra_edges=None,
port=0,
title=f"{st.name()} System",
)
server.visjs_noselect_cb = None
# register this graph server with the graph effects helpers
self.effects.registerGraphServer(server)
# enable constraints by default in the graph server (can toggle
# from subtree info panel)
if server.hasSubGraph("constraints"):
server.enableSubGraph("constraints")
def _setGraphSelection(raw: kw.Json):
"""Update the multibody visjs graph for the newly selected object."""
selection = kw.Selection.parse(raw)
if not selection.items:
self.effects.graph_edge_adder.clear()
self.effects.graph_highlighter.clear()
# helper method to emphasize a model in mbody visjs graph
def _emphasizeBaseKModel(mdl):
# show the outline for all bodies involved with the KModel, and axes for all the nodes
emphasis = []
mbobjs = mdl.multibodyObjs()
emphasis.extend(_emphasizeBodies(mbobjs.physical_bodies))
emphasis.extend(_emphasizeBodies([x.parentBody() for x in mbobjs.nodes]))
emphasis.extend(
_emphasizeBodies(
[x.parentHinge().pnode().parentBody() for x in mbobjs.physical_subhinges]
)
)
for st in mbobjs.subtrees:
emphasis.extend(_emphasizeBodies(st.sortedPhysicalBodiesList()))
self.effects.frame_outliner.set(emphasis)
return emphasis
# helper method to emphasize bodies in mbody visjs graph
def _emphasizeBodies(
primary: Sequence[kd.BodyBase],
secondary: Sequence[kd.BodyBase] = [],
tertiary: Sequence[kd.BodyBase] = [],
):
# highlight the subtree physical bodies in the multibody visjs graph
for bd in primary:
emphasis.append(EffectItem(obj=bd, params=OutlineParams(level="primary")))
for bd in secondary:
emphasis.append(EffectItem(obj=bd, params=OutlineParams(level="secondary")))
for bd in tertiary:
emphasis.append(EffectItem(obj=bd, params=OutlineParams(level="tertiary")))
return emphasis
emphasis = []
self.effects.graph_highlighter.clear()
for item in selection.items:
base_container = kc.BaseContainer.singleton()
try:
obj = base_container.at(item.id)
except IndexError:
continue
if isinstance(obj, kd.SubTree):
# highlight the subtree physical bodies in the multibody visjs graph
# generalize to handle compound body virtual roots (TODO)
bds = obj.sortedPhysicalBodiesList()
emphasis = _emphasizeBodies(primary=bds, secondary=[obj.virtualRoot()])
elif isinstance(obj, kd.CompoundBody):
# highlight the embedded physical bodies
emphasis = _emphasizeBodies(
primary=[obj] + obj.physicalBodiesTree().sortedPhysicalBodiesList(),
# secondary=[obj.physicalParentBody()],
)
elif isinstance(obj, kd.LoopConstraintBase):
self.effects.graph_highlighter.ensure([EffectItem(obj=obj, params=None)])
elif isinstance(obj, kd.CoordinateConstraint):
"""
# highlight the physical bodies involved
oshg = obj.osubhinge()
src_bd = cast(kd.PhysicalHinge, oshg.parentHinge()).pnode().parentBody()
pshg = obj.psubhinge()
tgt_bd = cast(kd.PhysicalHinge, pshg.parentHinge()).pnode().parentBody()
_emphasizeBodies(primary=[src_bd], secondary=[tgt_bd])
"""
pass
elif isinstance(obj, kd.PhysicalSubhinge):
# highlight the physical bodies involved
hge = cast(kd.PhysicalHinge, obj.parentHinge())
emphasis = _emphasizeBodies(
primary=[hge.onode().parentBody()], secondary=[hge.pnode().parentBody()]
)
self.effects.frame_outliner.set(emphasis)
# self.frame_axes.clear()
elif isinstance(obj, ks.ProxyScenePart):
# hightlight the frame this scene part is connected to
frm = obj.ancestorFrame()
assert isinstance(frm, kd.PhysicalBody)
bd = cast(kd.PhysicalBody, frm)
emphasis = _emphasizeBodies(primary=[bd], secondary=[])
elif isinstance(obj, ks.ProxySceneFileObject):
# hightlight the frame this scene part is connected to
frm = obj.ancestorFrame()
assert isinstance(frm, kd.PhysicalBody)
bd = cast(kd.PhysicalBody, frm)
emphasis = _emphasizeBodies(primary=[bd], secondary=[])
elif isinstance(obj, ks.WebScenePart):
# hightlight the frame this scene part is connected to
psp = obj.getManager()
frm = psp.ancestorFrame()
assert isinstance(frm, kd.PhysicalBody)
bd = cast(kd.PhysicalBody, frm)
emphasis = _emphasizeBodies(primary=[bd], secondary=[])
elif isinstance(obj, ks.WebSceneFileObject):
# hightlight the frame this scene part is connected to
psp = obj.getManager()
frm = psp.ancestorFrame()
assert isinstance(frm, kd.PhysicalBody)
bd = cast(kd.PhysicalBody, frm)
emphasis = _emphasizeBodies(primary=[bd], secondary=[])
elif isinstance(obj, kd.FramePairHinge):
# need this to keep the chained f2f part from kicking in (this edge already exists)
self.effects.graph_highlighter.ensure([EffectItem(obj=obj, params=None)])
elif isinstance(obj, kf.ChainedFrameToFrame) or isinstance(
obj, kf.OrientedChainedFrameToFrame
):
# this is for new f2fs selected via pick mode
# highlight the physical bodies involved
f2f = cast(kf.FrameToFrame, obj)
obd = self.mbody.getNodeAncestor(f2f.oframe()).parentBody()
pbd = self.mbody.getNodeAncestor(f2f.pframe()).parentBody()
emphasis = _emphasizeBodies(primary=[obd], secondary=[pbd])
self.effects.frame_outliner.set(emphasis)
# self.frame_axes.clear()
self.effects.graph_highlighter.set(
[
EffectItem(obj=cast(kc.Base, frame), params=None)
for frame in [f2f.oframe(), f2f.pframe()]
]
)
params = GraphEdgeParams(
color="#00ff00",
dashed=True,
id=f2f.id(),
arrows=True,
title=f"'{f2f.name()}' ({f2f.typeString()}) between\n the '{f2f.oframe().name()}/{f2f.pframe().name()}'\n frames",
)
self.effects.graph_edge_adder.set(
[EffectItem(obj=(f2f.oframe(), f2f.pframe()), params=params)]
)
# self.effects.graph_highlighter.clear()
elif isinstance(obj, kmdl.SpringDamper):
# show the outline for all bodies involved with the
# KModel, and axes for all the nodes
mbobjs = obj.multibodyObjs()
nd0 = mbobjs.nodes[0]
nd1 = mbobjs.nodes[1]
bd0 = nd0.parentBody()
bd1 = nd1.parentBody()
# visualize the ride spring element via a green dashed line
params = GraphEdgeParams(
color="#00ff00",
dashed=True,
id=obj.id(),
arrows=True,
title=f"'{obj.name()}' ({obj.typeString()}) model connecting\n the '{nd0.name()}/{bd0.name()}'\n and '{nd1.name()}/{bd1.name()}'\n body nodes",
)
graph_edge_emphasis = [EffectItem(obj=(bd0, bd1), params=params)]
self.effects.graph_edge_adder.set(graph_edge_emphasis)
elif SimpleSpdp is not None and isinstance(obj, SimpleSpdp):
# show the outline for all bodies involved with the
# KModel, and axes for all the nodes
mbobjs = obj.multibodyObjs()
# emphasis = _emphasizeBaseKModel(obj)
nd0 = mbobjs.nodes[0]
nd1 = mbobjs.nodes[1]
bd0 = nd0.parentBody()
bd1 = nd1.parentBody()
# visualize the ride spring element via a green dashed line
params = GraphEdgeParams(
color="#ff0000",
dashed=True,
arrows=True,
id=obj.id(),
title=f"'{obj.name()}' ({obj.typeString()}) model connecting\n the '{nd0.name()}/{bd0.name()}'\n and '{nd1.name()}/{bd1.name()}'\n body nodes",
)
graph_edge_emphasis = [EffectItem(obj=(bd0, bd1), params=params)]
self.effects.graph_edge_adder.set(graph_edge_emphasis)
elif isinstance(obj, (kmdl.BaseKModel)):
# fallback for a BaseKModel emphasis
# show the outline for all bodies involved with the
# KModel, and axes for all the nodes
emphasis = _emphasizeBaseKModel(obj)
else:
emphasis.append(EffectItem(obj=obj, params=None))
pass
# Update the visjs graph to emphasize whatever is selected
self.effects.graph_highlighter.ensure(emphasis)
selection_state.onChange(_setGraphSelection)
# Add a callback to cleanup the visjs web server
def cleanupVisjsServer():
self.effects.unregisterGraphServer(server)
server.close()
self.cleanups["cleanupVisjsServer"] = cleanupVisjsServer
return server
def _setupSubTreeTreeView(self, options: SubTreeTreeViewOptions) -> kw.TreeView:
"""Create NON-refreshable multibody level subtrees hierarchy."""
if not self.mbody or not self.router:
raise ValueError("GUI has been closed")
return createSubTreeTreeView(
router=self.router, gui_selection=self.selection, subtree=self.mbody, options=options
)
def _setupSubTreesTreeView(self) -> kw.TreeView:
"""Create refreshable multibody subtrees tree view.
Selections made from this view are current seg faulting unless the context
version of the SubTree tree view is created first. Wierd. TODO - fix.
"""
if not self.mbody or not self.router:
raise ValueError("GUI has been closed")
self.mbody.ensureHealthy()
return createSubTreesTreeView(
router=self.router,
gui_selection_state=self.selection,
mb=self.mbody,
)
def _setupStatePropagatorTreeView(
self, sp: kd.StatePropagator, options: SubTreeTreeViewOptions
) -> kw.TreeView:
if not self.mbody or not self.router:
raise ValueError("GUI has been closed")
return createStatePropagatorTreeView(
router=self.router,
sp=sp,
gui_selection_state=self.selection,
)
def _setupActiveContactNodesTreeView(self, st: kd.SubTree) -> kw.TreeView:
if not self.mbody or not self.router:
raise ValueError("GUI has been closed")
return createActiveContactNodesTreeView(
router=self.router, st=st, gui_selection_state=self.selection
)
def _setupBilateralConstraintsTreeView(self, st: kd.SubTree) -> kw.TreeView:
if not self.mbody or not self.router:
raise ValueError("GUI has been closed")
return createBilateralConstraintsTreeView(
router=self.router, st=st, gui_selection_state=self.selection
)
def _setupScenesTreeView(self) -> kw.TreeView:
"""Create scene tree view."""
if not self.mbody or not self.router:
raise ValueError("GUI has been closed")
ps = self.mbody.getScene()
if ps is None:
raise ValueError("The scene has not been created yet.")
return createScenesTreeView(
router=self.router, gui_selection_state=self.selection, pscene=ps
)
def _setupFramesTreeView(self) -> kw.TreeView:
"""Create frame tree view."""
if not self.mbody or not self.router:
raise ValueError("GUI has been closed")
self.mbody.frameContainer().ensureHealthy()
return createFramesTreeView(
router=self.router,
gui_selection_state=self.selection,
f=self.mbody.frameContainer().root(),
)
def _setupMultibodyTreeView(self) -> kw.TreeView:
"""Create multibody tree view."""
if not self.mbody or not self.router:
raise ValueError("GUI has been closed")
self.mbody.ensureHealthy()
return createSubTreeBodiesTreeView(
router=self.router,
gui_selection_state=self.selection,
st=self.mbody,
)
def _setupInfoPanel(self, state: kw.State) -> InfoPanel:
if not self.mbody or not self.router:
raise ValueError("GUI has been closed")
context = GuiContext(
dock=self.dock,
router=self.router,
selection=state, # self.selection,
multibody=self.mbody,
scene=self.scene,
graphics=self.graphics,
effects=self.effects,
mbody_tree_view=self.mbody_tree_view,
subtrees_tree_view=self.subtrees_tree_view,
setup_visjs=self._setupVisjs,
setup_info_panel=self._setupInfoPanel,
visjs_servers=self.visjs_servers,
visjs_frames_server=self.visjs_frames_server,
setup_frames_visjs=self._setupFramesVisjs,
visjs_iframe=self.visjs_iframe,
# visjs_frames_noselect_cb=self.visjs_frames_noselect_cb,
graphics_frame=self.graphics_frame,
signal_error=self._notifyError,
worker=self.worker,
)
info_panel = InfoPanel(context=context)
# Register cleanup callback
def _cleanup():
info_panel.close()
del context.graphics
del context.scene
del context.multibody
self.cleanups["cleanupInfoPanel"] = _cleanup
"""
def _update(raw: kw.Json):
obj = None
context = None
# Parse the raw json into a Selection struct
selection = kw.Selection.parse(raw)
if selection.items:
base_container = kc.BaseContainer.singleton()
try:
item = selection.items[0]
obj = base_container.at(item.id)
context = item.context
except IndexError:
pass
info_panel.updateFor(obj, context)
else:
info_panel.updateFor(None, None)
self.selection.onChange(_update)
"""
return info_panel
[docs]
def updateInfoPanel(self, raw: kw.Json, info_panel: InfoPanel):
"""Update the specified info panel based on selection change."""
obj = None
context = None
# Parse the raw json into a Selection struct
selection = kw.Selection.parse(raw)
if selection.items:
base_container = kc.BaseContainer.singleton()
try:
item = selection.items[0]
obj = base_container.at(item.id)
context = item.context
except IndexError:
pass
info_panel.updateFor(obj, context)
else:
info_panel.updateFor(None, None)
def _setupModels(self, sp: kd.StatePropagator, time_display_period: float | None):
if time_display_period is not None:
time_display = kmdl.TimeDisplay("auto_time_display", sp, self.graphics)
time_display.setPeriod(time_display_period)
[docs]
def close(self):
"""Idempotently close the GUI and cleanup created objects."""
self.cleanups.executeAndPopReverse()
[docs]
def __del__(self):
"""Idempotently close the GUI and cleanup created objects."""
self.close()
def _handleServerError(self, error_msg: str):
"""Handle server errors.
This is a handler to be registered with the HttpWsServer.
The ensures that uncaught GUI error are logged and displayed on
the frontend.
"""
kc.error(error_msg)
self._notifyError()
def _notifyError(self):
"""Notify the frontend that a new error has occurred."""
self.error_count += 1
markdown = f'<span style="color:red;">Error occurred ({self.error_count})</span>. _See console for details._'
self.notifier.notify(markdown)