Skip to content

Add per-primitive thickness to collision meshes - #287

Merged
zfergus merged 3 commits into
mainfrom
feature/primitive-thickness
Nov 4, 2026
Merged

zfergus merged 3 commits into
mainfrom
feature/primitive-thickness

Conversation

@zfergus

@zfergus zfergus commented Nov 3, 2026

Copy link
Copy Markdown
Member

Description

Adds per-primitive thickness to CollisionMesh. Contacts (collisions/normal), the broad phase (candidates), CCD (ipc.hpp, Candidates), and friction (TangentialPotential) then keep each pair of primitives the sum of their thicknesses apart.

  • Thickness on the mesh. Each vertex, edge, and face has a thickness: the distance its surface is offset from it. A sheet of total thickness h gives each of its primitives h / 2, as in Codimensional IPC (Li et al. 2021).
  • Per-collision dmin. NormalCollisions::build sets each collision's dmin to its pair's thickness and activates it within dhat of that. When a distance type reduces a face-vertex pair to an edge-vertex or vertex-vertex collision (or an edge-edge pair to one of those), the thickness is that of the primitives the collision keeps. The barrier, adhesion, and friction potentials already read collision.dmin.
  • Per-vertex box inflation. Each vertex box is inflated by the largest thickness of the vertex and the edges and faces it bounds. Edge and face boxes are unions of their vertices' boxes, so each one covers its primitive's thickness. Thickness reaches every broad phase this way, with none of them changed.
  • Per-pair CCD distance. is_step_collision_free, compute_collision_free_stepsize, compute_cfl_stepsize, and compute_per_vertex_safe_distances stop each pair at its own thickness.

Impacts:

  • Contacts between garment panels of different fabrics, or rods of varying radius, rest at their own distance rather than at one dmin for the whole mesh. A panel seam is as thick as its thicker side.
  • Meshes without a thickness behave exactly as before, with the same candidates and collisions.
  • The uniform min_distance and dmin parameters are deprecated in favor of the mesh's thickness.

API changes

  • CollisionMesh setters:
    • set_thickness(vertex, edge, face) sets all three.
    • set_vertex_thickness(vertex): edges and faces take their thickest vertex.
    • set_face_thickness(face, vertex = {}): edges and vertices take their thickest face. An edge on no face takes its thicker vertex.
    • clear_thickness() removes it.
  • CollisionMesh accessors: has_thickness(), vertex_thickness(vi), edge_thickness(ei), face_thickness(fi), the *_thicknesses() vectors, max_thickness(), and pair_thickness(candidate). All return 0 (or empty) without a thickness.
  • Candidates: each candidate type has an inline thickness(vertex_thickness, edge_thickness, face_thickness) that sums its two sides, so the candidates do not depend on CollisionMesh.
  • TangentialCollision::dmin: the dmin of the normal collision it was built from.
  • build_vertex_boxes: new overloads take a per-vertex inflation on top of inflation_radius.
  • Python: all of the above is bound. min_distance and dmin default to None.

💥 Breaking changes

  • min_distance is now std::optional<double> = std::nullopt on is_step_collision_free, compute_collision_free_stepsize, Candidates::{is_step_collision_free, compute_collision_free_stepsize, compute_cfl_stepsize, compute_per_vertex_safe_distances}, and ogc::TrustRegion::{warm_start_time_step, update, update_if_needed}.
  • dmin is now std::optional<double> = std::nullopt on NormalCollisions::build (both overloads) and TangentialPotential::{force, force_jacobian, gcp_force, gcp_force_jacobian}.
  • Behavior of a given value: it overrides the mesh's thickness for every pair, as before this PR, and logs a deprecation warning once per process. Without a value, each pair uses its thickness (zero without one).
  • What still compiles: calls passing a double compile unchanged. Code that takes these functions' addresses with the old signatures (e.g., py::overload_cast<..., const double, ...>) needs std::optional<double>.
  • Friction default: TangentialPotential::force and force_jacobian now default to each collision's own dmin instead of 0. Before, building collisions with dmin = d and calling force without a dmin used 0 for the normal force magnitude. It now uses d, consistent with the barrier.
  • Unchanged signatures: the per-pair functions without a mesh (*Candidate::ccd, NarrowPhaseCCD, the barrier and stiffness functions) keep a plain double. They receive the resolved value.
  • GPU CCD: a mesh with a thickness and no min_distance takes the CPU narrow phase in compute_collision_free_stepsize, even with the SweepAndTiniestQueue broad phase. The GPU pipeline has no per-primitive thickness.

