Skip to content

Commit bd547a5

Browse files
committed
feat: capsule collision checking and clearance (WP-12d)
CollisionModel reports the closest approach between an arm's links and its surroundings, and between the arm's own links, at any configuration. WP-12d bundled three things and this is one of them. The other two are open for reasons worth recording. CUDA cannot be verified -- GitHub's runners have no GPU, and a component whose correctness rested on a machine under a desk would be the first thing here not gated by CI. Full TOPP has no jerk limit: it optimises in the phase plane subject to velocity and acceleration, and this library calls jerk the limit that decides whether the mechanics ring. Shipping a planner that dropped it for a shorter cycle time would contradict ADR-0006, and adding jerk to TOPP is a harder problem than TOPP rather than a refinement of it. Everything is a capsule -- a segment with a radius -- so the distance between any two things is the distance between two segments minus their radii. One function, no special cases, no orientation to track. The cost is fidelity: a link that is not very capsule-shaped is covered generously and the model refuses poses a real machine could hold. That is the right direction to be wrong in and the fix is more capsules. Skipping adjacent links is not enough, and believing it is breaks the model. Everyone knows two links sharing a joint touch always. Less obvious is that a spherical wrist turns its last three links about nearly one point, so links two apart overlap there at every configuration as well -- and a model with only the adjacent rule reports a collision while the arm is parked. Nobody debugs a collision checker that fires on a stationary machine; they switch collision checking off, and then the cell has none. build() therefore takes the pairs to ignore, the example ships with the three wrist pairs already excluded, and the dullest test in the file asserts the arm is clear standing still. An exclusion naming a link that does not exist is refused rather than dropped: somebody wrote it meaning something, and ignoring it leaves a model that believes it is safer than it is. Self clearance and obstacle clearance are reported apart, and the tests are what forced it. Returning only the overall minimum is exactly what it says and useless: a straight arm's own links sit 0.22 m apart, so the overall minimum reports 0.22 wherever an obstacle is, until the obstacle is nearer than the arm is to itself. An obstacle 30 cm away was invisible to somebody asking about obstacles. Not a bug in the arithmetic -- a question answered so literally it stopped being useful. Distances are signed, so an overlap reports its depth rather than clamping at zero; touching and driven 40 mm through want different responses. With nothing to measure at all the answer is kNothingNear, a large finite number, because infinity survives arithmetic that should have failed and returns as a NaN somewhere else much later. Two of my own errors the tests caught. An obstacle expected at 0.84 came back at 0.8372, because the upper arm's thicker 0.07 sleeve beats the forearm's 0.06 despite its end sitting slightly further off. And the configuration written to demonstrate self-collision does not collide -- the elbow had to bend the other way, which was found by sweeping rather than by picturing it. A query costs 432 ns for six links, three obstacles and nine self pairs -- twenty-seven capsule distances, 0.043% of a millisecond cycle, allocating nothing -- so it can gate every setpoint rather than only every plan. It is deliberately not wired into CartesianPlan: the obstacle set changes far more often than the arm does, and baking it in would mean replanning to answer a question about a fence that moved. A test follows clearance along a planned Cartesian move instead, which is the composition without the coupling. Nothing checks the swept volume between two configurations, so a step large enough to pass through a thin obstacle passes through it undetected. Stated here rather than discovered later; following a plan means sampling it densely. Also corrects a comment from the previous commit. It said upload-pages-artifact@v3 is not part of the Node 20 deprecation. It does not appear in the notice directly, being a composite action, and it still carries one through the upload-artifact it calls -- which is the single warning the last run reported, down from ten. Comment only, no behaviour. 15 new tests; 222 total, green under GCC and Clang in Debug and Release, ASan/UBSan and clang-tidy, 205 under TSan, with the documentation gate and the installed-package consumer both extended -- the consumer checking a parked arm is clear of itself, which fails first if the allowed-collision set did not survive the install.
1 parent d15bb9d commit bd547a5

13 files changed

Lines changed: 1006 additions & 16 deletions

