geometry.py

# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2025 SWGY, Inc
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""
Geometry loading and placement.

Loads the MICH helmet and the SAPI plate meshes (posed twice: front and back
plates) and transforms them into the simulation's world frame, where the sensor
sits at the origin, the helmet faces -Y, and Z is up. The transforms below seat
each asset around that origin sensor; they are applied once at startup, and
`fix_normals()` gives each mesh consistent outward face normals.
"""

import os

import numpy as np
import trimesh


def _load_mesh(path: str, label: str) -> trimesh.base.Trimesh:
    """Load a mesh file, with a clear error if it is missing or unreadable."""
    if not os.path.exists(path):
        raise FileNotFoundError(f"{label} geometry file not found: {path}")
    try:
        return trimesh.load_mesh(path)
    except Exception as e:
        raise ValueError(f"Failed to load {label} geometry from {path}: {e}") from e


def load_helmet(path: str) -> trimesh.base.Trimesh:
    """Load the MICH helmet and orient it around the sensor.

    Rotate +90 deg about X (asset frame -> world Z-up) and scale by 0.5 so the
    shell seats around the origin sensor.
    """
    helmet = _load_mesh(path, "Helmet")
    rotation = trimesh.transformations.rotation_matrix(np.pi / 2, [1, 0, 0])
    scale = trimesh.transformations.scale_matrix(0.5)
    helmet.apply_transform(
        trimesh.transformations.concatenate_matrices(rotation, scale))
    helmet.fix_normals()
    return helmet


def load_vest(path: str) -> trimesh.base.Trimesh:
    """Load the SAPI vest plate and place it in front of the sensor.

    Rotate 180 deg about X, then translate to [0, -0.12, -0.5] so the plate sits
    just in front of (and below) the origin sensor, at the torso position.
    """
    vest = _load_mesh(path, "SAPI vest")
    rotation = trimesh.transformations.rotation_matrix(np.pi, [1, 0, 0])
    translation = trimesh.transformations.translation_matrix([0, -0.12, -0.5])
    vest.apply_transform(
        trimesh.transformations.concatenate_matrices(translation, rotation))
    vest.fix_normals()
    return vest


def load_back_plate(path: str) -> trimesh.base.Trimesh:
    """Load the SAPI plate posed as the back plate.

    Mirror of the front plate: the same 180 deg flip about X, then 180 deg about
    Z so the concave face looks forward (toward the body), translated to
    [0, 0.18, -0.5] -- 0.3 m behind the front plate, at the same height.
    """
    plate = _load_mesh(path, "SAPI back plate")
    rot_x = trimesh.transformations.rotation_matrix(np.pi, [1, 0, 0])
    rot_z = trimesh.transformations.rotation_matrix(np.pi, [0, 0, 1])
    translation = trimesh.transformations.translation_matrix([0, 0.18, -0.5])
    plate.apply_transform(
        trimesh.transformations.concatenate_matrices(translation, rot_z, rot_x))
    plate.fix_normals()
    return plate