A C4 description of motionkit at three zoom levels, followed by the rules that decide where new code goes. The diagrams are Mermaid, so they render on GitHub; in the Doxygen reference they appear as source, which is the honest trade for not adding a diagram toolchain to the build.
The audience is someone deciding whether to depend on this library, or where to put their next change in it.
motionkit is a library, not a process. It computes; it never talks to hardware, never owns a thread, and never decides when it is called. Everything with an arrow into it is somebody else's process.
flowchart TB
op["Operator<br/><i>person</i>"]
integrator["Integrator<br/><i>person</i>"]
subgraph cell [" "]
runtime["<b>Motion runtime</b><br/><i>software system</i><br/>Cyclic task, typically 1 kHz.<br/>Owns the clock and the fieldbus."]
mk["<b>motionkit</b><br/><i>this library</i><br/>Poses, frames, kinematics<br/>and trajectory profiles."]
safety["<b>Safety supervisor</b><br/><i>software system</i><br/>Independent stop authority."]
end
drive["Servo drives<br/><i>hardware</i>"]
op -->|"jogs, sets feed override"| runtime
integrator -->|"declares frames, tools,<br/>joint geometry"| runtime
runtime -->|"calls, synchronously,<br/>inside the cycle"| mk
runtime -->|"setpoints"| drive
safety -->|"stop request"| runtime
mk -.->|"stopping distance<br/>informs the envelope"| safety
The dashed edge is the only claim motionkit makes about safety, and it is
deliberately weak. StopProfile computes how far an axis travels once a stop
begins; a supervisor may use that to size a safety envelope. motionkit is not
the stop authority and is not built to any functional-safety integrity level.
The supervisor exists precisely because a library linked into the same process
as the motion it supervises cannot be its own independent check.
flowchart LR
subgraph consumer ["Consumer process"]
app["<b>Application code</b><br/><i>C++20</i>"]
end
subgraph dist ["motionkit distribution"]
core["<b>motionkit_core</b><br/><i>static or shared library</i><br/>All computation."]
warn["<b>motionkit_warnings</b><br/><i>INTERFACE target</i><br/>The warning set, exported<br/>so consumers may opt in."]
pkg["<b>motionkitConfig.cmake</b><br/><i>package</i><br/>find_package entry point."]
end
app -->|"#include, links"| core
app -.->|"optional"| warn
pkg -->|"defines<br/>motionkit::core"| core
pkg --> warn
Two containers rather than one because the warning set is a separate promise
from the code. A consumer who wants motionkit's diagnostics can link
motionkit::warnings; one who has their own house style is not
forced into -Wold-style-cast by an include. Both are exported, and CI builds
a consumer against the installed package rather than the build tree, because
an export that only works in-tree fails in a stranger's project and not in
ours — see ADR-0004.
The header dependency graph, drawn from the actual #include edges rather than
from intent. types.hpp is at the bottom and depends on nothing.
flowchart BT
types["<b>types</b><br/>Scalar, Vec3, Mat3"]
expected["<b>expected</b><br/>Expected<T, E>"]
so3["<b>so3</b><br/>SO3"]
se3["<b>se3</b><br/>SE3"]
frame["<b>frame_graph</b><br/>FrameGraph, FrameId"]
kin["<b>kinematics</b><br/>SerialChain, IkOptions"]
dyn["<b>dynamics</b><br/>DynamicChain, RigidBody"]
cal["<b>calibration</b><br/>tool point, hand-eye"]
col["<b>collision</b><br/>Capsule, CollisionModel"]
ms["<b>motion_state</b><br/>MotionState, MotionSample"]
jerk["<b>detail/jerk_segments</b><br/><i>implementation detail</i>"]
traj["<b>trajectory</b><br/>ScurveProfile, StopProfile,<br/>SynchronizedTrajectory"]
so3 --> types
se3 --> so3
se3 --> types
frame --> se3
frame --> expected
kin --> se3
kin --> expected
dyn --> kin
dyn --> expected
cal --> se3
cal --> expected
col --> kin
col --> expected
ms --> types
jerk --> ms
jerk --> types
traj --> jerk
traj --> ms
traj --> expected
classDef pose fill:#dbeafe,stroke:#2563eb,color:#1e3a5f
classDef motion fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef base fill:#f1f5f9,stroke:#64748b,color:#1e293b
class so3,se3,frame,kin,dyn,cal,col pose
class ms,jerk,traj motion
class types,expected base
Two subtrees rise from types. The pose side — SO3, SE3, FrameGraph,
SerialChain, DynamicChain, calibration — answers where, and now what force.
The motion side — MotionState, ScurveProfile, StopProfile — answers when.
trajectory.hpp includes no se3.hpp, and neither kinematics.hpp nor
dynamics.hpp includes motion_state.hpp.
For most of this library's life those two never met, and that separation is still the most useful thing to know before adding to it. Trajectory planning here is over scalar axes: a profile knows a start, a goal and a set of limits, and has no idea whether the number it moves is a joint angle, a rail position or a spindle override. Kinematics maps joint angles to poses and has no notion of time.
dynamics is the interesting test of that boundary, because it is plainly
about time — it takes joint velocities and accelerations — and it still sits
on the pose side. It takes them as bare span<const Scalar> rather than as a
MotionState, so it never learns where those numbers came from. That is not an
oversight to be tidied: a torque calculation has no use for the profile that
produced the acceleration, and coupling it to one would stop a caller with
measured encoder rates from using it at all.
cartesian is the exception, and the only one. It depends on both sides,
because a straight line in space traversed within joint limits cannot be
described by either alone. It is the newest module and the most delicate, and
the reason it was hard is visible in the diagram: everything else needs one
subtree, and this needs the relationship between them — which is the Jacobian,
and the Jacobian is different at every point on the path. Near a singularity it
demands unbounded joint rates for an ordinary tool speed, and the measured cost
is a factor of 7.4 on the duration of an otherwise identical 50 mm move.
That it is the only crossing is worth preserving. A second module reaching across would make the boundary a suggestion; keeping the traffic through one place means the awkward part of the design is in one file with an ADR attached to it (ADR-0012).
Routes through several waypoints are planned as one path under one profile, so
they no longer stop in between — cartesian grew that rather than a new module,
because a route and a single move differ only in their geometry and must not
differ in how they are paced.
collision is the newest module and sits on the pose side beside dynamics: it
depends on kinematics for where the links are and on nothing that knows about
time. It is deliberately not wired into cartesian, because the obstacle set
changes far more often than the arm does, and baking it into the planner would
mean replanning to answer a question about a fence that moved.
Pacing by path difficulty is in cartesian too, off by default, and takes most
of what time-optimal path parameterisation would offer without giving up the
jerk limit — on the paths where it helps at all. ADR-0016 measures both sides.
CUDA batch inverse kinematics is not planned: GitHub's runners have no GPU, and
nothing here rests on a claim CI cannot check.
- Dependencies point down, never sideways within a level.
se3may useso3;frame_graphandkinematicsare siblings and must not include one another. A change needing that edge is a change that wants a new module. detail/is not part of the promise. It is excluded from the published reference. Anything a consumer may reasonably depend on belongs in a public header, where the documentation gate applies to it.- Failures that a control loop can expect are values, not exceptions.
Expected<T, E>on the boundary; exceptions only where the caller has already violated a precondition, such as normalising a zero vector. - Anything callable from the cyclic task allocates nothing and does not
throw.
lookup,sample,forward,jacobianandinverseare all pinned by tests that replace globaloperator newand assert a zero delta.