#!/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()