Source code for Karana.Scene.convexify

#!/usr/bin/env python3
# Copyright (c) 2024-2026 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.

"""Helpers to convexify meshes."""

from pathlib import Path
import warnings
from collections.abc import Mapping, Sequence
from typing import Any

try:
    import gltflib as gltf
except ImportError:
    raise ImportError("Please install gltflib to use this module")

import Karana.Scene as ks

__all__ = ["convexify"]


def readFile(
    infile: str | Path | ks.SceneFileObjectSpec | ks.SceneFileObject,
) -> list[ks.ScenePartSpec]:
    """Get a list of ScenePartSpecs from a given file.

    Parameters
    ----------
    infile: str | Path | ks.SceneFileObjectSpec | ks.SCeneFileObject
        The file to load

    Returns
    -------
    list[ks.ScenePartSpec]
        The ScenePartSpecs from the file

    """
    # Project the infile arg to a Path
    if isinstance(infile, str):
        infile = Path(infile)
    elif isinstance(infile, ks.SceneFileObjectSpec):
        infile = Path(infile.filepath)
    elif isinstance(infile, ks.SceneFileObject):
        infile = Path(infile.getFilepath())

    if not infile.is_file():
        raise FileNotFoundError(f"{infile} not found or is not a regular file.")

    importer = ks.AssimpImporter()
    return importer.importFrom(infile).parts


def convexifyPartSpecs(
    part_specs: Sequence[ks.ScenePartSpec],
    coacd_args: Mapping[str, Any] | None = None,
    verbose: bool = False,
) -> list[ks.ScenePartSpec]:
    """Convert a collection of parts to convex hulls.

    Parameters
    ----------
    part_specs: Sequence[ks.ScenePartSpec]
        The collection of parts to convert
    coacd_args: Mapping[str, Any] | None
        If given, extra kwargs to pass to coacd
    verbose: bool
        Enable verbose output

    Returns
    -------
    list[ks.ScenePartSpec]
        The convex hulls

    """
    coacd_args = dict(coacd_args or {})

    # Maps original geometry ids to their convex hulls
    convex_geometry_map: dict[int, list[ks.StaticMeshGeometry]] = {}
    convex_specs = []
    for part_spec in part_specs:
        geometry = part_spec.geometry
        if not isinstance(geometry, ks.StaticMeshGeometry):
            warnings.warn(
                f"Geometry {geometry.name()} used by {part_spec.name} is not a triangular mesh. Skipping"
            )

        # Ensure the list of convex hulls for this geometry is cached
        if geometry.id() not in convex_geometry_map:
            convex_geometry_map[geometry.id()] = ks.getConvexDecomposition(
                geom=geometry, coacd_args=coacd_args, verbose=verbose, dupe_vertices=False
            )

        convex_hulls = convex_geometry_map[geometry.id()]
        for i, hull in enumerate(convex_hulls):
            convex_specs.append(
                ks.ScenePartSpec(
                    name=f"{part_spec.name}_convex{i}",
                    geometry=hull,
                    material=ks.defaultMaterial(),
                    transform=part_spec.transform,
                    scale=part_spec.scale,
                    layers=part_spec.layers,
                )
            )

    return convex_specs


