Source code for Karana.KUtils.MultibodyTUI.graphics

# 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.

from dataclasses import dataclass
from collections.abc import Iterable, Mapping
from abc import ABC, abstractmethod
from typing import TypeVar, Generic, Literal
from Karana.Scene import ProxyScene, ProxySceneNode
from Karana.Scene import (
    ScenePart,
    PhongMaterial,
    PhongMaterialInfo,
    Color,
    LAYER_PHYSICAL_GRAPHICS,
    LAYER_ORNAMENTAL,
    LAYER_STICK_FIGURE,
    LAYER_COLLISION,
)
from Karana.Scene.Scene_types import MaterialType
from Karana.Dynamics import LoopConstraintHinge
from Karana.Frame import Frame
from Karana.Scene import GraphicalSceneCamera

from .dialog import FlagData, FlagsTUI, ChoiceData, ChoiceTUI


Item = TypeVar("Item")
Cookie = TypeVar("Cookie")


[docs] class FocusBase(Generic[Item, Cookie], ABC): """Base class for emphasizing items in a list The intent is for concrete subclasses to implement side effects in _focus_one that indicate to the user that the item is in focus (and do the inverse in _unfocus_one). For example, if Item is a Frame then _focus_one might turn on axes visualization for that Frame. The Cookie can be used if some arbitrary state from _focus_one is needed later in the corresponding _unfocus_one. """ def __init__(self): """FocusBase constructor""" self._focus_cookies: Mapping[Item, Cookie] = {} @abstractmethod def _focus_one(self, item: Item) -> Cookie: """Side effects to put the given item in focus Concrete classes should implement this method with whatever side effects are necessary to indicate that the given item is in focus. If some extra state needs to be maintained to be able to later unfocus the item, it should be returned. Parameters ---------- item: Item The item to focus Returns ------- Cookie Arbitrary value to be passed to a later _unfocus_one call for the same item """ @abstractmethod def _unfocus_one(self, item: Item, cookie: Cookie): """Side effects to remove focus from the given item Concrete classes should implement this method with whatever side effects are necessary to unfocus a previously focused item. If some additional state is needed it can be returned by _focus_one and will be passed as an argument here. Parameters ---------- item: Item The item to unfocus cookie: Cookie Value that was returned be the prior _focus_one call for the given item """
[docs] def list_focused(self) -> Iterable[Item]: """All currently focused items""" return list(self._focus_cookies)
[docs] def is_focused(self, item: Item) -> bool: """Whether a given item is focused""" return item in self._focus_cookies
[docs] def set(self, items: Iterable[Item]): """Set the list of focused items This will intelligently consider only the changes in the focus list and do the minimal amount of focusing/unfocusing. This is to avoid triggering unnecesssary side effects that may be performance intensive. Parameters ---------- items: Iterable[Item] The new list of focused items """ old = set(x for x in self.list_focused()) new = set(x for x in items) self.remove(old - new) self.add(new - old)
[docs] def add(self, items: Iterable[Item]): """Additionally focus a list of items This doesn't affect currently focused items and will ignore items from the given list that are already focused. Parameters ---------- items: Iterable[Item] The additional new items to focus """ for item in items: if self.is_focused(item): continue cookie = self._focus_one(item) self._focus_cookies[item] = cookie
[docs] def remove(self, items: Iterable[Item]): """Unfocus a list of items This will ignore items that aren't already focused Parameters ---------- items: Iterable[Item] The items to unfocus """ for item in items: if not self.is_focused(item): continue cookie = self._focus_cookies.pop(item) self._unfocus_one(item, cookie)
[docs] def clear(self): """Unfocus all focused items""" for item, cookie in self._focus_cookies.items(): self._unfocus_one(item, cookie) self._focus_cookies = {}
[docs] def __del__(self): self.clear()
[docs] class NodePool: """Basic helper for reusable ProxySceneNodes For use cases where nodes need to be constantly added and removed, this pool can be used in lieu of actually creating and destroying the nodes. Instead, when a node is no longer needed it can be returned to the pool for later reuse. If the pool runs out of unused nodes it will automatically create new ones as needed. """ def __init__(self, name: str, scene: ProxyScene): """NodePool constructor Parameters ---------- name: str Pool name used as part of the names of created nodes scene: ProxySceneNode The scene to create the nodes within """ self._name = name self._scene = scene self._count = 0 self._free = [] def _create(self): """Internal helper to actually create a new node""" name = f"pool_{self._name}_node{self._count}" self._count += 1 node = ProxySceneNode(name, self._scene) self._free.append(node)
[docs] def borrow(self) -> ProxySceneNode: """Get a node from the pool Returns ------- ProxySceneNode A free node from the pool """ if not self._free: self._create() return self._free.pop()
[docs] def release(self, node: ProxySceneNode): """Return a node to the pool for reuse Parameters ---------- node: ProxySceneNode The node to return to the pool """ self._free.append(node)
[docs] class PartHighlighter(FocusBase[ScenePart, MaterialType]): """FocusBase that applies a fixed material to focused SceneParts""" def __init__(self, highlight: MaterialType | Color): """PartHighlighter constructor Parameters ---------- highlight: MaterialType | Color The appearance to apply to focused parts """ super().__init__() # If we just got a color, create a phong material from it if isinstance(highlight, Color): mat_info = PhongMaterialInfo() mat_info.color = Color.BLACK mat_info.ambientColor = Color.BLACK # Use emissive color so that the highlight stands out under # arbitrary lighting conditions mat_info.emissiveColor = highlight highlight = PhongMaterial(mat_info) self._material = highlight def _focus_one(self, item: ScenePart) -> MaterialType: # Apply the highlight material, returning the original material # as a cookie old_material = item.getMaterial() item.setMaterial(self._material) return old_material def _unfocus_one(self, item: ScenePart, cookie: MaterialType): # Restore the original material item.setMaterial(cookie)
[docs] @dataclass class LoopLineCookie: """Cookie data for LoopConnector""" src_node: ProxySceneNode tgt_node: ProxySceneNode line_id: int
[docs] class LoopConnector(FocusBase[LoopConstraintHinge, LoopLineCookie]): """FocusBase that draws a line across a LoopConstraintHinge""" def __init__(self, scene: ProxyScene, source_color: Color, target_color: Color): super().__init__() self._viz_scene = scene.graphics() self._node_pool = NodePool("lc_viz", scene) self._src_color = source_color self._tgt_color = target_color def _focus_one(self, item: LoopConstraintHinge) -> LoopLineCookie: # Attach unused nodes from the pool src_node = self._node_pool.borrow() tgt_node = self._node_pool.borrow() src_node.attachTo(item.sourceNode().parentBody()) tgt_node.attachTo(item.targetNode().parentBody()) # Add a line if self._viz_scene: viz_src_node = src_node.graphics(self._viz_scene) viz_tgt_node = tgt_node.graphics(self._viz_scene) line_id = self._viz_scene._addLineBetween( viz_src_node, viz_tgt_node, self._src_color, self._tgt_color, dashed=True ) # Return a cookie with the values needed to later undo what # we've just done return LoopLineCookie(src_node=src_node, tgt_node=tgt_node, line_id=line_id) def _unfocus_one(self, item: LoopConstraintHinge, cookie: LoopLineCookie): # Remove the line if self._viz_scene: self._viz_scene._removeLineBetween(cookie.line_id) # Release the nodes back to the pool cookie.src_node.detach() self._node_pool.release(cookie.src_node) cookie.tgt_node.detach() self._node_pool.release(cookie.tgt_node)
[docs] @dataclass class FramePairLineCookie: """Cookie data for FramePairConnector""" src_node: ProxySceneNode tgt_node: ProxySceneNode line_id: int
[docs] class FramePairConnector(FocusBase[tuple[Frame, Frame], FramePairLineCookie]): """FocusBase that draws lines connecting Frame pairs""" def __init__(self, scene: ProxyScene, source_color: Color, target_color: Color): super().__init__() self._viz_scene = scene.graphics() self._node_pool = NodePool("frame_pair_viz", scene) self._src_color = source_color self._tgt_color = target_color def _focus_one(self, item: tuple[Frame, Frame]) -> FramePairLineCookie: # Attach unused nodes from the pool src_node = self._node_pool.borrow() tgt_node = self._node_pool.borrow() src_node.attachTo(item[0]) tgt_node.attachTo(item[1]) # Draw the line if self._viz_scene: viz_src_node = src_node.graphics(self._viz_scene) viz_tgt_node = tgt_node.graphics(self._viz_scene) line_id = self._viz_scene._addLineBetween( viz_src_node, viz_tgt_node, self._src_color, self._tgt_color ) # Return a cookie with the values needed to later undo what # we've just done return FramePairLineCookie(src_node=src_node, tgt_node=tgt_node, line_id=line_id) def _unfocus_one(self, item: tuple[Frame, Frame], cookie: FramePairLineCookie): # Remove the line if self._viz_scene: self._viz_scene._removeLineBetween(cookie.line_id) # Return nodes to the pool cookie.src_node.detach() self._node_pool.release(cookie.src_node) cookie.tgt_node.detach() self._node_pool.release(cookie.tgt_node)
[docs] class FrameAxesDisplay(FocusBase[Frame, ProxySceneNode]): """FocusBase that shows a Frame's axes""" def __init__(self, scene: ProxyScene, axes_size: float = 10): super().__init__() self._viz_scene = scene.graphics() self._node_pool = NodePool("frame_viz", scene) self._axes_size = axes_size def _focus_one(self, item: Frame) -> ProxySceneNode: node = self._node_pool.borrow() node.attachTo(item) if self._viz_scene: node.graphics(self._viz_scene).showAxes(self._axes_size) # Return the node as a cookie so it can later be removed return node def _unfocus_one(self, item: Frame, cookie: ProxySceneNode): if self._viz_scene: cookie.graphics(self._viz_scene).showAxes(0) cookie.detach() self._node_pool.release(cookie)
[docs] class MaskSelectTUI(FlagsTUI): def __init__(self, camera: GraphicalSceneCamera): self._camera = camera mask = camera.getMask() controlled_layers = ( LAYER_PHYSICAL_GRAPHICS | LAYER_ORNAMENTAL | LAYER_STICK_FIGURE | LAYER_COLLISION ) self._base_layers = mask & ~controlled_layers super().__init__( header=[ "Select scene layers", " enter: confirm", " q / Ctrl-C: cancel", " ↓ / j: move down", " ↑ / k: move up", " spacebar: toggle highlighted layer", " 1-4: toggle corresponding layer", ], flags=[ FlagData( brief="Physical graphics", enabled=bool(mask & LAYER_PHYSICAL_GRAPHICS), ), FlagData( brief="Ornamental graphics", enabled=bool(mask & LAYER_ORNAMENTAL), ), FlagData( brief="Stick figure graphics", enabled=bool(mask & LAYER_STICK_FIGURE), ), FlagData( brief="Collision graphics", enabled=bool(mask & LAYER_COLLISION), ), ], ) def _post_update(self, flag: FlagData): self._camera.setMask(self.mask) @property def mask(self): mask = self._base_layers if self._flags[0]: mask |= LAYER_PHYSICAL_GRAPHICS if self._flags[1]: mask |= LAYER_ORNAMENTAL if self._flags[2]: mask |= LAYER_STICK_FIGURE if self._flags[3]: mask |= LAYER_COLLISION return mask
PerspectiveModeOptions = Literal[ "pers_free", "ortho_px", "ortho_py", "ortho_pz", "ortho_nx", "ortho_ny", "ortho_nz" ]
[docs] class PerspectiveModeTUI(ChoiceTUI): def __init__(self): choices = [ ChoiceData( brief="Free", description="Use a standard perspective projection", value="pers_free", ), ChoiceData( brief="+x", description="Orthographic view from the positive x-axis", value="ortho_px", ), ChoiceData( brief="+y", description="Orthographic view from the positive y-axis", value="ortho_py", ), ChoiceData( brief="+z", description="Orthographic view from the positive z-axis", value="ortho_pz", ), ChoiceData( brief="-x", description="Orthographic view from the negative x-axis", value="ortho_nx", ), ChoiceData( brief="-y", description="Orthographic view from the negative y-axis", value="ortho_ny", ), ChoiceData( brief="-z", description="Orthographic view from the negative z-axis", value="ortho_nz", ), ] super().__init__( header=[ "Select swing mode", " enter: confirm", " q / Ctrl-C: cancel", " ↓ / j: move down", " ↑ / k: move up", " 1-8: jump to choice", ], choices=choices, ) @property def mode(self) -> PerspectiveModeOptions | None: return getattr(self.choice, "value", None)