File tree

.github/workflows/ci.yml

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ jobs:
168168
#include <cstddef>
169169
#include "motionkit/core/calibration.hpp"
170170
#include "motionkit/core/cartesian.hpp"
171+
#include "motionkit/core/collision.hpp"
171172
#include "motionkit/core/dynamics.hpp"
172173
#include "motionkit/core/frame_graph.hpp"
173174
#include "motionkit/core/kinematics.hpp"
@@ -316,6 +317,24 @@ jobs:
316317
!arm.forward(ended).value.isApprox(route.back(), 1e-5, 1e-5)) {
317318
return 22;
318319
}
320+
// The collision header. A parked arm must be clear of itself --
321+
// the check that fails first if the exported allowed-collision set
322+
// did not survive the install.
323+
const auto guard = CollisionModel::sixAxisExample();
324+
if (!guard) {
325+
return 23;
326+
}
327+
const std::array<Scalar, 6> parked{};
328+
const auto nearest = guard.value.clearance(parked, {});
329+
if (!nearest || !(nearest.value.to_self > 0.0)) {
330+
return 24;
331+
}
332+
const std::array<Capsule, 1> fence{
333+
Capsule{Vec3{0.30, 0.0, 0.0}, Vec3{0.30, 0.0, 1.2}, 0.05}};
334+
const auto against = guard.value.clearance(parked, fence);
335+
if (!against || against.value.to_obstacles >= kNothingNear) {
336+
return 25;
337+
}
319338
return 0;
320339
}
321340
EOT
@@ -348,10 +367,15 @@ jobs:
348367
cmake --build build --target docs
349368
350369
- name: Upload for publication
351-
# Deliberately still v3. It is a composite action with no Node runtime
352-
# of its own, so it is not part of the Node 20 deprecation, and v3 with
353-
# deploy-pages@v5 is the pairing GitHub ships in its own Pages starter
354-
# workflow. Newer majors exist; none of them buy anything here.
370+
# Deliberately still v3, but not for the reason first written here.
371+
#
372+
# It is a composite action with no Node runtime of its own, so it does
373+
# not appear in the deprecation notice directly -- and it still carries
374+
# one, because the upload-artifact it calls internally is a Node 20
375+
# action. That is the single warning the last run reported, down from
376+
# ten. Clearing it means a newer major of this action, which is a
377+
# different pairing with deploy-pages from the one GitHub ships in its
378+
# own Pages starter workflow, and that trade has not been made yet.
355379
uses: actions/upload-pages-artifact@v3
356380
with:
357381
path: build/docs/html

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ bump as "something may have moved".
3131

3232
### Added
3333

