Skip to content

Feature/esp ee fix prototype - #288

Draft
fsichetti wants to merge 28 commits into
ipc-sim:mainfrom
fsichetti:feature/esp-ee-fix-prototype
Draft

fsichetti wants to merge 28 commits into
ipc-sim:mainfrom
fsichetti:feature/esp-ee-fix-prototype

Conversation

@fsichetti

Copy link
Copy Markdown
Contributor

Fix ESP edge-edge force spikes on coarse meshes

Description

Fixes the large, narrow force spikes ESP produces when an edge-edge (EE) contact slides past an edge endpoint on coarse meshes (#280).
Also includes miscellaneous minor improvements.
Touches src/ipc/esp, the EE mollifier in src/ipc/gcp/distance, ESP friction, and the candidate build.

Fixes #280

  • Spike fix. An EE sample goes from weight 0 to its full weight over an extremely narrow window (a tenth of the gap) and takes a fixed share of its element's average, however large the element, so the energy step and the force peak grow with element size. Fixes that address this:
    • Area-consistent EE weights (ee_area_scaling, on): each EE sample is weighted to stand for a (dbar x dbar) patch instead of a fixed share of its element.
    • Smoothstep mollifier (EEMollifierType::SMOOTHSTEP, default): C² fade-in over a width equal to the gap. GCP's original mollifier remains available.
    • Local quadrature transfer (local_quadrature_transfer, off in ipc-toolkit): an EE sample steals weight only from its own edge's endpoints instead of renormalizing the entire element, which keeps the normal force steady while the sample fades in. Each face corner gives at most half its weight to each of its two edges, with one normalization per (face, edge), so samples on different edges stay independent. Supported with friction.
  • Candidate reuse: the swept (CCD) candidate build takes all_types, so a simulator can build ESP collision sets at line-search trial points from the step's candidates instead of running a broad phase per trial point, as it already does for IPC; candidate-to-set conversion runs in parallel. Speeds things up a bit.
  • Element weights are now the rest measure (face area, rod length) instead of area / 9 and length / 4.
  • Per-face lattice rules (set_auto_lattice): an optional fixed face quadrature rule, chosen per face from its rest area, that places more points on larger faces.

API changes

  • ESPParameters, new optional fields: ee_mollifier (EEMollifierConfig: SMOOTHSTEP with rel_eps = 1, or GCP), ee_mollifier_power (0: none for smoothstep, round(p) + 1 for GCP under an inverse-power barrier), ee_weight, ee_area_scaling, ee_area_ref, local_quadrature_transfer; face rules via set_quad_rule() (weights summing to 1), lattice_rule(m) and set_auto_lattice().
  • Candidates::build(..., all_types) for both the static and the swept build; has_sets().
  • TangentialCollisions::build() for ESP supports local transfer.
  • Python bindings for all of the above (face rules: set_quad_rule(points, weights), set_lattice_rule(m), set_auto_lattice()).

💥 Behavior changes

  • Element weights are the rest measure: divide the barrier stiffness by 9 for the same energy scale.
  • EE contact defaults change (smoothstep, area scaling): EE contact is relatively softer on coarser elements, and energies differ from before.
  • The default vertex rule's corner weights now sum to 1 instead of 3.
  • set_quad_rule({}) (formerly vertex integration) now throws; use lattice_rule(1) or the default.
  • The default (smoothstep) EE weight is not raised to p + 1 under inverse-power barriers; GCP keeps the power.
  • Test thresholds in test_esp_potential.cpp were re-measured.

No new dependencies.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • Enhancement (non-breaking change which improves existing functionality)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

How Has This Been Tested?

  • Full non-benchmark suite (Release).
  • New tests:
    • finite-difference gradients and Hessians of local transfer (log and inverse-power barriers, area weights, lattice rules), and PSD of the projected Hessians;
    • edge-over-box repro values; EE endpoint-limit sweeps;
    • ESP friction under transfer (matches the normalized weights exactly when no weight is transferred);
    • all-types candidates keep surface pairs next to codim. primitives (static and swept builds).
  • Python: python/tests/test_esp_local_transfer.py.
  • Downstream: PolyFEM branch feature/esp-ee-fix-prototype builds against this and runs.

Test Configuration:

  • OS and Version: Ubuntu 24.04.5 LTS (AMD EPYC 7452, 128 threads)
  • Compiler and Version: GCC 13.3.0

Checklist

  • I have followed the project style guide
  • My code follows the clang-format style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • 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
  • Any dependent changes have been merged and published in downstream modules

Results

ablation.mp4
  • The energy change happens more slowly and does not cause the observed extremely steep force gradient anymore.
  • At one state (45k vertices) the contact Hessian takes 0.08 s with 1.24 M nonzeros with local transfer, against 0.58 s and 4.13 M without.
  • Candidate reuse and parallel set conversion halve the ESP collision-set build (0.230 → 0.113 s), about 5 % of an iteration.

Huangzizhou and others added 28 commits October 24, 2026 09:10
s_e used a_e = A/3 (faces) and L/2 (rods), which is only the measure per
unit weight for vertex samples. With a face quadrature rule, whose
weights sum to 1, that left EE samples about 3x heavier than intended.
Use a_e = measure / (total weight of the element's fixed samples), so an
EE sample's share on large elements is about m r^2 / A for any rule.
Vertex-rule results are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Faces were weighted by area / 9 and edges in no face by length / 4, a
leftover with no counterpart in the formulation, which integrates the
point potential against the rest area. Weight each element by its rest
measure instead: area for faces, edge area for edges in no face, vertex
area for isolated vertices (unchanged). Energies of face-only meshes
scale by 9, so barrier stiffnesses tuned with the old weights should be
divided by 9; meshes mixing faces and rods also change the relative
weight of the two.

Update the edge-over-box transfer test values and the local-transfer
doc's sweep numbers accordingly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the empty face rule ("order 0"), which integrated faces over their
vertices with weight 1 each, while explicit rules used their own weights.
The two conventions gave edge-edge samples different shares (up to 1/4 of
a face with vertex samples, 1/2 with a rule) and made the unnormalized
potential 3x the area integral for vertex samples only.

- Faces always use a rule, normalized to sum to 1. The default is the
  vertex rule (corners, 1/3 each), the same as PolyFEM's order-1 rule.
  An empty rule is rejected.
- Corner points are integrated with the shared vertex samples, weighted
  by their rule weight, so each vertex is evaluated once instead of once
  per incident face. Only the other points get per-face samples.
- Rods weigh 1/2 per endpoint and particles 1, so every element's fixed
  samples weigh 1 and the unnormalized potential is the area integral.
- Local quadrature transfer accepts any rule with positive corner
  weights: an edge's endpoints donate, capped by the smaller corner
  weight, and the rule's other points are ordinary samples. With the
  vertex rule this is the previous formula exactly.
- The prototype's area-consistent EE scale uses the element's measure
  (its fixed samples weigh 1).

Tests: the no-EE normalized and unnormalized potentials now agree; the
edge-over-box legacy values and the EE endpoint-limit shift bounds are
re-measured; transfer derivatives are also checked with a rule that has
edge and interior points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ESPParameters::lattice_rule(m): the vertex rule on m x m congruent
  sub-triangles. Points on the lattice (i, j, k) / m with weights 1, 3 or
  6 over 3 m^2 (corner, edge, interior): positive, corners and edge
  points included, spacing edge / m. m = 1 is the vertex rule.
- ESPParameters::set_auto_lattice(spacing, max_subdivisions): each face
  uses lattice_rule(m) with m = ceil(equilateral side / spacing) from its
  rest area, clamped to [1, max]. The rule depends on rest geometry only,
  so it is fixed per face; it is not differentiable with respect to the
  rest shape, where a fixed rule should be used instead.
- face_rule(rest_area) gives each face's corner weights and other points;
  the potential, collision building, friction, and local transfer use it.
  Collisions record the lattice settings, and evaluating them with
  different settings throws.
- Python: set_lattice_rule(m), set_auto_lattice(spacing,
  max_subdivisions), lattice_spacing.

Tests cover the lattice rules' weights, corners and first moments, the
subdivision choice, auto vs fixed equivalence on equal-area faces, the
settings check, and finite-difference gradients and Hessians with auto
lattice rules for both the normalized potential and local transfer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The smoothstep EE mollifier faded samples in over w = sqrt(eps) d with
the GCP eps = 1e-2, i.e. 0.1 d, while the edge's endpoint vertex loses
its potential over the gap d as it rounds the other edge. The sample came
in ten times faster than the vertex went out, so any residual energy step
was released over a tenth of the gap: on the coarse box-crossing scene a
40 N lateral spike 1.5 um wide and a 2.4 N normal overshoot.

The smoothstep-rel width is now w = sqrt(eps_rel) d with eps_rel = 1
(ESP_EE_MOLLIFIER_EPS overrides it; GCP keeps its eps). On the coarse box
crossing the lateral peak drops to 3.9 N spread over 88 um and the
overshoot to 0.6 N; on a refined mesh (body 8 x 8, target 32 x 32) the
peak is 0.38 N with no overshoot. w = d is the widest relative width
without a near-endpoint barrier hole (w <= d); the near-endpoint barrier
map stays at 1.00 of a far crossing for gaps up to 100 um.

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

Optimize ESP local transfer with inactive-source skipping and shared sample jets
Replace the prototype's environment variables (ESP_EE_MOLLIFIER,
ESP_EE_MOLLIFIER_EPS/_ELL/_L, ESP_EE_AREA_REF, ESP_LOCAL_TRANSFER,
ESP_EE_WEIGHT, ESP_MOLLIFIER_ORDER) with ESPParameters fields:

- ee_mollifier (EEMollifierConfig: type, rel_eps, width, length),
  default SMOOTHSTEP_REL with rel_eps = 1 (w = d)
- ee_area_scaling (default on) and ee_area_ref (0 = dbar)
- ee_weight (1.5) and ee_mollifier_power (0 = from the barrier, see
  ee_mollifier_exponent())
- legacy_element_weights (default off): faces weigh area / 9 and edges in
  no face length / 4, as before the rest-measure change

local_quadrature_transfer stays opt-in (off), since it throws for
unnormalized weights and friction.

The edge-edge mollifier functions take the config explicitly; the GCP
formulation passes EEMollifierConfig::gcp(), so it keeps the original
shape (under the env switches its values and analytic derivatives
disagreed). ESP friction weights now match the potential's edge-edge
weights: mollifier power and area scaling included.

Old (env) and new (fields) evaluate identical energies and gradients
(max relative difference 4e-16 over log and inverse-power barriers,
normalized/unnormalized, with and without transfer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GCC rejects the single-brace forms under -Werror=missing-braces.
Tangential collisions now use each sample's coefficient in the transferred
energy instead of rejecting transfer with friction:

- a face's fixed samples keep w w_i (not normalized), and their far
  potentials keep it;
- an EE sample's near potential gets sum_f w_f c_f s_f m_j / D_f, and the
  same mass leaves its source edge's endpoints' near coefficients in
  proportion (1 - u_j, u_j), so every coefficient stays nonnegative;
- a sample's contact force is |w_near b_near'(d) + w_far b_far'(d)|.

Faces only, as the rest of ESP friction. With no EE weight the friction
matches the normalized formulation exactly (tested).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ESPPotential::set_lagged_weights() holds each integration element's
near-weight total D_e (its sample weights plus its edge-edge mollifier
weights, from element_near_weights() at a reference state) fixed. The
energy is then sum_e w_e [N_e(x) / D_e + F_e(x) / W_e]: every sample enters
with a fixed nonnegative coefficient, so

- each vertex, face and edge-edge sample is evaluated once, not once per
  element containing it (about 6x for vertex samples);
- each sample's Hessian is projected on its own stencil; the dense
  per-element block that the quotient rule needs for a PSD projection is
  gone.

At the reference state the value equals the normalized energy; the
derivatives omit the derivative of D_e. On the mat-twist state at t = 7 s
(45k vertices), the projected Hessian takes 0.13 s with 1.22M nonzeros,
against 2.17 s and 6.18M with local transfer and 1.18 s and 4.13M without.

Not supported with local_quadrature_transfer (throws).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Build the nine candidate adjacency relations concurrently and sort and
  deduplicate their rows in parallel. Converting candidates to sets was two
  thirds of an ESP collision build (0.152 of 0.230 s on a 45k-vertex
  mat-twist state); it now takes 0.025 s, and the full build 0.113 s.
- Add Candidates::has_sets(), and skip the conversion in ESPCollisions::build
  when the candidates already carry their sets (copies share them).
- Add all_types to the swept Candidates::build, so ESPCollisions can be built
  from a line search's candidates at any point of its trajectory.
- Bind the swept all_types build, convert_candidates_to_sets, has_sets, and
  ESPCollisions.build from candidates in Python.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With each element's denominator D_e held, element e's transferred term
w_e c_e s_e m_j (N(q_j) - R_j) / D_e is a fixed multiple of its pair's
term, and every element containing the source edge shares the pair. So each
pair is evaluated once, as

  coef[ea] m_j (N(q_j) - (1 - u_j) N_a - u_j N_b),
  coef[ea] = sum_{e ∋ ea} w_e c_e s_e / D_e,

and its Hessian is projected on the union of its closest point's, its
source endpoints', and its four vertices' stencils. The fixed samples keep
their per-sample path. element_near_weights() returns the transfer's
denominators (1 + sum_j s_e m_j, with its area scale) when transfer is on.

At the reference state the value equals the normalized transfer energy;
gradient and Hessian match finite differences of the lagged energy with
the projected Hessian PSD (log and inverse-power barriers, with and without
lattice rules).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With D_e frozen for a time step, an endpoint gives away coef * m_j * (1 - u_j)
(or u_j) of near weight per edge-edge pair. That can exceed its fixed weight
only if the element's edge-edge weight grew past its frozen value within the
step. lagged_hessian() now logs, at debug level, how many endpoints donate,
the lowest remaining fraction of fixed weight, and how many are negative.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ee_mollifier_exponent() now defaults to 1 for the smoothstep mollifiers and
keeps round(p) + 1 under an InversePowerBarrier of power p only for the GCP
family. GCP's weight starts linearly in the squared endpoint clearance, and
without the power its gradient in the edge-edge endpoint-limit test reaches
~7e15 (p = 2). Smoothstep starts cubically and stays bounded (1.8e6 with or
without the power), while the power also raises the distance spline to p + 1
and makes edge-edge contact up to 180x softer than vertex contact at moderate
gaps (p = 2; 5x without). An explicit ee_mollifier_power still applies to
any mollifier.

The inverse-quadratic endpoint-limit thresholds are re-measured (shift of
about 5e-5 of P and 5e-4 of |grad|).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts:
- python ESP bindings: upstream split define_esp_parameters out of
  define_esp_potential; the new ESPParameters fields go in the former.
- Candidates::build (CCD): upstream's thickness-aware broad-phase build with
  our all_types detection. The CCD build now also skips the codim. passes
  under all_types (3D), as the static build does, since they would replace
  the whole-mesh vertex-vertex and edge-vertex lists.

Full non-benchmark test suite passes (455 test cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove the fixed-width and sqrt-of-gap variants (SMOOTHSTEP with a fixed
width, SMOOTHSTEP_SQRT, GCP_FIXED, GCP_SQRT) and their width/length fields:
they were explored while diagnosing the edge-edge force spikes and either
leave a hole in the barrier near edge endpoints or release the energy step
too abruptly. What remains is the smoothstep mollifier with w^2 = rel_eps d^2
(default, w = d), renamed from SMOOTHSTEP_REL to SMOOTHSTEP, and GCP's
original mollifier.

Also document local_quadrature_transfer, clear_lagged_weights and
lagged_weights in the Python bindings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add EEMollifierType and EEMollifierConfig to the ESP API pages, and describe
the friction weights under local quadrature transfer in
TangentialCollisions::build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- esp_local_transfer.cpp: braces around single-statement bodies, auto* for
  pointers, and rename a lambda's local that shadowed `out` (-Wshadow).
- esp_local_transfer.hpp: name the declarations' parameters.
- mollifier.tpp: braces around single-statement bodies.
- esp_tangential_collisions.cpp: write the contact-distance test without the
  negation (still false for a NaN distance).

clang-tidy (the project's configuration) now reports nothing on the changed
files, as on main, and GCC reports no warnings in src/, tests/ or the
Python bindings.

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

With all_types the whole-mesh pass already finds every vertex-vertex and
edge-vertex candidate; the codim.-only passes clear their output first, so
running them would leave only codim. pairs. Both builds skip them; the test
covers both and fails for the CCD build without its guard (surface
vertex-vertex and edge-vertex pairs drop to 0). Also clarify the comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local-only notes and example are ignored through .git/info/exclude
instead.
…gging

Each face corner now gives half its weight to each of its two edges, and
the edge-edge samples on an edge take weight only from that edge's halves,
with one live normalization per (face, edge): coefficient c s with
c = min(w_a, w_b) / 2 (1/2 on rods) and denominator 1 + s sum m_j.
Samples on different edges are independent and a corner always keeps part
of its weight, so the energy is exact (no lag) and every coefficient is
positive:

  E = sum_e w_e [sum fixed samples (N + F)]
    + sum_edges sum_pools (w c s) sum_j m_j (N(q_j) - (1 - u_j) N_a - u_j N_b)
                                 / (1 + s sum_j m_j).

Vertex and face-rule interior samples have constant coefficients, so each
is evaluated once and projected on its own stencil; each edge's edge-edge
term is one block over its pairs' and endpoints' stencils. This gives the
sparsity lagged normalization was added for, so lagged normalization is
removed (ESPPotential::element_near_weights, set_lagged_weights,
clear_lagged_weights, lagged_weights and their bindings). Friction under
transfer uses the same per-(face, edge) pools.

Also:
- lattice_subdivisions(): clamp before converting to int (a tiny spacing
  overflowed).
- ESPPotential: log the transfer precondition error.
- Documentation: barrier power (GCP only), legacy_element_weights caveat,
  ee_weight, ee_area_scaling's share c, Candidates::has_sets().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It only scaled face weights by 1/9 and rod weights by 1/4, which on a
face-only mesh is a stiffness change, and it did not restore earlier
energies anyway (corner weights now sum to 1). Also removes its test,
binding and element_weight_scale().
Takes barycentric points (n x 3) and weights; Python could only set
lattice rules before.

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

codecov Bot commented Nov 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.89163% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.95%. Comparing base (54d58eb) to head (17796f8).

Files with missing lines Patch % Lines
src/ipc/esp/esp_collisions.cpp 75.00% 4 Missing ⚠️
src/ipc/esp/esp_local_transfer.cpp 99.43% 2 Missing ⚠️
src/ipc/esp/esp_potential.cpp 98.03% 2 Missing ⚠️
src/ipc/gcp/distance/mollifier.tpp 95.23% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #288      +/-   ##
==========================================
+ Coverage   96.77%   96.95%   +0.18%     
==========================================
  Files         224      226       +2     
  Lines       22772    23400     +628     
  Branches     1576     1671      +95     
==========================================
+ Hits        22038    22688     +650     
+ Misses        734      712      -22     
Flag Coverage Δ
unittests 96.95% <98.89%> (+0.18%) ⬆️

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 v2.0.0 milestone Nov 7, 2026

This branch has not been deployed

No deployments
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.

Edge-edge discontinuity of ESP energy

3 participants