Metadata-Version: 2.5
Name: masscompose
Version: 0.1.0
Summary: Compose mesh inertia and point masses into a combined COM/inertia tensor
Project-URL: Homepage, https://github.com/Iarush/masscompose
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Games/Entertainment :: Simulation
Classifier: Topic :: Scientific/Engineering :: Physics
Requires-Python: >=3.13
Requires-Dist: numpy>=1.24
Requires-Dist: trimesh>=4.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# MassCompose

Combine a mesh's inertia with the point masses bolted to it — battery, motor,
controller — into one total mass, one centre of mass, and one 3×3 inertia
tensor about that centre of mass.

`trimesh` already gives you the mesh's own properties. The part that gets
written wrong by hand is the parallel-axis composition that folds discrete
masses in with it: the shifts have to be taken from the *system* centre of
mass, not from the origin, and the resulting tensor is symmetric and
positive semi-definite either way, so a wrong one looks entirely plausible
until the thing it describes tumbles in simulation.

## Installation

```
pip install masscompose
```

Runtime dependencies are `numpy` and `trimesh`, and nothing else. From a
checkout, `pip install -e ".[dev]"` adds `pytest`.

## Usage

```python
import numpy as np
import trimesh

from masscompose import PointMass, compose, load_mesh, to_urdf_inertial

# Stand-in for your own file, so this example runs as written:
# a 300 x 200 x 8 mm chassis plate, exported in millimetres.
trimesh.creation.box(extents=[300.0, 200.0, 8.0]).export("chassis.stl")

# PETG at 40% infill. Units are declared, never guessed.
chassis = load_mesh("chassis.stl", mesh_units="mm", density=1240.0, infill=0.4)

payload = [
    PointMass([0.000, 0.000, 0.050], mass=0.420, name="battery"),
    PointMass([0.080, -0.040, 0.020], mass=0.075, name="esc"),
    PointMass([-0.120, 0.000, 0.090], mass=0.031, name="camera"),
]

mp = compose(chassis, payload)

print(f"mass = {mp.mass:.4f} kg")
print(f"com  = {np.round(mp.com, 5).tolist()} m")
print(to_urdf_inertial(mp))
```

```
mass = 0.7641 kg
com  = [0.00298, -0.00393, 0.0331] m
<inertial>
  <origin xyz="0.002983980735 -0.00392629044079 0.0330986284159" rpy="0 0 0"/>
  <mass value="0.76408"/>
  <inertia ixx="0.00139712657604" ixy="0.000231048057795" ixz="0.000290264872788" iyy="0.00320050197129" iyz="-3.92958852476e-05" izz="0.0036070176526"/>
</inertial>
```

Point-mass positions are in metres, in the same frame as the mesh file's
coordinates after unit conversion. The tensor is about `mp.com`, which is why
the `<origin>` carries it — the two have to agree or the block is wrong.

## If you only need mesh inertia

You do not need this package. `trimesh` does it in three lines:

```python
mesh = trimesh.load("chassis.stl", force="mesh")
mesh.apply_scale(0.001)   # your file's units -> metres
mesh.density = 1240.0     # then read mesh.mass, mesh.center_mass, mesh.moment_inertia
```

Reach for MassCompose when there are discrete masses to fold in on top of that.

## Scope

| Does | Does not |
|---|---|
| One watertight mesh, given a density or a known mass | Multi-body assemblies, or sub-bodies with their own orientation |
| N point masses at explicit XYZ | Point masses with extent of their own |
| Parallel-axis composition about the system centre of mass | Dynamics, simulation, or anything time-dependent |
| Mandatory `mesh_units` — `"mm"`, `"cm"`, `"m"` | Guessing units from the file |
| Effective density via `infill` for printed parts | Per-region or graded density |
| Watertightness, volume, and density checks that raise | Repairing a broken mesh |
| Post-condition checks: symmetry, positive semi-definiteness, triangle inequality | Silently returning a plausible-looking wrong tensor |
| URDF `<inertial>` export | MJCF, USD, SDF |
| SI throughout — kg, m, kg·m² | Any other unit system on output |

## API

The importable surface is these six names and nothing else.

```python
load_mesh(path, *, mesh_units, density=None, mass=None, infill=1.0, allow_open=False) -> MeshBody
```
Mass properties of one mesh file, in SI, with `inertia` about the mesh's own
COM. Exactly one of `density` (kg/m³) or `mass` (kg) is required. `infill`
scales density for printed parts and cannot be combined with `mass`. Raises
`ValueError` on a non-watertight mesh unless `allow_open=True` downgrades it
to a warning.

```python
compose(body: MeshBody, point_masses: Sequence[PointMass]) -> MassProperties
```
Total mass, system COM, and the inertia tensor about that COM. An empty
`point_masses` returns the mesh's own properties. Validates its own output.

```python
to_urdf_inertial(mp: MassProperties) -> str
```
The `<inertial>` block as a string, ready to paste inside a `<link>`. The six
`i**` attributes are matrix entries, so `ixy` is `inertia[0, 1]` — the standard
physics product of inertia, no sign flip.

```python
PointMass(position: np.ndarray, mass: float, name: str | None = None)
```
A discrete mass, in the mesh's frame. `position` must be shape `(3,)` exactly —
a `(3, 1)` column is rejected rather than flattened, because a transposed
vector reaches the parallel-axis step and comes out wrong but plausible.

```python
MeshBody(mass: float, com: np.ndarray, inertia: np.ndarray)
```
What `load_mesh` returns: `inertia` about `com`, in SI.

```python
MassProperties(mass: float, com: np.ndarray, inertia: np.ndarray)
```
What `compose` returns: same three fields, same convention.

## Tests

```
pytest
```

Mesh properties are checked against closed-form values for a sphere, box, and
cylinder; composition against hand-computed parallel-axis results, including
the off-diagonal signs; and the URDF block by round-tripping the XML back to a
3×3.
