Source code for Karana.KUtils.MultibodyTUI.mbody

# 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