34+
- **Capsule collision checking** (WP-12d). `CollisionModel` reports the closest
35+
approach between an arm's links and its surroundings, and between the arm's
36+
own links, at any configuration. Distances are signed, so an overlap reports
37+
its depth. Self and obstacle clearances are reported separately because one
38+
routinely masks the other. Takes an allowed-collision set, without which a
39+
spherical wrist reports a collision while parked.
40+
([ADR-0015](docs/adr/0015-collision-checking-in-capsules.md))
3441
- **Blended routes through waypoints** (WP-12c). `CartesianPlan::planThrough`
3542
plans a whole sequence as one path under one profile, so the tool does not
3643
stop at intermediate waypoints. Interior corners are rounded by a configurable

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ add_library(motionkit_core
4747
src/core/se3.cpp
4848
src/core/frame_graph.cpp
4949
src/core/cartesian.cpp
50+
src/core/collision.cpp
5051
src/core/calibration.cpp
5152
src/core/dynamics.cpp
5253
src/core/kinematics.cpp

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Two caveats worth knowing before you spend an afternoon on them:
2020

2121
- **TSan excludes the allocation tests.** They replace global `operator new`,
2222
which is exactly what TSan's runtime also does. `MOTIONKIT_BUILD_ALLOCATION_TESTS`
23-
is off under that preset, so the count is 191 rather than 207.
23+
is off under that preset, so the count is 205 rather than 222.
2424
- **`ScurveProfile` is rest-to-rest; `ReachProfile` is not.** Use the latter
2525
when the axis is already moving. `StopProfile` starts from an arbitrary state
2626
and has no position target, which is a third problem again.

README.md

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ Eigen, no KDL, no Pinocchio — the algorithms are the point.
2626
| WP-12a | Straight-line Cartesian moves, paced by joint limits | **Done** |
2727
| WP-12b | Moves from a non-zero state (the piece blending needed) | **Done** |
2828
| WP-12c | Blended routes through waypoints, without stopping | **Done** |
29-
| WP-12d | Full TOPP, CUDA batch IK and collision checking | Planned |
29+
| WP-12d | Capsule collision checking and clearance | **Done** |
30+
| WP-12e | Full TOPP, CUDA batch IK | Planned |
3031
| WP-15 | API reference, architecture docs, contribution process | **Done** |
3132

32-
207 tests, all passing under GCC and Clang in Debug and Release. ASan and UBSan
33-
exercise the full suite. TSan exercises the 191 ordinary tests; the sixteen
33+
222 tests, all passing under GCC and Clang in Debug and Release. ASan and UBSan
34+
exercise the full suite. TSan exercises the 205 ordinary tests; the seventeen
3435
allocator-interposition tests run in a dedicated executable and are excluded
3536
from TSan because both the tests and the sanitizer runtime replace the global
3637
allocation functions.
@@ -49,7 +50,7 @@ ctest --preset debug
4950
```
5051

5152
Other presets: `release`, `asan`, `tsan`, `tidy`. The `tsan` preset intentionally
52-
runs 191 tests: the sixteen tests that instrument global allocation are a
53+
runs 205 tests: the seventeen tests that instrument global allocation are a
5354
test-harness incompatibility with TSan, not an exemption for production code.
5455

5556
Before pushing, run the formatter -- CI enforces it:
@@ -238,6 +239,23 @@ allocates nothing, which is 0.23 % of a 1 kHz cycle, so it can run in the loop
238239
that needs the answer rather than on a thread with a queue in front of it. See
239240
[ADR-0008](docs/adr/0008-kinematics-by-screws-and-a-damped-inverse.md).
240241

242+
**Skipping adjacent links is not enough for self-collision, and believing it is
243+
breaks the model.** Two links sharing a joint touch always, so everyone skips
244+
adjacent pairs. But a spherical wrist turns its last three links about nearly one
245+
point, so links *two* apart overlap at every configuration too — and a model with
246+
only the adjacent rule reports a collision while the arm is parked. Nobody debugs
247+
that; they switch collision checking off, and then the cell has none. `build`
248+
takes an allowed-collision set, and the dullest test in the repo asserts the
249+
example arm is clear while standing still.
250+
251+
**Self-clearance and obstacle clearance are reported apart.** Returning only the
252+
overall minimum was the first design and the tests killed it: a straight arm's own
253+
links sit **0.22 m** apart, so the overall minimum reports 0.22 regardless of where
254+
an obstacle is, until the obstacle is nearer than the arm is to itself. An obstacle
255+
30 cm away became invisible. Not a bug in the arithmetic — a question answered so
256+
literally it stopped being useful. See
257+
[ADR-0015](docs/adr/0015-collision-checking-in-capsules.md).
258+
241259
**Blending cuts the corner, and the library says how much.** A tool cannot turn
242260
a sharp corner at speed — its velocity would have to change direction
243261
instantaneously — so the only choice is between stopping at the waypoint and not
@@ -422,8 +440,8 @@ test wrong.
422440
|---|---|
423441
| GCC + Clang × Debug + Release | `-Wconversion` and `-Wold-style-cast` fire on different constructs per compiler |
424442
| `-Werror` with `-Wconversion -Wsign-conversion -Wold-style-cast -Wshadow` | Silent narrowing in a pose pipeline is a field failure, not a warning |
425-
| ASan + UBSan on all 207 tests, `-fno-sanitize-recover=all` | A UBSan finding fails the build rather than printing a note |
426-
| TSan on the 191 ordinary tests | Ahead of the threaded executor in WP-08; the sixteen allocator-interposition tests are excluded because TSan defines the same global allocation hooks |
443+
| ASan + UBSan on all 222 tests, `-fno-sanitize-recover=all` | A UBSan finding fails the build rather than printing a note |
444+
| TSan on the 205 ordinary tests | Ahead of the threaded executor in WP-08; the seventeen allocator-interposition tests are excluded because TSan defines the same global allocation hooks |
427445
| clang-tidy, `--warnings-as-errors=*` | Rule set and exclusions justified in ADR-0002 |
428446
| `scripts/format.sh --check` with clang-format 18 | Formatting is not a review topic, and CI runs the same check developers run |
429447
| **install with repository tests off + downstream consumer compile and run** | Exercises only the installed package contract; it caught a real bug on first run when the exported target was `motionkit::motionkit_core` but consumers used `motionkit::core` |
@@ -436,7 +454,7 @@ test wrong.
436454
| Document | What it answers |
437455
|---|---|
438456
| [docs/architecture.md](docs/architecture.md) | C4 context, container and component views, and the rules that decide where new code goes |
439-
| [docs/adr/](docs/adr/) | Fourteen decisions, each with the alternatives that lost and why |
457+
| [docs/adr/](docs/adr/) | Fifteen decisions, each with the alternatives that lost and why |
440458
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to build, what the gates are, and the conventions clang-format cannot express |
441459
| [docs/review-checklist.md](docs/review-checklist.md) | The questions that have actually caught something here |
442460
| [CHANGELOG.md](CHANGELOG.md) | What changed, and what `0.x` promises |
@@ -473,7 +491,7 @@ Unit tests assert known values; the interesting ones assert **properties** over
473491
thousands of uniformly sampled rotations from a fixed seed — a property test you
474492
cannot replay is a flake, not a test.
475493

476-
Sixteen allocation tests are instrumentation rather than ordinary unit tests. They
494+
Seventeen allocation tests are instrumentation rather than ordinary unit tests. They
477495
run in their own executable because their global `operator new`/`operator delete`
478496
replacements affect an entire process. That target alone suppresses GNU's
479497
`-Wmismatched-new-delete` diagnostic: the `malloc`/`free` pairing is deliberate
@@ -524,6 +542,7 @@ no real-time scheduling:
524542
| `DynamicChain::massMatrix`, 6R CRBA | 399.8 | 427.0 | 1050.9 | 0.043 % |
525543
| `CartesianPlan::sample`, 6R 33 knots | 17.9 | 18.4 | 28.5 | 0.002 % |
526544
| `CartesianPlan::plan`, 6R 33 knots | 76109 | 77373 | 87793 | 7.74 % |
545+
| `CollisionModel::clearance`, 6R + 3 obstacles | 416.1 | 431.6 | 1013.2 | 0.043 % |
527546

528547
The maximum column is dominated by whatever else the machine was doing, and is
529548
reported anyway: a control loop is sized by its worst cycle, not its median.

benchmarks/bench_core.cpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include <vector>
1919

2020
#include "motionkit/core/cartesian.hpp"
21+
#include "motionkit/core/collision.hpp"
2122
#include "motionkit/core/dynamics.hpp"
2223
#include "motionkit/core/frame_graph.hpp"
2324
#include "motionkit/core/kinematics.hpp"
@@ -243,6 +244,16 @@ int main() {
243244
g_sink = replanned.value.duration();
244245
}));
245246

247+
const auto guard = CollisionModel::sixAxisExample();
248+
const std::array<Capsule, 3> obstacles{
249+
Capsule{Vec3{0.6, 0.0, 0.0}, Vec3{0.6, 0.0, 1.2}, 0.05},
250+
Capsule{Vec3{-0.5, 0.4, 0.3}, Vec3{-0.5, 0.4, 0.3}, 0.10},
251+
Capsule{Vec3{0.0, -0.7, 0.0}, Vec3{0.4, -0.7, 0.9}, 0.03}};
252+
253+
report("CollisionModel::clearance (6R, 3 obstacles)", measure([&](std::size_t) {
254+
g_sink = guard.value.clearance(pose, obstacles).value.distance;
255+
}));
256+
246257
std::printf(
247258
"\n Timings come from an ordinary desktop with no isolated cores and no\n"
248259
" real-time scheduling, so the maximum column is dominated by whatever\n"
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# ADR-0015: Collision checking in capsules, with an allowed-collision set that is not optional
2+
3+
- **Status**: Accepted
4+
- **Date**: 2026-09-10
5+
- **Deciders**: Onur Can Urhan
6+
7+
## Context
8+
9+
WP-12d bundled three things: full time-optimal path parameterisation, CUDA
10+
batch inverse kinematics, and collision checking. Only the third is here, and
11+
the other two are open for reasons worth writing down rather than leaving to be
12+
inferred from an empty directory.
13+
14+
**CUDA cannot be verified.** GitHub's runners have no GPU, and every claim this
15+
library makes is currently gated by something that runs on every pull request.
16+
A component whose correctness rested on a machine under a desk would be the
17+
first exception, and the first exception is how a project stops being able to
18+
say its tests mean anything.
19+
20+
**Full TOPP conflicts with a decision already made.** Classical TOPP optimises
21+
in the phase plane of the path parameter and its rate, subject to velocity and
22+
acceleration. It has **no jerk limit** -- and this library's README calls jerk
23+
"the limit that distinguishes an S-curve from a trapezoid, and the one that
24+
decides whether the mechanics ring". Shipping a planner that quietly dropped it
25+
in exchange for a shorter cycle time would contradict
26+
[ADR-0006](0006-jerk-limited-profiles-and-a-single-path-parameter.md). Adding
27+
jerk to TOPP is a substantially harder problem than TOPP, not a refinement of
28+
it, and pretending otherwise in a commit message would be the worst of the
29+
options.
30+
31+
Collision checking has none of those problems. It is exactly testable, needs no
32+
hardware, and answers a question the Cartesian planner already half-answers:
33+
`least_manipulability` reports how close a move came to a singularity, and
34+
nothing reported how close it came to the fence.
35+
36+
## Decision 1: everything is a capsule
37+
38+
A link, a fence post, a fixture, a cable: all of them a segment with a radius.
39+
40+
The distance between two capsules is the distance between two segments minus
41+
their radii. That is **one function with no special cases and no orientation to
42+
track**, and it is the entire geometry in the file. Boxes need separating-axis
43+
tests and an orientation each; meshes need a broad phase before they are
44+
affordable at all.
45+
46+
The cost is fidelity, and it is a real cost. A link that is not very
47+
capsule-shaped is covered generously, so the model refuses moves that would
48+
have been fine. That is the right direction to be wrong in, and it is cheaper to
49+
fix by adding a second capsule than by adopting a mesh.
50+
51+
Capsules are given **in the base frame with every joint at zero**, the same
52+
frame joints and inertias use, so all three can be read off one CAD model in one
53+
pose. [ADR-0008](0008-kinematics-by-screws-and-a-damped-inverse.md) explains why
54+
this library keeps asking for that frame and no other.
55+
56+
## Decision 2: skipping adjacent links is not enough, and believing it is breaks the model
57+
58+
Two links sharing a joint touch at every configuration, so a self-collision
59+
check that includes them reports a collision always. Skipping adjacent pairs is
60+
obvious and universal.
61+
62+
It is also insufficient, and the insufficiency is not obvious. A spherical wrist
63+
turns its last three links about nearly one point, so links **two** apart
64+
overlap there at every configuration as well. A model with only the adjacent
65+
rule reports a collision while the arm is parked.
66+
67+
That failure has a predictable ending. Nobody debugs a collision checker that
68+
fires on a stationary machine; they switch collision checking off, and then the
69+
cell has none. So `build` takes the pairs to ignore -- the same
70+
allowed-collision set a real cell keeps beside its geometry -- and the example
71+
model ships with the three wrist pairs already excluded. The test that matters
72+
most in the file is the dull one asserting the example arm reports positive
73+
clearance while standing still.
74+
75+
An exclusion naming a link that does not exist is **refused**, not dropped.
76+
Somebody wrote it meaning something, and a model that silently ignores it
77+
believes it is safer than it is.
78+
79+
## Decision 3: self-clearance and obstacle clearance are reported apart
80+
81+
`clearance()` returns the closest pair overall, and beside it `to_self` and
82+
`to_obstacles`.
83+
84+
Returning only the overall minimum was the first design, and the tests killed
85+
it. A straight arm's own links sit a fixed distance apart -- **0.22 m** on the
86+
example, between the upper arm and the wrist -- so the overall minimum reports
87+
0.22 no matter where an obstacle is, until the obstacle comes nearer than the
88+
arm is to itself. An obstacle 30 cm away is then completely invisible to
89+
somebody asking about obstacles.
90+
91+
That is not a bug in the arithmetic; the overall minimum was exactly the overall
92+
minimum. It is a question answered so literally that it stopped being useful.
93+
The two numbers cost one extra comparison each and answer the two questions
94+
people actually ask.
95+
96+
## Decision 4: distances are signed
97+
98+
Negative means overlapping, by the depth of the overlap, rather than clamped at
99+
zero. "Touching" and "driven 40 mm through" call for different responses, and a
100+
caller who only wants a yes or no compares against zero.
101+
102+
When there is genuinely nothing to measure -- no obstacles, every link pair
103+
adjacent or excluded -- the answer is `kNothingNear`, a large finite number.
104+
Infinity is the honest answer and a terrible one to hand back, because it
105+
survives arithmetic that should have failed and reappears as a NaN much later,
106+
somewhere else.
107+
108+
## Consequences
109+
110+
- A clearance query costs **432 ns** for six links, three obstacles and nine
111+
self pairs -- twenty-seven capsule distances, 0.043% of a millisecond cycle,
112+
allocating nothing. It can therefore run as a gate on every setpoint rather
113+
than as a planning-time check, which is the difference between catching a bad
114+
move and catching a bad *command*.
115+
- The model is per-configuration. Nothing here checks the swept volume between
116+
two configurations, so a step large enough to pass through a thin obstacle
117+
passes through it undetected. Following a plan means sampling it densely
118+
enough, and a test does exactly that along a Cartesian move.
119+
- Six links of capsules is a coarse robot. It will refuse poses a real machine
120+
could hold. That is stated rather than hidden, and the fix is more capsules.
121+
- Nothing is wired into `CartesianPlan` automatically. Planning does not take
122+
obstacles and does not report clearance, because the obstacle set changes far
123+
more often than the arm does and baking it into the planner would mean
124+
replanning to answer a question about a fence that moved.
125+
126+
## Alternatives considered
127+
128+
**Spheres only.** Simpler still -- distance between two spheres is one
129+
subtraction -- and a link needs five or six of them to be covered as well as one
130+
capsule covers it. The segment-segment distance is thirty lines and pays for
131+
itself immediately.
132+
133+
**A full mesh with a broad phase.** What a production cell uses, and correct in
134+
a way capsules never are. It is also a bounding-volume hierarchy, a mesh loader
135+
and a format decision, none of which this library has any business owning.
136+
137+
**Reporting every colliding pair rather than the closest.** A caller deciding
138+
whether to move needs one number. A caller diagnosing why gets the pair that
139+
produced it, which in practice is the one being argued about anyway.
140+
141+
**Checking swept volumes between configurations.** The honest fix for the gap
142+
named above, and it needs a continuous-collision formulation -- conservative
143+
advancement, or a bound on how far the geometry moves per unit of path. Both are
144+
larger than this whole file.

0 commit comments

Comments
 (0)