# 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.
import os
import numpy as np
from scipy.spatial import ConvexHull
from Karana.Math import HomTran
from Karana.Scene import (
StaticMeshGeometry,
ScenePartSpec,
PhongMaterialInfo,
PhongMaterial,
PhysicalMaterial,
Color,
LAYER_ORNAMENTAL,
)
from Karana.Dynamics import PhysicalBody
__all__ = ["computeSphereSweptHull", "createBodyHullPartSpec"]
def _generateSpherePoints(num_points: int, radius: float):
"""Generate a point cloud on the surface of a sphere.
Parameters
----------
num_points: int
Number of points to generate
radius: float
Radius of the sphere
Returns
-------
num_points x 3 array of points on the sphere
"""
phi = np.pi * (3.0 - np.sqrt(5.0))
i = np.arange(num_points)
y = 1 - (i / float(num_points - 1)) * 2
r = np.sqrt(1 - y * y)
theta = i * phi
x = r * np.cos(theta)
z = r * np.sin(theta)
return np.vstack((x, y, z)).T * radius
def _fixWinding(positions, faces):
"""Ensure faces have an outward-pointing winding (CCW).
Parameters
----------
positions:
Vx3 array of vertex positions
faces:
Fx3 array of face indices
Returns
-------
Fx3 array of face indices with CCW windings
"""
reoriented_faces = np.copy(faces)
# The center of the hull's volume is a good reference point for checking orientation
hull_center = np.mean(positions, axis=0)
for i, face in enumerate(faces):
# Get the 3 vertices (A, B, C) of the current face
v_indices = face
A, B, C = positions[v_indices]
# Compute two edge vectors (AB and AC)
AB = B - A
AC = C - A
# Compute the face normal using the cross product
normal = np.cross(AB, AC)
# Normalize the vector for consistency
normal = normal / np.linalg.norm(normal)
# Check winding orientation
# A vector from the face centroid (A) to the hull center (hull_center)
centroid_to_center = hull_center - A
# If the dot product is positive, the normal points inward toward the center.
# We need to reverse the winding (swap B and C) to flip the normal outward.
if np.dot(normal, centroid_to_center) > 0:
# Reverse the winding: swap the second and third vertex index
reoriented_faces[i] = [v_indices[0], v_indices[2], v_indices[1]]
return reoriented_faces
[docs]
def computeSphereSweptHull(positions, *, sphere_radius: float = 0.03, points_per_sphere: int = 20):
"""Compute a sphere-swept convex hull.
Parameters
----------
positions:
An Nx3 array of vertex positions to form a hull over.
sphere_radius: float
Radius of the sphere to sweep over the hull. Defaults to 0.03.
points_per_sphere: int
Number of points on the sphere surface approximation. Defaults
to 20.
Returns
-------
StaticMeshGeometry
A StaticMeshGeometry for the sphere-swept convex hull
"""
positions = np.array(positions)
if positions.shape[1] != 3:
raise ValueError("Input positions must be an Nx3 numpy array.")
if sphere_radius <= 0:
raise ValueError("Input sphere_radius must be positive")
if points_per_sphere <= 0:
raise ValueError("Input points_per_sphere must be positive")
sphere_points = _generateSpherePoints(points_per_sphere, sphere_radius)
all_sphere_points = []
for center in positions:
translated_cloud = sphere_points + center
all_sphere_points.append(translated_cloud)
combined_point_cloud = np.vstack(all_sphere_points)
# Compute the Convex Hull
try:
hull = ConvexHull(combined_point_cloud)
except Exception as e:
# This doesn't really achieve anything, but I reraise here to make it
# clear in the code that this could potentially error
raise
# Ensure CCW face winding
faces = _fixWinding(positions=hull.points, faces=hull.simplices)
# Omit normals and uv coords
normals = np.empty(shape=(0, 3), dtype=np.float32)
uv_coords = np.empty(shape=(0, 2), dtype=np.float32)
return StaticMeshGeometry(
positions=hull.points, faces=faces, normals=normals, surface_map_coords=uv_coords
)
[docs]
def createBodyHullPartSpec(
body: PhysicalBody,
*,
sphere_radius: float = 0.03,
points_per_sphere: int = 20,
material: PhysicalMaterial | PhongMaterial | None = None,
) -> ScenePartSpec:
"""Create a ScenePartSpec for a convex hull over a body's nodes.
The resulting ScenePartSpec should be added to the body by calling
PhysicalBody.addScenePartSpec.
Parameters
----------
body: PhysicalBody
The body of interest
sphere_radius: float
Radius of the sphere swept through the hull. Defaults to 0.03.
points_per_sphere: int
Number of points on the sphere surface approximation. Defaults
to 20.
material: PhysicalMaterial | PhongMaterial | None
Material to apply. If None, uses a default material.
Returns
-------
ScenePartSpec
A ScenePartSpec for the hull
"""
if material is None:
# Create a flat-shaded yellow material
mat_info = PhongMaterialInfo()
mat_info.color = Color.BLACK
mat_info.ambient_color = Color.BLACK
mat_info.emissive_color = Color.YELLOW
material = PhongMaterial(mat_info)
# Create a sphere-swept convex hull geometry over the body's nodes
positions = []
for node in body.nodeList() + body.constraintNodeList():
pos = body.frameToFrame(node).relTransform().getTranslation()
positions.append(pos)
if not positions:
raise ValueError(f"Body {body.name()} has no nodes")
geometry = computeSphereSweptHull(
positions, sphere_radius=sphere_radius, points_per_sphere=points_per_sphere
)
part_spec = ScenePartSpec()
part_spec.name = f"{body.name()}_hull_part"
part_spec.geometry = geometry
part_spec.material = material
part_spec.transform = HomTran()
part_spec.scale = np.array([1, 1, 1], dtype=np.float64)
part_spec.layers = LAYER_ORNAMENTAL
return part_spec