Metadata-Version: 2.4
Name: pyondsel
Version: 0.0.3
Summary: Python bindings for OndselSolver (multibody kinematics / MBD), the FreeCAD assembly engine
Author: pyondsel contributors
License-Expression: LGPL-2.1-only
License-File: LICENSE
License-File: NOTICE.md
License-File: extern/OndselSolver/LICENSE
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: C++
Classifier: Topic :: Scientific/Engineering
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Requires-Python: >=3.10
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Description-Content-Type: text/markdown

# pyondsel

Python bindings for [OndselSolver](https://github.com/FreeCAD/OndselSolver), the multibody
kinematics engine behind FreeCAD's Assembly workbench. Define a mechanism (parts, joints, a motor),
solve it, and read back where every part is at each instant.

- **Author in Python or in native ASMT.** Build a model from `Part` / `Marker` / `Joint` / `Motion`
  objects, or hand it OndselSolver's own `.asmt` text (portable to and from FreeCAD).
- **All 26 joint kinds.** Lower pairs (revolute, cylindrical, translational, spherical, screw, ...),
  higher pairs (point/line/plane incidence), and coupling joints (gear, rack-and-pinion, constant
  velocity, ...), so the full OndselSolver constraint set is reachable from Python.
- **Fault-isolated.** The solver runs in a worker subprocess, so an ill-posed model raises
  `SolveError` instead of crashing your program.
- **Structured results.** Get a `Trajectory`: the time samples plus each part's position and
  orientation at every frame.

## Getting started

### Install

```bash
pip install -e .        # or: uv pip install -e .
```

This compiles the vendored OndselSolver C++ into a single extension module (scikit-build-core +
pybind11). You need a C++17 compiler and CMake >= 3.18. No other system libraries are required.

### Your first mechanism

A crank on a pivot, driven one full turn per second:

The authoring classes are imported from their modules (`pyondsel`'s top-level package is
intentionally empty; import each class from its submodule):

```python
from pyondsel.model.asmt_model import AsmtModel
from pyondsel.model.joint import Joint
from pyondsel.model.marker import Marker
from pyondsel.model.motion import Motion
from pyondsel.model.part import Part
from pyondsel.model.simulation import Simulation

model = AsmtModel(name="Rig")
model.add_ground_marker(Marker("pivot"))                        # a fixed point to pin the crank to

crank = model.add_part(Part("crank", mass=1.0,
                            moments_of_inertia=(0.001, 0.02, 0.02),
                            mass_center=(0.5, 0.0, 0.0)))
crank.add_marker(Marker("hub"))                                 # where the crank meets the pivot

model.add_joint(Joint("j1", "revolute",
                      model.ground_path("pivot"),
                      model.part_path("crank", "hub")))          # 1 rotational DoF about the hub Z
model.add_motion(Motion.constant_speed("spin", "j1", turns_per_time=1.0))
model.simulation = Simulation(t_start=0.0, t_end=1.0, frames=36)

trajectory = model.solve()
crank = trajectory.parts["/Rig/crank"]
for t, angle in zip(trajectory.times, (b[2] for b in crank.bryant_angles)):
    print(f"t={t:.2f}s  crank angle={angle:.3f} rad")
```

The later snippets assume these imports (and the `model` above).

## How-to

### Read a part's pose at each frame

`Trajectory.parts` maps each part's full path (`/<assembly>/<part>`) to a `PartTrajectory` with
`positions` (x, y, z) and `bryant_angles` (Bryant / Tait X-Y-Z angles) per frame. For a full 3x3
rotation, use `rotation_matrix`:

```python
from pyondsel.asmt_solver import rotation_matrix

part = trajectory.parts["/Rig/crank"]
for pos, ang in zip(part.positions, part.bryant_angles):
    R = rotation_matrix(ang)          # R = Rx(bx) . Ry(by) . Rz(bz)
    ...
```

### Choose the joint and motion

A `Joint` couples two markers under a `kind` that maps to an OndselSolver ASMT joint block. All 26
kinds the solver recognizes are exposed, in four groups:

- **Lower pairs:** `fixed` (lock / ground), `revolute`, `cylindrical`, `translational`, `spherical`,
  `universal`, `screw`, `planar`.
- **Compound lower pairs:** `cylspherical`, `revcylindrical`, `sphspherical`, `revrevolute`.
- **Higher pairs** (point / line / plane incidence): `point_in_line`, `point_in_plane`, `in_line`,
  `line_in_plane`, `in_plane`. These keep a point or line on another marker's line or plane, e.g. a
  crank pin riding a yoke slot.
- **Coupling / relational:** `gear`, `rack_pinion`, `constant_velocity`, `no_rotation`,
  `parallel_axes`, `perpendicular`, `angle`, `at_point`, `compound`.

Motion kinds are `rotational` (drives the angle about Z) and `translational` (drives the slide along
Z); the expression is a function of `time`, e.g. `"2.0*pi*time"`, `"0.5*time"`, `"sin(time)"`.

```python
model.add_joint(Joint("slide", "translational", a_path, b_path))
model.add_motion(Motion("push", joint="slide", expression="0.05*time", kind="translational"))
```

### Joints that carry extra parameters (gears, screws, and friends)

Some kinds need extra scalars beyond the two markers; they are optional `Joint` fields the writer
emits only for the kinds that declare them, and a missing required one raises `ValueError`:

| Kind | Field(s) | Meaning |
|---|---|---|
| `screw` | `pitch` | translation per radian about Z |
| `gear` | `radius_i`, `radius_j` | the two pitch radii (their ratio is the gear ratio) |
| `rack_pinion` | `pitch_radius` | the pinion pitch radius |
| `angle` | `angle` | the constrained angle between the marker Z axes |
| `in_plane` | `offset` | offset of the point from marker J's plane |
| `compound` | `distance_ij` | the fixed distance between the two markers |

```python
# a meshing gear pair: a coupling joint whose two pitch radii set the ratio
model.add_joint(Joint("mesh", "gear",
                      model.part_path("pinion", "axis"), model.part_path("gear", "axis"),
                      radius_i=0.024, radius_j=0.036))
```

### Ground a part

Fix a part to the world by joining one of its markers to a ground marker with a `fixed` joint:

```python
model.add_joint(Joint("anchor", "fixed", model.ground_path("origin"),
                      model.part_path("base", "seat")))
```

### Control the number of frames

`Simulation(t_start, t_end, frames)` records `frames` evenly spaced samples over the span, so the
trajectory length is predictable regardless of the solver's internal step size.

### Handle failures

```python
from pyondsel.errors import AsmtInputError, SolveError

try:
    trajectory = model.solve()
except AsmtInputError:         # malformed model / ASMT text
    ...
except SolveError as exc:      # solver could not solve, or crashed on an ill-posed model
    print(exc.returncode, exc.detail)
```

### Work with native ASMT directly

`model.to_asmt()` returns OndselSolver's text format; `solve_asmt(text)` (from
`pyondsel.solve import solve_asmt`) solves raw ASMT and returns the solved ASMT (carrying the
trajectory). Pre-solved files (with a stored trajectory) are accepted: pyondsel strips the old
series and re-solves. This is the interchange with FreeCAD and the upstream solver.

## Format

pyondsel does not invent a format; it speaks OndselSolver's ASMT. See `extern/OndselSolver` for the
upstream project and `tests/data/*.asmt` for reference models (crank-slider, four-bar, and others).

## License

LGPL-2.1-only. pyondsel statically links OndselSolver (LGPL-2.1), so it adopts the same license;
see `NOTICE.md` for what that means for use and redistribution (and why the label is `-only`, not
`-or-later`).