class GlbExporter:
    """Helper class to export parts to a glb file."""

    def __init__(self):
        """Create the GlbExporter."""
        self.binary_data = bytearray()
        self.mesh_id_to_gltf_index: dict[int, int] = {}
        self.gltf_meshes: list[gltf.Mesh] = []
        self.buffer_views: list[gltf.BufferView] = []
        self.accessors: list[gltf.Accessor] = []
        self.nodes: list[gltf.Node] = []
        self.current_buffer_offset = 0

    def addPartSpec(self, part_spec: ks.ScenePartSpec):
        """Add a part to the scene at the root.

        Parameters
        ----------
        part_spec: ks.ScenePartSpec
            The part to add

        """
        mesh = part_spec.geometry
        if not isinstance(mesh, ks.StaticMeshGeometry):
            raise TypeError(f"Cannot add non-mesh geometry")
        gltf_mesh_index = self._ensureMesh(mesh)
        node = gltf.Node(mesh=gltf_mesh_index)
        # gltflib expects standard python lists
        # Need to use magnitude to strip units
        node.translation = list(part_spec.transform.getTranslation().magnitude)
        node.rotation = list(part_spec.transform.getUnitQuaternion().toVector4())
        node.scale = list(part_spec.scale)
        self.nodes.append(node)

    def exportTo(self, filepath: Path):
        """Export the add parts to a glb file.

        Parameters
        ----------
        filepath: Path
            The path to export to

        """
        if filepath.suffix.lower() != ".glb":
            warnings.warn(f"Writing a glb file to {filepath} which lacks the .glb extension")
        scenes = [gltf.Scene(nodes=list(range(len(self.nodes))))]
        model = gltf.GLTFModel(
            asset=gltf.Asset(version="2.0"),
            scenes=scenes,
            scene=0,
            nodes=self.nodes,
            meshes=self.gltf_meshes,
            buffers=[gltf.Buffer(byteLength=len(self.binary_data))],
            bufferViews=self.buffer_views,
            accessors=self.accessors,
        )

        glb_resource = gltf.GLBResource(data=bytes(self.binary_data))
        result = gltf.GLTF(model=model, resources=[glb_resource])
        result.export(str(filepath))

    def _addToBuffer(
        self, data_bytes: bytes, component_type: int, accessor_type: str, count: int
    ) -> int:
        """Append raw data to the internal binary blob.

        Parameters
        ----------
        data_bytes: bytes
            The raw data to add
        component_type: int
            The data type of each scalar
        accessor_type: int
            The type of each indexable element.
        count: int
            The number of indexable elements.

        Returns
        -------
        int
            The accessor index

        """
        view_index = len(self.buffer_views)
        self.buffer_views.append(
            gltf.BufferView(
                buffer=0, byteOffset=self.current_buffer_offset, byteLength=len(data_bytes)
            )
        )
        accessor_index = len(self.accessors)
        self.accessors.append(
            gltf.Accessor(
                bufferView=view_index,
                byteOffset=0,
                componentType=component_type,
                count=count,
                type=accessor_type,
            )
        )

        self.binary_data.extend(data_bytes)
        self.current_buffer_offset += len(data_bytes)

        # Ensure 4-byte alignment required by GLTF spec
        alignment_padding = (4 - (self.current_buffer_offset % 4)) % 4
        if alignment_padding > 0:
            self.binary_data.extend(b"\x00" * alignment_padding)
            self.current_buffer_offset += alignment_padding

        return accessor_index

    def _ensureMesh(self, mesh: ks.StaticMeshGeometry) -> int:
        """Idempotently add a mesh, returning its gltf index.

        Parameters
        ----------
        mesh: ks.StaticMeshGeometry
            The mesh to ensure is added

        Returns
        -------
            The gltf index of the mesh

        """
        if mesh.id() in self.mesh_id_to_gltf_index:
            return self.mesh_id_to_gltf_index[mesh.id()]

        v_accessor = self._addToBuffer(
            mesh.positions.tobytes(),
            component_type=gltf.ComponentType.FLOAT.value,
            accessor_type=gltf.AccessorType.VEC3.value,
            count=len(mesh.positions),
        )
        f_accessor = self._addToBuffer(
            mesh.faces.tobytes(),
            # faces are np.int32, but this shouldn't matter as long as they
            # remain positive
            component_type=gltf.ComponentType.UNSIGNED_INT.value,
            accessor_type=gltf.AccessorType.SCALAR.value,
            count=mesh.faces.size,
        )

        gltf_mesh_index = len(self.gltf_meshes)
        self.gltf_meshes.append(
            gltf.Mesh(
                primitives=[
                    gltf.Primitive(
                        attributes=gltf.Attributes(POSITION=v_accessor),
                        indices=f_accessor,
                    )
                ],
            )
        )
        self.mesh_id_to_gltf_index[mesh.id()] = gltf_mesh_index
        return gltf_mesh_index


def writeGlb(outfile: str | Path, part_specs: Sequence[ks.ScenePartSpec]) -> ks.SceneFileObjectSpec:
    """Write the given parts to a glb file.

    Parameters
    ----------
    outfile: str | Path
        The filepath to write to.
    part_specs: Sequence[ks.ScenePartSpec]
        The collection of parts to write

    Returns
    -------
    ks.SceneFileObjectSpec
        A SceneFileObjectSpec pointing to the created file

    """
    outfile = Path(outfile)
    exporter = GlbExporter()
    for part_spec in part_specs:
        exporter.addPartSpec(part_spec)
    exporter.exportTo(outfile)
    return ks.SceneFileObjectSpec(filepath=outfile, is_convex=True)


[docs] def convexify( infile: str | Path | ks.SceneFileObjectSpec | ks.SceneFileObject, outfile: str | Path, coacd_args: Mapping[str, Any] | None = None, verbose: bool = False, ) -> ks.SceneFileObjectSpec: """Convexify a mesh file. Parameters ---------- infile: str | Path | ks.SceneFileObjectSpec | ks.SceneFileObject The original file to convert from outfile: str | Path The destination to write the convexified hulls to coacd_args: Mapping[str, Any] | None If given, extra kwargs to pass to coacd verbose: bool Enables verbose output Returns ------- ks.SceneFileObjectSpec A SceneFileObjectSpec pointing to the created file """ part_specs = readFile(infile) convex_specs = convexifyPartSpecs(part_specs, coacd_args=coacd_args, verbose=verbose) return writeGlb(outfile, convex_specs)
def main(): """Run convexify from the CLI.""" import argparse parser = argparse.ArgumentParser() parser.add_argument("infile", type=Path, help="source file to convexify") parser.add_argument("outfile", type=Path, help="destination filepath") parser.add_argument("--verbose", "-v", action="store_true", help="enable verbose output") class KeyValueAppendAction(argparse.Action): """Parse pairs of standalone arguments into a dict.""" def __call__(self, parser, namespace, values, option_string=None): # Retrieve or initialize our accumulation dictionary dest_dict = getattr(namespace, self.dest) if dest_dict is None: dest_dict = {} # Extract key and value from the nargs=2 array pair key, val_str = values # Apply type casting and store dest_dict[key] = self._autocast(val_str) setattr(namespace, self.dest, dest_dict) @staticmethod def _autocast(val_str: str): """Attempt to cast a string into a strict bool, int, or float. Parameters ---------- val_str: str The raw string to cast """ # Strict boolean checks if val_str == "True": return True if val_str == "False": return False # Try Integer try: return int(val_str) except ValueError: pass # Try Float try: return float(val_str) except ValueError: pass # Fallback to string return val_str parser.add_argument( "--arg", nargs=2, action=KeyValueAppendAction, dest="coacd_args", metavar=("KEY", "VALUE"), help="Specify an extra argument to coacd; repeatable", ) args = parser.parse_args() convexify(**args.__dict__) if __name__ == "__main__": main()