# Copyright (c) 2024-2025 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.
"""MultibodyTUI class and state."""
from dataclasses import dataclass, field, replace
from typing import Literal, get_args, Any
from collections.abc import Mapping
import time
from Karana.Frame import Frame
from Karana.Scene import Color, PerspectiveProjection, OrthographicProjection, LAYER_GRAPHICS
from Karana.Scene.Scene_types import MaterialType
from Karana.Dynamics import (
PhysicalBody,
SubhingeType,
HingeBase,
SubhingeBase,
LoopConstraintBase,
)
from .base import TUIBase
from . import constants as c
from . import terminal as term
from .terminal import printLines, printWithPager, clearScreen, bold, invert, normalMode
from .graphics import (
PartHighlighter,
LoopConnector,
FrameAxesDisplay,
FramePairConnector,
MaskSelectTUI,
PerspectiveModeTUI,
)
from .history import StateHistory
from .view import View
from .graph import BodyGraphAdapter, FrameGraphAdapter
from .mode import ModeCycler
from .dialog import SearchDialog, FlagsTUI, FlagData
from .loopdb import LoopConstraintDatabase
from .swing import SwingConfig, SwingConfigMenuTUI, SwingManager
from .notify import Notifier
VizMode = Literal["item", "list", "none"]
VizModeCycler = ModeCycler[VizMode]
[docs]
def choiceDisplayStr(choice: Any, choices: list[Any]) -> str:
"""Construct a string indicating the selected choice from a list.
Parameters
----------
choice: Any
The currently selected choice
choices: list[Any]
A list of candidate choices
Returns
-------
str
A string listing choices, emphasizing any that equal choice
"""
choice_strs = []
for candidate in choices:
if choice == candidate:
choice_strs.append(bold(f">{candidate}<"))
else:
choice_strs.append(f" {candidate} ")
return "".join(choice_strs)
ViewTag = Literal["body", "frame", "loop_constraint"]
[docs]
@dataclass
class State:
"""Full state of the MultibodyTUI.
The TUI generally works by copying this state and pushing a modified
copy to the StateHistory stack. Separately the TUI can redraw its
terminal output and update the graphics window based on the current
State instance.
"""
# state specific to exploring bodies
body_view: View[PhysicalBody]
# state specific to exploring frames
frame_view: View[Frame]
# state specific to exploring loop constraints
loop_constraint_view: View[LoopConstraintBase]
# layers bitmask for the visualization camera
viz_mask: int
# controls which items are given emphasis in visualization
viz: VizModeCycler = field(default_factory=VizModeCycler)
# controls which view (eg: body, frame) is active
view_tag: ViewTag = "body"
# whether frame axes visualizations should be shown
show_frame_axes: bool = True
# what kind of swing to perform (see swing module for details)
swing_config: SwingConfig = field(default_factory=SwingConfig)
@property
def view(self):
"""The current view."""
view_tag = self.view_tag
if view_tag == "body":
return self.body_view
if view_tag == "frame":
return self.frame_view
if view_tag == "loop_constraint":
return self.loop_constraint_view
raise NotImplementedError(f"{view_tag=}")
[docs]
def replaceView(self, view: View):
"""Copy the full state replacing the current view.
Parameters
----------
view: View
The new view to replace the current view
Returns
-------
State
A copy of the full state with the current view replaced
"""
view_tag = self.view_tag
if view_tag == "body":
return replace(self, body_view=view)
if view_tag == "frame":
return replace(self, frame_view=view)
if view_tag == "loop_constraint":
return replace(self, loop_constraint_view=view)
raise NotImplementedError(f"{view_tag=}")
[docs]
class MultibodyTUI(TUIBase):
"""A terminal user interface (TUI) to examine a multibody."""
# **Key concepts for developers making changes**
#
# We maintain a stack of previous states to support undo/redo
# operations. Setting self.state implicitly pushes the new state
# value to the top of the stack. For this to work correctly, state
# changes must be handled with care. A State instance should be
# treated as immutable, and to make changes the current state
# should first be copied, then modified before setting it as the
# new state. The dataclass module provides a `replace` function to
# do this concisely, and we make extensive use of replace and copy
# methods whenever we modify the state so that changes don't leak
# backward into previous states.
#
# A _view_ has a specific meaning in the MultibodyTUI context and
# can be thought of as one aspect or dimension of the multibody and
# has little to do with visualization. For example, current views
# include the body view and frame view. At any time, exactly one
# view is active. The active view determines what information is
# displayed and may enable additional, specialized keyboard
# interactions beyond the ones that are available globally.
#
# The MultibodyTUI sets aside one line in its text display for
# arbitrary notification messages. This can be used to display
# errors or even nominal status information. Because space is
# constrained and multiple notification-worthy things can happen in
# a short span of time, we display notifications on a priority
# system where for example errors can replace info but not the other
# way around. The notification message and priority is cleared each
# time we process a key press. More details can be found in the
# notification module
def __init__(
self,
multibody,
*,
run: bool = True,
highlight: MaterialType | Color = Color.YELLOW,
axes_size: float = 10,
):
"""Create an instance of MultibodyTUI.
Parameters
----------
multibody :
The multibody to create a TUI for.
run : bool
If true, then run the MultibodyTUI.
highlight : MaterialType | Color
The highlight to use for the currently selected object.
axes_size : float
The size to use for the axes of the currently selected object.
"""
self.multibody = multibody
self.graph_adapters: Mapping[ViewTag, GraphAdapter] = {
"body": BodyGraphAdapter(self.multibody),
"frame": FrameGraphAdapter(self.multibody.frameContainer()),
}
self.loopdb = LoopConstraintDatabase(self.multibody)
self.hist = StateHistory(self._createDefaultState())
self.notify = Notifier(on_notify=self.redraw)
scene = multibody.getScene()
# used to change part color to highlight it
self.highlighter = PartHighlighter(highlight)
# an alternate highlighter (useful when highlighting 2 bodies at
# the same time and we can use different colors to distinguish
# them)
self.highlighter2 = PartHighlighter(Color.RED)
self.loop_lines = LoopConnector(scene, Color.YELLOW, Color.CYAN)
self.frame_lines = FramePairConnector(scene, Color.YELLOW, Color.CYAN)
self.frame_axes = FrameAxesDisplay(scene, axes_size=axes_size)
self.swing_manager = SwingManager(self.multibody, self.notify)
graphics = scene.graphics()
if graphics:
graphics._setBackgroundColor(Color.BLACK)
self.frame_overlay_id = graphics.addOverlayText("", 0, 0.05, color=Color.CYAN)
self.body_overlay_id = graphics.addOverlayText("", 0, 0.05, color=Color.YELLOW)
self.constraint_overlay_id = graphics.addOverlayText("", 0, 0.05, color=Color.RED)
self._updateGraphics()
super().__init__(run=run)
@property
def state(self) -> State:
"""The current state."""
return self.hist.state
@state.setter
def state(self, state: State):
"""Set the state, stowing the old one for undos."""
self.hist.state = state
def _createDefaultState(self) -> State:
try:
viz_mask = self.multibody.getScene().graphics().defaultCamera().getMask()
except AttributeError:
# No graphics set up, use default mask
viz_mask = LAYER_GRAPHICS
return State(
body_view=View("body", self.graph_adapters["body"].nodes()),
frame_view=View("frame", self.graph_adapters["frame"].nodes()),
loop_constraint_view=View("loop_constraint", list(self.loopdb.allLoopConstraints())),
viz_mask=viz_mask,
)
def _dumpCurrent(self):
current = self.state.view.current
if hasattr(current, "dumpString"):
text = self.state.view.current.dumpString()
printWithPager(text)
def _jumpNext(self, inc=1):
view = self.state.view.copy()
view.next(inc)
self.state = self.state.replaceView(view)
def _jumpPrev(self, inc=1):
view = self.state.view.copy()
view.prev(inc)
self.state = self.state.replaceView(view)
def _jumpInList(self, values, current=None):
view = self.state.view.copy()
view.select(values, current)
self.state = self.state.replaceView(view)
def _jumpFavorites(self):
favorites = self.state.view.favorites
if not favorites:
self.notify.error(f"No marked items in {self.state.view_tag} view")
return
self.notify.info(f"Marked items in {self.state.view_tag} view")
self._jumpInList(favorites)
def _toggleFavorite(self):
view = self.state.view.copy()
view.toggle_favorite()
self.state = self.state.replaceView(view)
def _jumpParents(self):
if self.state.view_tag == "loop_constraint":
self._jumpLcSource()
return
graph = self.graph_adapters.get(self.state.view_tag, None)
if graph is None:
self.notify.error(f"Cannot list parents for {self.state.view_tag} view")
return
current = self.state.view.current
parents = graph.parents(current)
if not parents:
self.notify.warn("No parents")
return
self.notify.info(f"Parents of {current.name()}")
self._jumpInList(parents)
def _jumpChildren(self):
if self.state.view_tag == "loop_constraint":
self._jumpLcTarget()
return
graph = self.graph_adapters.get(self.state.view_tag, None)
if graph is None:
self.notify.error(f"Cannot list children for {self.state.view_tag} view")
return
current = self.state.view.current
children = graph.children(current)
if not children:
self.notify.warn("No children")
return
self.notify.info(f"Children of {current.name()}")
self._jumpInList(children)
def _jumpAll(self):
if self.state.view_tag == "loop_constraint":
self.notify.info(f"Listing all loop constraints")
self._jumpInList(list(self.loopdb.allLoopConstraints()))
return
graph = self.graph_adapters.get(self.state.view_tag, None)
if graph is None:
self.notify.error(f"Cannot list all items for {self.state.view_tag} view")
return
self._jumpInList(graph.nodes())
self.notify.info(f"Listing all items in {self.state.view_tag} view")
def _jumpAncestors(self):
graph = self.graph_adapters.get(self.state.view_tag, None)
if graph is None:
self.notify.error(f"Cannot list ancestors for {self.state.view_tag} view")
return
current = self.state.view.current
ancestors = graph.ancestors(current)
if not ancestors:
self.notify.warn(f"No ancestors")
return
self._jumpInList(ancestors)
self.notify.info(f"Ancestors of {current.name()}")
def _jumpDescendants(self):
graph = self.graph_adapters.get(self.state.view_tag, None)
if graph is None:
self.notify.error(f"Cannot list descendants for {self.state.view_tag} view")
return
current = self.state.view.current
descendants = graph.descendants(current)
if not descendants:
self.notify.warn(f"No descendants")
return
self._jumpInList(descendants)
self.notify.info(f"Descendants of {current.name()}")
def _updateGraphics(self):
viz_mode = self.state.viz.mode
show_axes = self.state.show_frame_axes
if viz_mode == "item":
items = [self.state.view.current]
elif viz_mode == "list":
items = self.state.view.selection
else:
items = []
viz_scene = self.multibody.getScene().graphics()
if viz_scene:
viz_scene.setOverlayText(self.body_overlay_id, "")
viz_scene.setOverlayText(self.frame_overlay_id, "")
viz_scene.setOverlayText(self.constraint_overlay_id, "")
if self.state.view_tag == "body":
# highlight the selected bodies
parts = sum((body.getSceneParts() for body in items), [])
self.highlighter.clear()
self.highlighter2.clear()
self.highlighter.set(parts)
# highlight the inboard body with different color
if viz_mode == "item":
body = items[0]
if viz_scene:
viz_scene.setOverlayText(self.body_overlay_id, body.name())
if not body.isRootBody():
pbody = body.physicalParentBody()
parts = pbody.getSceneParts()
self.highlighter2.set(parts)
if show_axes:
self.frame_axes.set(items)
else:
self.frame_axes.clear()
self.loop_lines.clear()
pairs = []
for body in items:
if body == self.multibody.virtualRoot():
continue
parent = body.physicalParentBody()
if parent in items:
pairs.append((body, parent))
self.frame_lines.set(pairs)
elif self.state.view_tag == "frame":
self.highlighter.clear()
self.highlighter2.clear()
if show_axes:
self.frame_axes.set(items)
else:
self.frame_axes.clear()
self.loop_lines.clear()
if len(items) == 1:
if viz_scene:
viz_scene.setOverlayText(self.frame_overlay_id, items[0].name())
pairs = []
# show lines conntecting the frame to its parent
for frame in items:
parent = frame.parent()
if parent in items:
pairs.append((frame, parent))
# hightight the parts of the body that the frame is
# attached to
nd = self.multibody.getNodeAncestor(frame)
if nd:
body = nd.parentBody()
parts = body.getSceneParts()
self.highlighter.set(parts)
self.frame_lines.set(pairs)
elif self.state.view_tag == "loop_constraint":
self.highlighter.clear()
self.highlighter2.clear()
frames = sum(
[[lc.sourceNode().parentBody(), lc.targetNode().parentBody()] for lc in items], []
)
if show_axes:
self.frame_axes.set(frames)
else:
self.frame_axes.clear()
self.loop_lines.set(items)
pairs = []
if viz_mode == "item":
lc = self.state.view.current
if viz_scene:
viz_scene.setOverlayText(self.constraint_overlay_id, lc.name())
bodies = self.loopdb.relatedBodies(lc)
for body in bodies:
if body == self.multibody.virtualRoot():
continue
parent = body.physicalParentBody()
if parent in bodies:
pairs.append((body, parent))
# highlight the source body
parts = lc.sourceNode().parentBody().getSceneParts()
self.highlighter.set(parts)
# highlight the target body
parts = lc.targetNode().parentBody().getSceneParts()
self.highlighter2.set(parts)
self.frame_lines.set(pairs)
else:
self.highlighter.clear()
self.highlighter2.clear()
self.frame_axes.clear()
self.loop_lines.clear()
self.frame_lines.clear()
viz_mask = self.state.viz_mask
if viz_scene:
camera = viz_scene.defaultCamera()
if viz_mask != camera.getMask():
camera.setMask(viz_mask)
def _configSwing(self):
tui = SwingConfigMenuTUI(init_config=self.state.swing_config)
if not (swing_config := tui.config):
self.notify.error("Aborted swing config changes")
return
self.notify.info("Swing config updated")
self.state = replace(self.state, swing_config=swing_config)
def _articulateBody(self, ik=True, index=0):
if self.state.view_tag != "body":
# This shouldn't happen
return
body = self.state.view.current
if body == self.multibody.virtualRoot():
self.notify.error("Cannot swing virtual root")
return
hinge = body.parentHinge()
self.notify.info(
f"Articulating body hinge [{self.state.swing_config.mode}] coord index={index} ..."
)
self.redraw()
self.swing_manager.swingHinge(hinge, coord_offset=index, **self.state.swing_config.__dict__)
self.notify.info(
f"Done articulating hinge [{self.state.swing_config.mode}] coord index={index}"
)
self.redraw()
def _articulateLoopConstraint(self, ik=True, index=0):
if self.state.view_tag != "loop_constraint":
return
if not hasattr(self.state.view.current, "hinge"):
if not self.state.swing_config.mode == "udot_pulse":
self.notify.error(
f"Convel constraints can only be articulated in udot_pulse mode not {self.state.swing_config.mode} mode"
)
return
# this is a convel loop constraint - need to apply inputs
# self.notify.error("Cannot swing non-hinge loop constraints")
# return
self.notify.info(f"Articulating convel [{self.state.swing_config.mode}]...")
self.redraw()
kwargs = self.state.swing_config.__dict__.copy()
del kwargs["mode"]
self.swing_manager.swingConvel(self.state.view.current, **kwargs)
self.notify.info(f"Done articulating convel [{self.state.swing_config.mode}]")
else:
# hinge loop constraint
hinge = self.state.view.current.hinge()
self.notify.info(
f"Articulating loop constraint hinge [{self.state.swing_config.mode}]..."
)
self.redraw()
self.swing_manager.swingHinge(
hinge, coord_offset=index, **self.state.swing_config.__dict__
)
self.notify.info(f"Done articulating hinge [{self.state.swing_config.mode}]")
self.redraw()
def _jumpLcSource(self):
if self.state.view_tag != "loop_constraint":
# This shouldn't happen
return
loop_constraint_view = self.state.loop_constraint_view
frame_view = self.state.frame_view.copy()
node = loop_constraint_view.current.sourceNode()
frame_view.select([node])
self.state = replace(self.state, frame_view=frame_view, view_tag="frame")
self.notify.info(f"Source node of {loop_constraint_view.current.name()}")
def _jumpLcTarget(self):
if self.state.view_tag != "loop_constraint":
# This shouldn't happen
return
loop_constraint_view = self.state.loop_constraint_view
frame_view = self.state.frame_view.copy()
node = loop_constraint_view.current.targetNode()
frame_view.select([node])
self.state = replace(self.state, frame_view=frame_view, view_tag="frame")
self.notify.info(f"Target node of {loop_constraint_view.current.name()}")
def _lcBodies(self):
if self.state.view_tag != "loop_constraint":
# This shouldn't happen
return
lc = self.state.loop_constraint_view.current
body_view = self.state.body_view.copy()
bodies = self.loopdb.relatedBodies(lc)
if not bodies:
self.notify.warn("No related bodies")
return
body_view.select(bodies)
self.notify.info(f"Bodies related to {lc.name()}")
self.state = replace(self.state, body_view=body_view, view_tag="body")
def _bodyEmanatingLcs(self):
if self.state.view_tag != "body":
# This shouldn't happen
return
body = self.state.body_view.current
loop_constraints = self.loopdb.emanatingLoopConstraints(body)
if not loop_constraints:
self.notify.warn("No emanating loop constraints")
return
loop_constraint_view = self.state.loop_constraint_view.copy()
loop_constraint_view.select(loop_constraints)
self.notify.info(f"Loops emanating from {body.name()}")
self.state = replace(
self.state, loop_constraint_view=loop_constraint_view, view_tag="loop_constraint"
)
def _bodyAllLcs(self):
if self.state.view_tag != "body":
# This shouldn't happen
return
body = self.state.body_view.current
loop_constraints = self.loopdb.relatedLoopConstraints(body)
if not loop_constraints:
self.notify.warn("No related loop constraints")
return
loop_constraint_view = self.state.loop_constraint_view.copy()
loop_constraint_view.select(loop_constraints)
self.notify.info(f"Loops related to {body.name()}")
self.state = replace(
self.state, loop_constraint_view=loop_constraint_view, view_tag="loop_constraint"
)
def _printHelp(self):
text = "\n".join(
[
"This is terminal user interface (TUI) to explore",
"a multibody. Below is a reference for what actions",
"are performed by various keys. Please note that",
"these keys are case sensitive, so holding shift",
"will cause a key to do something different. Users",
"are advised to ensure caps lock is turned off ",
"while using the TUI.",
"",
"Basic controls:",
" ?: help",
" Ctrl-C or q: quit",
" 1: go to body view",
" 2: go to frame view",
" 3: go to loop constraint view",
" d: dump current object",
" → (right arrow) or l: next in list",
" ← (left arrow) or h: previous in list",
" u: undo",
" Shift-U: redo",
"",
"Graphics controls:",
" v: cycle to next visualization mode",
" Shift-V: cycle to prev visualization mode",
" c: flash the visualization mode on and off",
" Shift-C: configure camera angle and projection",
" Shift-L: configure scene layer visibility",
" Shift-F: toggle frame axes visibility",
" Shift-S: configure the articulation mode",
"",
"Additional navigation controls:",
" ↓ (down arrow) or j: list children",
" ↑ (down arrow) or k: list parent(s)",
" a: list all objects",
" / (slash): search by name",
" ] or [: forward or back 10 in list",
" } or {: forward or back 100 in list",
" m: mark current object",
" Shift-M: list marked objects",
" Shift-A: list ancestor objects",
" Shift-D: list descendant objects",
"",
"Console controls:",
" ~ (tilde): embed a python REPL",
" This starts an interactive IPython console.",
" Variables defined in the script are accessible.",
" Additionally, the following names are injected:",
" item: the current item in the TUI",
" items: the currently selected item list in the TUI",
" multibody: the multibody the TUI is attached to",
" Exit with Ctrl-D or quit() to return to the TUI.",
"",
"Body view exclusive controls:",
" f: show listed bodies in frame view",
" e: list the body's emanating loop constraints",
" Shift-E: list all loop constraints involving the body",
" n: list the body's nodes in frame view",
" s: articulate the body's parent subhinge coordinate 0",
" t: articulate the body's parent subhinge coordinate 1",
" w: articulate the body's parent subhinge coordinate 2",
" x: articulate the body's parent subhinge coordinate 3",
" y: articulate the body's parent subhinge coordinate 4",
" z: articulate the body's parent subhinge coordinate 5",
"",
"Frame view exclusive controls:",
" b: show nearest ancestor body",
"",
"Loop constraint view exclusive controls:",
" ↓ (down arrow) or j: show target node in frame view",
" ↑ (up arrow) or k: show source node in frame view",
" s: articulate the loop constraint's subhinges",
" b: list all related bodies",
"",
]
)
printWithPager(text)
[docs]
def redraw(self):
"""Redraw the MultibodyTUI view."""
view = self.state.view
value = view.current
view_str = choiceDisplayStr(self.state.view_tag, get_args(ViewTag))
viz = self.state.viz
viz_mode_str = choiceDisplayStr(viz.mode, viz.modes)
lines = [
f"MultibodyTUI (press '?' for help and Ctrl-C or 'q' to quit)",
str(self.notify.message),
f"view: {view_str}",
f"viz mode: {viz_mode_str}",
"",
]
view_line = f"{view.name} ({view.index + 1}/{len(view)})"
if hasattr(value, "name"):
view_line = f"{view_line}: {term.bold(term.info(value.name()))}"
if view.on_favorite:
view_line = invert(bold(view_line))
lines.append(view_line)
if self.state.view_tag == "body":
body = value
vroot = self.multibody.virtualRoot()
if body == vroot:
parent_name = "N/A"
hinge_type = "N/A"
else:
parent_name = body.physicalParentBody().name()
hinge_type = body.parentHinge().hingeType()
related_loops = self.loopdb.relatedLoopConstraints(body)
emanating_loops = self.loopdb.emanatingLoopConstraints(body)
axes = ""
if not body.isRootBody():
for i in range(body.parentHinge().nSubhinges()):
sh = body.parentHinge().subhinge(i)
if sh.subhingeType() in [
SubhingeType.PIN,
SubhingeType.LINEAR,
SubhingeType.SCREW,
]:
axes += f"{sh.getUnitAxis()}, "
lines.extend(
[
f" parent: {parent_name}",
f" hinge: {hinge_type} {axes}",
f" num children: {len(body.multibody().childrenBodies(body))}",
f" num emanating loops: {len(emanating_loops)}",
f" num related loops: {len(related_loops)}",
]
)
elif self.state.view_tag == "frame":
frame = value
parent = frame.parent()
parent_name = parent.name() if parent else "N/A"
graph = self.graph_adapters["frame"]
nchildren = len(graph.children(frame))
lines.extend(
[
f" parent: {parent_name}",
f" num children: {nchildren}",
]
)
elif self.state.view_tag == "loop_constraint":
lc = value
snd = lc.sourceNode()
tnd = lc.targetNode()
lines.extend(
[
f" source node: {snd.name()}/{snd.parentBody().name()}",
f" target node: {tnd.name()}/{tnd.parentBody().name()}",
]
)
htype = (
"CONVEL" if not lc.hasHinge() else HingeBase.hingeTypeString(lc.hinge().hingeType())
)
axes = ""
if lc.hasHinge():
hge = lc.hinge()
if htype == "CUSTOM":
shs = []
for i in range(hge.nSubhinges()):
shs.append(SubhingeBase.subhingeTypeString(hge.subhinge(i).subhingeType()))
htype += " " + str(shs)
for i in range(hge.nSubhinges()):
sh = hge.subhinge(i)
if sh.subhingeType() in [
SubhingeType.PIN,
SubhingeType.LINEAR,
SubhingeType.SCREW,
]:
axes += f"{sh.getUnitAxis()}, "
else:
axes = lc.getUnitAxis()
lines.extend([f" {htype} {axes}"])
clearScreen()
printLines(lines)
def _activateView(self, view_tag: str):
# Leftover messages from the previous view would be misleading
self.notify.clear()
if view_tag == "loop_constraint":
if not len(self.state.loop_constraint_view):
self.notify.warn("Multibody has no loop constraints")
return
self.state = replace(self.state, view_tag=view_tag)
def _bodyToFrame(self):
if self.state.view_tag != "body":
# This should never happen
return
body_view = self.state.body_view
frame_view = self.state.frame_view.copy()
frame_view.select(body_view.selection, body_view.current)
self.notify.info("Viewing selected bodies in frame view")
self.state = replace(self.state, frame_view=frame_view, view_tag="frame")
def _bodyNodesToFrames(self):
if self.state.view_tag != "body":
# This should never happen
return
body_view = self.state.body_view
frame_view = self.state.frame_view.copy()
body = body_view.current
nodes = body.nodeList() + body.constraintNodeList()
if not nodes:
self.notify.warn("Body has no nodes to list")
return
frame_view.select(nodes, body_view.current)
self.notify.info(f"Nodes of {body.name()}")
self.state = replace(self.state, frame_view=frame_view, view_tag="frame")
def _frameToBody(self):
if self.state.view_tag != "frame":
# This should never happen
return
frame_view = self.state.frame_view
body_view = self.state.body_view.copy()
frame = frame_view.current
while frame and not isinstance(frame, PhysicalBody):
frame = frame.parent()
if not frame:
self.notify.warn("No bodies inboard from this frame")
return
body_view.select([frame])
self.notify.info(f"Inboard body of {frame.name()}")
self.state = replace(self.state, body_view=body_view, view_tag="body")
def _nextVizMode(self):
viz = self.state.viz.copy()
viz.next()
self.state = replace(self.state, viz=viz)
def _prevVizMode(self):
viz = self.state.viz.copy()
viz.prev()
self.state = replace(self.state, viz=viz)
def _flashVizMode(self):
orig_mode = self.state.viz.mode
if orig_mode == "none":
self.notify.warn("Cannot flash when viz mode is none")
modes = ["none", orig_mode] * 5 + ["none"]
for mode in modes:
self.state.viz.mode = mode
self._updateGraphics()
time.sleep(0.1)
self.state.viz.mode = orig_mode
def _configVizLayers(self):
camera = self.multibody.getScene().graphics().defaultCamera()
mask_tui = MaskSelectTUI(camera)
if mask_tui.confirmed:
viz_mask = mask_tui.mask
self.state = replace(self.state, viz_mask=viz_mask)
def _configCamera(self):
camera = self.multibody.getScene().graphics().defaultCamera()
camera_tui = PerspectiveModeTUI()
if camera_tui.mode is None:
self.notify.error("Aborted setting camera mode")
return
mode = camera_tui.mode
perspective_modes = ["pers_free"]
orthographic_modes = [
"ortho_px",
"ortho_py",
"ortho_pz",
"ortho_nx",
"ortho_ny",
"ortho_nz",
]
description_map = {
"pers_free": "free movement",
"ortho_px": "viewing from +x",
"ortho_py": "viewing from +y",
"ortho_pz": "viewing from +z",
"ortho_nx": "viewing from -x",
"ortho_ny": "viewing from -y",
"ortho_nz": "viewing from -z",
}
self.notify.info(f"Updated camera: {description_map[mode]}")
if mode in perspective_modes:
projection = PerspectiveProjection()
elif mode in orthographic_modes:
projection = OrthographicProjection()
else:
self.notify.error(f"Unexpected camera mode: {mode}")
camera.setProjection(projection)
target = [0, 0, 0]
if mode == "pers_free":
offset = [3, 3, 3]
up = [0, 0, 1]
elif mode == "ortho_px":
offset = [5, 0, 0]
up = [0, 0, 1]
elif mode == "ortho_py":
offset = [0, 5, 0]
up = [0, 0, 1]
elif mode == "ortho_pz":
offset = [0, 0, 5]
up = [0, 1, 0]
elif mode == "ortho_nx":
offset = [-5, 0, 0]
up = [0, 0, 1]
elif mode == "ortho_ny":
offset = [0, -5, 0]
up = [0, 0, 1]
elif mode == "ortho_nz":
offset = [0, 0, -5]
up = [0, 1, 0]
camera.pointCameraAt(offset=offset, target=target, up=up)
def _jumpSearch(self):
if self.state.view_tag == "loop_constraint":
nodes = self.multibody.enabledLoopConstraints()
else:
graph = self.graph_adapters.get(self.state.view_tag, None)
if graph is None:
self.notify.error(f"Cannot search in {self.state.view_tag} view")
return
nodes = graph.nodes()
name_map = {x.name(): x for x in nodes}
search = SearchDialog(
prompt=[f"Searching by {self.state.view_tag} name:"], universe=list(name_map)
)
entry = search.run()
if not entry:
self.notify.error("Search aborted")
return
matches = search.matches()
if not matches:
self.notify.error("No matches found")
return
values = [name_map[match] for match in matches]
self._jumpInList(values)
def _toggleFrameAxes(self):
show_frame_axes = not self.state.show_frame_axes
if show_frame_axes:
self.notify.info("Showing frame axes")
else:
self.notify.info("Hiding frame axes")
self.state = replace(self.state, show_frame_axes=show_frame_axes)
def _embedConsole(self):
item = self.state.view.current
items = self.state.view.selection
multibody = self.multibody
with normalMode():
from IPython import embed
embed()
def _notifyNoCommand(self, key: str):
self.notify.warn(f"No command defined for key: {repr(key)}")
def _undo(self):
# Leftover messages could be misleading
self.notify.clear()
if not self.hist.can_undo():
self.notify.warn(f"Nothing to undo")
return
self.hist.undo()
current_count = self.hist.current_state_count
total_count = self.hist.total_state_count
width = len(str(total_count))
status_str = f"[{current_count:{width}}/{total_count}]"
self.notify(f"Undo {status_str}")
def _redo(self):
# Leftover messages could be misleading
self.notify.clear()
if not self.hist.can_redo():
self.notify.warn(f"Nothing to redo")
return
self.hist.redo()
current_count = self.hist.current_state_count
total_count = self.hist.total_state_count
width = len(str(total_count))
status_str = f"[{current_count:{width}}/{total_count}]"
self.notify(f"Redo {status_str}")
[docs]
def handleKey(self, key: str) -> bool:
"""Handle a single key press from the user."""
# IMPORTANT: If modifying handleKey, be sure to also update
# _printHelp so that the two remain consistent!!
# Reset the notification message priority. This is needed so
# that a lower priority message can replace a higher priority
# one left over from a previous key press.
self.notify.clear_priority()
# Basic controls
if key == "?":
self._printHelp()
elif key == "q":
return False
elif key == "1":
self._activateView("body")
elif key == "2":
self._activateView("frame")
elif key == "3":
self._activateView("loop_constraint")
elif key == "d":
self._dumpCurrent()
elif key in ["h", c.KEY_LEFT]:
self._jumpPrev()
elif key in ["l", c.KEY_RIGHT]:
self._jumpNext()
elif key == "u":
self._undo()
elif key == "U":
self._redo()
elif key == "/":
self._jumpSearch()
# Graphics controls
elif key == "v":
self._nextVizMode()
elif key == "C":
self._configCamera()
elif key == "V":
self._prevVizMode()
elif key == "L":
self._configVizLayers()
elif key == "F":
self._toggleFrameAxes()
elif key == "c":
self._flashVizMode()
elif key == "S":
self._configSwing()
# Advanced navigation controls
elif key in ["k", c.KEY_UP]:
self._jumpParents()
elif key in ["j", c.KEY_DOWN]:
self._jumpChildren()
elif key == "]":
self._jumpNext(10)
elif key == "}":
self._jumpNext(100)
elif key == "[":
self._jumpPrev(10)
elif key == "{":
self._jumpPrev(100)
elif key == "a":
self._jumpAll()
elif key == "m":
self._toggleFavorite()
elif key == "M":
self._jumpFavorites()
elif key == "A":
self._jumpAncestors()
elif key == "D":
self._jumpDescendants()
# Other advanced controls
elif key == "~":
self._embedConsole()
# Body-specific controls
elif self.state.view_tag == "body":
if key == "s":
self._articulateBody(index=0)
elif key == "t":
self._articulateBody(index=1)
elif key == "w":
self._articulateBody(index=2)
elif key == "x":
self._articulateBody(index=3)
elif key == "y":
self._articulateBody(index=4)
elif key == "z":
self._articulateBody(index=5)
elif key == "f":
self._bodyToFrame()
elif key == "n":
self._bodyNodesToFrames()
elif key == "e":
self._bodyEmanatingLcs()
elif key == "E":
self._bodyAllLcs()
else:
self._notifyNoCommand(key)
# Frame-specific controls
elif self.state.view_tag == "frame":
if key == "b":
self._frameToBody()
else:
self._notifyNoCommand(key)
# Loop constraint-specific controls
elif self.state.view_tag == "loop_constraint":
if key == "s":
self._articulateLoopConstraint(index=0)
elif key == "t":
self._articulateLoopConstraint(index=1)
elif key == "w":
self._articulateLoopConstraint(index=2)
elif key == "x":
self._articulateLoopConstraint(index=3)
elif key == "y":
self._articulateLoopConstraint(index=4)
elif key == "z":
self._articulateLoopConstraint(index=5)
# if key == "s":
# self._articulateLoopConstraint()
elif key == "b":
self._lcBodies()
else:
self._notifyNoCommand(key)
else:
self._notifyNoCommand(key)
self._updateGraphics()
return True