Source code for Karana.KUtils.vizutils._sshull

# 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