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