Type of change

  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How Has This Been Tested?

  • tests/src/tests/collisions/test_thickness.cpp. Unless noted, each case runs on every broad phase.
    • A uniform thickness τ reproduces a uniform dmin of 2τ on two-cubes-close.ply: the same collision counts, barrier energy, and gradient, for the IPC, improved max approximation, and OGC collision sets. The improved max approximation is compared at 1e-2, since two builds of the same set already differ by ~1e-3 in gradient.
    • Two triangles of different face thicknesses come into contact exactly within dhat of the sum. Every collision's dmin equals its pair's thickness, including distinct vertex, edge, and face values. An explicit dmin overrides the thickness.
    • Codim vertices and plane-vertex contacts with a vertex thickness.
    • CCD stops a falling triangle at the summed thickness, and an explicit min_distance overrides it. The same holds for is_step_collision_free.
    • Per-vertex safe distances subtract the pair's thickness.
    • Tangential collisions keep their normal collision's dmin. The default friction force equals passing the pairs' thickness explicitly and differs from passing dmin = 0.
  • test_collision_mesh.cpp: the derivation rules (seams, edges on no face), the candidates' sums, and rejection of wrong sizes, negative values, and NaN.
  • python/tests/test_collision_mesh.py::test_thickness: the bindings, contact, CCD, and override, end to end.
  • The full suite passes: ipc_toolkit_tests "~[!benchmark]" (467 test cases) and python -m nose2.

Test Configuration:

  • OS and Version: AlmaLinux 9.8
  • Compiler and Version: GCC 11.5.0, CUDA 12.8 (IPC_TOOLKIT_WITH_CUDA=ON, Release, RTX 5080)

Checklist

  • My code follows the clang-format style guidelines of this project
  • I have commented my code, particularly in hard-to-understand areas
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have followed the project style guide
  • I have performed a self-review of my code
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • Any dependent changes have been merged and published in downstream modules

zfergus and others added 2 commits November 3, 2026 16:51
Each vertex, edge, and face of a CollisionMesh can carry a thickness, and a
pair of primitives rests at the sum of theirs.

- `CollisionMesh::set_thickness(vertex, edge, face)` sets it directly;
  `set_vertex_thickness` (edges and faces take their thickest vertex) and
  `set_face_thickness` (edges and vertices take their thickest face) derive
  it. `pair_thickness(candidate)` sums a pair's two sides.
- Each candidate type has an inline `thickness(vertex, edge, face)` on the
  three thickness vectors, so the candidates do not depend on the mesh.
- `NormalCollisions::build` sets each collision's `dmin` to its pair's
  thickness and activates it within `dhat` of that, including the
  sub-stencil a distance type reduces a face-vertex or edge-edge pair to.
- `Candidates::build` inflates each vertex box by the largest thickness of
  the vertex and the edges and faces it bounds, so every edge and face box
  covers its primitive. This works with every broad phase unchanged.
- CCD (`is_step_collision_free`, `compute_collision_free_stepsize`,
  `compute_cfl_stepsize`, `compute_per_vertex_safe_distances`) stops each
  pair at its own thickness. A thick mesh takes the CPU narrow phase rather
  than the GPU Sweep and Tiniest Queue pipeline, which has no thickness.
- `TangentialCollision` keeps its normal collision's `dmin`, which the
  friction forces use for the normal force magnitude.

The uniform `min_distance` and `dmin` parameters of these functions are now
`std::optional<double>` and deprecated. A given value overrides the mesh's
thickness and logs a warning once; otherwise each pair uses its thickness
(zero without one). The per-pair narrow-phase functions keep a plain
`double`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- semi_implicit_stiffness over a mesh takes an optional, deprecated dmin
  like the rest of the API and otherwise uses each stencil's own: a
  collision's dmin, or a candidate's thickness. It used one uniform dmin,
  so thick contacts got the stiffness of a pair at the surface.
- The Python thickness(vertex, edge, face) checks its indices and raises
  IndexError instead of reading past an empty or short array.
- Bind TangentialCollision.dmin in Python.
- The collision builder sums a sub-stencil's thickness through
  CollisionMesh::pair_thickness on the reduced candidate instead of
  repeating each pair rule.
- Document that CCD needs each vertex and edge no thicker than the
  thickest face or edge containing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.49104% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.78%. Comparing base (3765729) to head (fed6118).

Files with missing lines Patch % Lines
src/ipc/candidates/candidates.cpp 91.54% 6 Missing ⚠️
src/ipc/potentials/tangential_potential.cpp 92.85% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #287      +/-   ##
==========================================
+ Coverage   96.76%   96.78%   +0.01%     
==========================================
  Files         223      224       +1     
  Lines       22538    22772     +234     
  Branches     1550     1577      +27     
==========================================
+ Hits        21809    22039     +230     
- Misses        729      733       +4     
Flag Coverage Δ
unittests 96.78% <97.49%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@zfergus zfergus added this to the v1.7.0 milestone Nov 4, 2026
@zfergus
zfergus merged commit 54d58eb into main Nov 4, 2026
22 checks passed
@zfergus
zfergus deleted the feature/primitive-thickness branch November 4, 2026 01:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant