Skip to content

Make hydroelastic contacts respect margin and gap - #3719

Draft
mzamoramora-nvidia wants to merge 27 commits into
newton-physics:mainfrom
mzamoramora-nvidia:mzamoramora/hydroelastic-margin-gap
Draft

Make hydroelastic contacts respect margin and gap#3719
mzamoramora-nvidia wants to merge 27 commits into
newton-physics:mainfrom
mzamoramora-nvidia:mzamoramora/hydroelastic-margin-gap

Conversation

@mzamoramora-nvidia

@mzamoramora-nvidia mzamoramora-nvidia commented Jul 29, 2026

Copy link
Copy Markdown
Member

Description

Closes #2947.

Hydroelastic contacts now respect each shape's collision margin and gap.

For two shapes, define the margin-relative pair separation as:

d = (sdf_a - margin_a) + (sdf_b - margin_b)

The contact bands are:

  • d < 0: penetrating hydroelastic contact;
  • 0 <= d <= gap_a + gap_b: speculative contact candidate;
  • d > gap_a + gap_b: no contact.

Speculative contacts are exported with nonnegative separation and no current
pressure force. They retain activation stiffness so a compatible solver can
activate them if motion carries the cached contact into penetration. Reduction
keeps speculative and penetrating contacts in separate key ranges, preventing
force or wrench matching from moving a speculative representative into the
penetrating region.

The implementation also:

  • expands the SDF envelope to cover each shape's resolved margin + gap;
  • keeps each shape's adjusted SDF for pressure evaluation while using their sum
    for contact classification and exported separation;
  • evaluates custom pressure once during face generation and caches the result
    for reduction and export;
  • preserves reduced and unreduced force and moment matching for penetrating
    contacts;
  • deprecates HydroelasticSDF.Config.margin_contact_area without removing its
    compatibility behavior in the same release;
  • documents the contact bands, inherited-gap behavior, compatibility settings,
    and buffer cost;
  • adds a verification example with deliberately large margins and gaps so the
    separation is visible.

The hydroelastic_margin_gap example is included only for testing and visual
verification. It will be removed before this PR is ready to merge.

This PR does not implement a gradient-aware hydroelastic pressure surface. The
margin/gap classification is kept separate so a later change can add that mode
without redefining the contact bands. This is related to, but does not implement,
the work discussed in #3503.

Compatibility

Hydroelastic contacts are experimental, but existing scenes may still depend on
the earlier geometric-surface behavior. Setting only margin=0.0 is not enough
when a nonzero gap is inherited or configured. Use both:

shape_cfg = newton.ModelBuilder.ShapeConfig(
    is_hydroelastic=True,
    margin=0.0,
    gap=0.0,
)

This is the closest equivalent to the earlier geometric contact surface. It is
not claimed to be bit-for-bit identical.

gap=None continues to inherit ModelBuilder.rigid_gap, whose default is
nonzero. Users who do not want speculative hydroelastic contacts should set
gap=0.0 explicitly.

Determinism

This PR overlaps with the deterministic hydroelastic work in #3661. Merging
either PR may require coordination with the other.

Performance and memory

A nonzero gap generates additional contact candidates and therefore uses more
collision time and buffer memory. In the 20-frame hydroelastic nut-and-bolt ASV
benchmark on an NVIDIA RTX PRO 6000 Blackwell:

  • main: 62.4 +/- 0.4 ms;
  • this branch with gap=0.005: 80.7 +/- 0.4 ms;
  • ratio: 1.29.

Measured mean contact counts were:

Configuration Raw faces Reduced contacts
main 22,326 1,232
this branch, gap=0.0 33,026 1,243
this branch, gap=0.005 301,540 1,894

The remaining cached-pressure field costs one float per raw-face slot, about
6.8 MiB for the measured nut-and-bolt buffer. A separate geometric-area buffer
was removed during review.

Checklist

  • New or existing tests cover these changes
  • The documentation is up to date with these changes
  • CHANGELOG.md has been updated (if user-facing change)

Test plan

Focused hydroelastic tests:

uv run --extra dev python -m unittest newton.tests.test_hydroelastic

Result after merging the latest main: 39 passed, 2 skipped.

Verification-only margin/gap example:

uv run --extra examples -m newton.examples hydroelastic_margin_gap --device cuda:0 --viewer null --test --quiet --num-frames 360

Hydroelastic nut-and-bolt:

uv run --extra examples -m newton.examples nut_bolt_hydro --device cuda:0 --viewer null --test --quiet --num-frames 120 --world-count 1

Panda hydroelastic scenes:

uv run --extra examples -m newton.examples robot_panda_hydro --device cuda:0 --viewer null --test --quiet --num-frames 720 --world-count 1 --scene pen
uv run --extra examples -m newton.examples robot_panda_hydro --device cuda:0 --viewer null --test --quiet --num-frames 720 --world-count 1 --scene cube

Repository checks:

uvx pre-commit run -a
uv run --extra dev -m newton.tests

Result after merging the latest main: 5,025 passed, 158 skipped.

Manual visual check:

uv run --extra examples -m newton.examples hydroelastic_margin_gap --device cuda:0 --viewer gl --num-frames 360

Verify that the guide lines show the margin boundary and outer gap boundary,
that the sphere passes through the speculative band without being supported
there, and that it settles at the margin boundary.

Bug fix

Before this PR, hydroelastic contact extraction used raw SDF values and could
produce contacts outside the intended margin/gap bands.

Steps to reproduce:

  1. Create two hydroelastic shapes with nonzero margins and gaps.
  2. Place their geometric surfaces farther apart than the margin sum but within
    the margin-plus-gap sum.
  3. Run collision detection.
  4. Observe that contact existence and exported separation do not consistently
    follow the configured margin and gap.

The hydroelastic_margin_gap example provides a visual reproduction and checks
all three bands during a falling-sphere simulation.

New feature / API change

No new public symbol is added. Existing shape configuration now applies to
hydroelastic contact generation:

import newton

shape_cfg = newton.ModelBuilder.ShapeConfig(
    is_hydroelastic=True,
    kh=1.0e9,
    margin=0.10,
    gap=0.08,
    sdf_narrow_band_range=(-0.25, 0.4),
)

builder = newton.ModelBuilder()
builder.add_shape_box(body=-1, hx=0.8, hy=0.8, hz=0.25, cfg=shape_cfg)

Summary by CodeRabbit

  • New Features
    • Added more reproducible deterministic hydroelastic contact generation/reduction with stable ordering.
    • Added a runnable hydroelastic margin/gap example and included it in automated tests.
  • Bug Fixes
    • Improved hydroelastic contact band behavior at margin/gap boundaries, including speculative vs penetrating semantics.
    • Corrected hydroelastic force/pressure/stiffness aggregation and contact-distance conventions in reduced and deterministic modes.
    • Enhanced hydroelastic SDF padding validation and ensured models can finalize correctly when skip-validation options are used.
  • Documentation
    • Expanded collision/SDF docs with clarified margin/gap handling, padding requirements, and updated formulas/behavior.
  • Deprecated
    • margin_contact_area is deprecated but remains effective during the transition period.

Classify hydroelastic faces from both margin-adjusted SDF values and carry pair separation through reduced and unreduced contact export. Keep speculative contacts force-free while retaining local activation stiffness, and size generated SDFs for margin plus gap.
Add a non-gallery sphere-on-box example with visibly large margins and gaps. Its final checks require both speculative detection and penetrating activation before the sphere settles near the margin boundary.
Describe active, speculative, and absent hydroelastic contacts, SDF padding requirements, and the margin-zero/gap-zero compatibility setting. Correct the series material slope and retire the old speculative-force wording.
Prune candidate voxel blocks with the combined margin-adjusted SDF bound and the sum of both gaps. This preserves valid contacts when the shapes contribute unequal shares of the speculative envelope.
Keep reduced speculative contacts at their raw sampled positions and normals. This prevents force and wrench matching from moving a speculative representative into the active margin band.
Keep speculative reduction in disjoint slots so it cannot perturb penetrating force or wrench matching. Verify MuJoCo Warp activation, validate attached SDF padding metadata, exempt particle-only shapes, and add visual band guides.
Keep speculative reduction in one disjoint voxel-key family so a raw patch cannot be exported again through a normal-bin entry. Exercise cached MuJoCo Warp contacts across the activation boundary and verify their solver reference values. Keep attached-SDF remediation limited to the supported rebuild path.
Store one area value per hydroelastic face and reuse the cached pressure
through reduction and export. This removes redundant buffer state while
preserving speculative activation and custom pressure behavior.

Deprecate the unused margin contact area setting and cover zero-gap and
deprecation behavior with focused tests.
Define pair separation and the default gap cost in plain language. Update
the implementation plan and migration guidance to match the deprecated
margin contact area setting.
Keep margin_contact_area effective during its deprecation window instead
of turning it into a no-op immediately. Cover both reduced and unreduced
speculative contacts and correct the custom-pressure explanation.
Capture the final ASV timing, contact-count growth, and buffer cost. Note
that setting gap to zero removes most of the speculative-band overhead.
Keep the public configuration docstring consistent with Newton's SI unit conventions.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Hydroelastic contacts now account for per-shape margins and gaps, cache pressure through reduction and export, support deterministic ordering and accumulation, validate SDF padding, deprecate margin_contact_area, and add tests, documentation, and a runnable margin-gap example.

Changes

Hydroelastic margin-gap contacts

Layer / File(s) Summary
Contact contracts and SDF padding
newton/_src/geometry/sdf_utils.py, newton/_src/sim/builder.py, newton/_src/geometry/narrow_phase.py, newton/_src/geometry/sdf_hydroelastic.py
SDF construction padding is tracked, hydroelastic padding must cover margin + gap, and deterministic hydroelastic validation is wired through the narrow phase.
Margin-aware contact generation
newton/_src/geometry/sdf_hydroelastic.py
Generation and decoding use margin-adjusted SDF values, pair separation, cached pressure, shape data, and stable face fingerprints.
Cached-pressure reduction and deterministic export
newton/_src/geometry/contact_reduction_global.py, newton/_src/geometry/contact_reduction_hydroelastic.py
Reducer buffers store pressure, speculative contacts use separate key space, and deterministic reduction updates force, moment, stiffness, distance, and ordering calculations.
Verification, examples, and documentation
newton/tests/test_hydroelastic.py, newton/tests/test_examples.py, newton/examples/contacts/*, docs/concepts/collisions.rst, CHANGELOG.md
Tests and examples cover margin-gap bands, padding validation, deterministic behavior, and deprecation; documentation and changelog entries describe the updated behavior.

Estimated code review effort: 5 (Critical) | ~90 minutes

Possibly related issues

  • newton-physics/newton#2621 — Addresses speculative hydroelastic contacts in the non-penetrating gap band.
  • newton-physics/newton#3233 — Covers related margin/gap classification and deterministic hydroelastic contact generation.

Possibly related PRs

Suggested reviewers: nvtw, shi-eric

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately captures the main hydroelastic margin/gap behavior change.
Linked Issues check ✅ Passed The changes implement margin-aware hydroelastic contact generation, SDF padding, and contact-band handling required by #2947.
Out of Scope Changes check ✅ Passed The docs, tests, and example additions support the same hydroelastic margin/gap work and do not appear unrelated.
Docstring Coverage ✅ Passed Docstring coverage is 88.24% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
docs/concepts/collisions.rst (1)

1584-1587: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Mark margin_contact_area as deprecated at this first mention. Lines 1023 and 1659 both flag it; readers hitting this paragraph first may take it as a supported knob.

📝 Suggested wording
-Speculative contacts carry an activation stiffness based on
-``margin_contact_area``. This allows a compatible solver to activate a cached
-contact after closing motion without treating the speculative contact as a
-current force.
+Speculative contacts carry an activation stiffness based on the deprecated
+``margin_contact_area`` compatibility setting. This allows a compatible solver
+to activate a cached contact after closing motion without treating the
+speculative contact as a current force.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/concepts/collisions.rst` around lines 1584 - 1587, Update the first
mention of margin_contact_area in the speculative contacts paragraph to mark it
as deprecated, matching the existing deprecation notation used at the other
documented mentions while preserving the surrounding explanation.
newton/_src/sim/builder.py (1)

10414-10424: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated hydroelastic sdf_padding >= margin + gap check.

_validate_shapes() (10414-10424) and the SDF-generation loop in finalize() (11476-11485) both compute margin + gap for hydroelastic colliding shapes and raise an equivalent ValueError with the same message pattern. Since the finalize() copy is unconditional, skip_validation_shapes=True doesn't actually skip this specific invariant — it still gets validated (and can still raise) from the second site, which is a bit at odds with the flag's stated purpose of skipping "validation of shapes having valid contact margins."

Consider extracting a single _check_hydroelastic_sdf_padding(shape_index, margin, gap, sdf_padding) helper (or computing required_sdf_padding once and reusing it) to avoid two independently-maintained copies of the same error text and threshold logic.

Also applies to: 11476-11485

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@newton/_src/sim/builder.py` around lines 10414 - 10424, The hydroelastic
sdf_padding invariant is duplicated in _validate_shapes() and finalize(),
causing validation to run even when skip_validation_shapes=True. Extract a
shared _check_hydroelastic_sdf_padding helper, or otherwise centralize the
required margin + gap calculation and error, then invoke it only through the
intended validation path so finalize() does not independently revalidate skipped
shapes.
newton/_src/geometry/contact_reduction_hydroelastic.py (1)

205-246: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate voxel-registration logic vs. the existing penetrating-contact path (lines 302-328).

The new speculative branch (compute voxel index → clamp → split into group/slot → hashtable_find_or_insert → set entry_k_effreduction_update_slot) is structurally identical to "Part 2: Voxel-based reduction" below it, differing only in the bin_id base offset (SPECULATIVE_BIN_OFFSET vs NUM_NORMAL_BINS). Extracting a shared @wp.func helper parametrized by the bin offset (and the value to store) would remove ~20 lines of copy-pasted logic and keep the two paths from silently drifting apart in the future.

♻️ Sketch of a shared helper
`@wp.func`
def _register_voxel_slot(
    shape_a: int,
    shape_b: int,
    bin_offset: int,
    voxel_idx: int,
    score: float,
    contact_id: int,
    shape_material_k_hydro: wp.array(dtype=wp.float32),
    reducer_data: GlobalContactReducerData,
) -> int:
    voxels_per_group = wp.static(NUM_SPATIAL_DIRECTIONS + 1)
    voxel_group = voxel_idx // voxels_per_group
    voxel_local_slot = voxel_idx % voxels_per_group
    entry_idx = hashtable_find_or_insert(
        make_contact_key(shape_a, shape_b, bin_offset + voxel_group),
        reducer_data.ht_keys,
        reducer_data.ht_active_slots,
    )
    if entry_idx >= 0:
        reducer_data.entry_k_eff[entry_idx] = _effective_stiffness(
            shape_material_k_hydro[shape_a], shape_material_k_hydro[shape_b]
        )
        reduction_update_slot(
            entry_idx, voxel_local_slot, _make_contact_value_fast(score, 0, contact_id),
            reducer_data.ht_values, reducer_data.ht_capacity,
        )
    else:
        wp.atomic_add(reducer_data.ht_insert_failures, 0, 1)
    return entry_idx

Both the depth >= 0.0 branch and "Part 2" could then call this with bin_offset=wp.static(SPECULATIVE_BIN_OFFSET) / wp.static(NUM_NORMAL_BINS) respectively.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@newton/_src/geometry/contact_reduction_hydroelastic.py` around lines 205 -
246, Extract the duplicated voxel-registration flow into a shared `@wp.func`, such
as _register_voxel_slot, parameterized by bin offset, voxel index, score, and
contact ID while reusing the existing stiffness, hash-table, slot-update, and
insertion-failure logic. Replace both the depth >= 0.0 speculative branch and
the Part 2 voxel-reduction path with calls using their respective bin offsets,
preserving their existing values and behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@newton/_src/geometry/contact_reduction_hydroelastic.py`:
- Around line 535-536: Update the margin_contact_area documentation in both the
kernel-factory parameter doc and HydroelasticReductionConfig.margin_contact_area
attribute doc to state the SI unit [m²], matching
HydroelasticSDF.Config.margin_contact_area. Keep the existing deprecation
description unchanged.

In `@newton/examples/contacts/example_hydroelastic_margin_gap.py`:
- Around line 113-116: Update capture() to initialize self.graph to None before
entering wp.ScopedCapture, and guard the capture context so it is used only on
CUDA devices while still running self.simulate() on non-CUDA devices. Preserve
step()’s existing self.graph check and assign the captured graph only when
capture succeeds.

---

Nitpick comments:
In `@docs/concepts/collisions.rst`:
- Around line 1584-1587: Update the first mention of margin_contact_area in the
speculative contacts paragraph to mark it as deprecated, matching the existing
deprecation notation used at the other documented mentions while preserving the
surrounding explanation.

In `@newton/_src/geometry/contact_reduction_hydroelastic.py`:
- Around line 205-246: Extract the duplicated voxel-registration flow into a
shared `@wp.func`, such as _register_voxel_slot, parameterized by bin offset,
voxel index, score, and contact ID while reusing the existing stiffness,
hash-table, slot-update, and insertion-failure logic. Replace both the depth >=
0.0 speculative branch and the Part 2 voxel-reduction path with calls using
their respective bin offsets, preserving their existing values and behavior.

In `@newton/_src/sim/builder.py`:
- Around line 10414-10424: The hydroelastic sdf_padding invariant is duplicated
in _validate_shapes() and finalize(), causing validation to run even when
skip_validation_shapes=True. Extract a shared _check_hydroelastic_sdf_padding
helper, or otherwise centralize the required margin + gap calculation and error,
then invoke it only through the intended validation path so finalize() does not
independently revalidate skipped shapes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: e0872dfa-fa57-46d4-be21-2694db58dc4a

📥 Commits

Reviewing files that changed from the base of the PR and between 0a3e9e1 and e51e583.

📒 Files selected for processing (12)
  • CHANGELOG.md
  • docs/concepts/collisions.rst
  • issue_2947_hydroelastic_margin_gap_plan.md
  • newton/_src/geometry/contact_reduction_global.py
  • newton/_src/geometry/contact_reduction_hydroelastic.py
  • newton/_src/geometry/narrow_phase.py
  • newton/_src/geometry/sdf_hydroelastic.py
  • newton/_src/geometry/sdf_utils.py
  • newton/_src/sim/builder.py
  • newton/examples/contacts/example_hydroelastic_margin_gap.py
  • newton/tests/test_examples.py
  • newton/tests/test_hydroelastic.py

Comment thread newton/_src/geometry/contact_reduction_hydroelastic.py Outdated
Comment on lines +113 to +116
def capture(self):
with wp.ScopedCapture() as capture:
self.simulate()
self.graph = capture.graph

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -A6 'def capture\(self\)' newton/examples/contacts

Repository: newton-physics/newton

Length of output: 5524


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file outline =="
ast-grep outline newton/examples/contacts/example_hydroelastic_margin_gap.py --view compact || true

echo "== relevant lines =="
sed -n '1,180p' newton/examples/contacts/example_hydroelastic_margin_gap.py | nl -ba

echo "== constructor/delegation around Simulation class =="
rg -n "class .*Simulation|def __init__|super().__init__|self\.use_graph|GraphBuilder|enable_graph|def step|self.graph|capture\(" newton/examples/contacts/example_hydroelastic_margin_gap.py | head -80

echo "== nearby captures in examples with CUDA guards / pre-initialization =="
sed -n '130,180p' newton/examples/contacts/example_domino_spiral.py | nl -ba
sed -n '140,175p' newton/examples/contacts/example_newton_cradle.py | nl -ba
sed -n '150,170p' newton/examples/contacts/example_balance_bird.py | nl -ba
sed -n '245,265p' newton/examples/contacts/example_nut_bolt_sdf.py | nl -ba
sed -n '945,965p' newton/examples/contacts/example_brick_stacking.py | nl -ba

Repository: newton-physics/newton

Length of output: 390


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
# Read-only semantic probe for the Python control-flow issue as written:
# this class does not define self.graph in __init__, then defines it conditionally
# inside capture(); after a failure on the first with block, step() checks if self.graph.
class OffendingSimulation:
    def __init__(self):
        self.sim_substeps = 1
    def capture(self):
        # In Warp, this would raise on CPU before anything in the with block runs.
        raise RuntimeError("no CUDA")
    def step(self):
        if self.graph:
            pass

sim = OffendingSimulation()
try:
    sim.capture()
except RuntimeError as exc:
    print(f"capture raised: {type(exc).__name__}: {exc}")
    try:
        sim.step()
    except AttributeError as exc:
        print(f"step raised: {type(exc).__name__}: {exc}")

# Compare with the guard pattern from other hydroelastic examples:
class GuardedSimulation:
    def __init__(self):
        self.sim_substeps = 1
    def capture(self):
        self.graph = None
        if not True:  # simulating is_cuda=False
            return
        with None as graph:  # would be ScopedCapture
            pass
        self.graph = graph.graph
    def step(self):
        if self.graph:
            pass

sim2 = GuardedSimulation()
try:
    sim2.capture()
    sim2.step()
    print("guarded example did not raise in capture/step")
except Exception as exc:
    print(f"guarded example raised: {type(exc).__name__}: {exc}")
PY

Repository: newton-physics/newton

Length of output: 330


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant lines =="
python3 - <<'PY'
from pathlib import Path
p = Path("newton/examples/contacts/example_hydroelastic_margin_gap.py")
for i, line in enumerate(p.read_text().splitlines(), start=1):
    if 1 <= i <= 180:
        print(f"{i:4}: {line}")
PY

echo "== Simulation constructor/delegation and graph usage =="
python3 - <<'PY'
from pathlib import Path
p = Path("newton/examples/contacts/example_hydroelastic_margin_gap.py")
text = p.read_text()
for needle in ["class Example", "def __init__", "Simulation.__init__", "enable_graph", "self.graph", "def step"]:
    for i, line in enumerate(text.splitlines(), start=1):
        if needle in line:
            start=max(1,i-2); end=min(len(text.splitlines()),i+3)
            lines=text.splitlines()
            print(f"\n--- {needle} at {i} ---")
            for j in range(start,end+1):
                print(f"{j:4}: {lines[j-1]}")
PY

echo "== compare guarded initializations =="
python3 - <<'PY'
from pathlib import Path
for path in [
    "newton/examples/contacts/example_domino_spiral.py",
    "newton/examples/contacts/example_newton_cradle.py",
    "newton/examples/contacts/example_balance_bird.py",
    "newton/examples/contacts/example_nut_bolt_sdf.py",
    "newton/examples/contacts/example_hydroelastic_margin_gap.py",
]:
    print(f"\n--- {path} ---")
    lines=Path(path).read_text().splitlines()
    for i, line in enumerate(lines, start=1):
        if "self.graph" in line or "self.use_graph" in line or f"with wp.ScopedCapture" in line or f"wp.get_device().is_cuda" in line:
            start=max(1,i-1); end=min(len(lines),i+3)
            for j in range(start,end+1):
                print(f"{j:4}: {lines[j-1]}")
PY

Repository: newton-physics/newton

Length of output: 15161


Guard graph capture for non-CUDA devices. capture() calls wp.ScopedCapture unconditionally, but self.graph is only assigned after the capture body; on CPU this raises before step() runs, and step() later checks self.graph. Drop the CUDA guard and initialize self.graph = None before calling wp.ScopedCapture, as several capture-enabled examples already do.
[low effort_and_high_reward]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@newton/examples/contacts/example_hydroelastic_margin_gap.py` around lines 113
- 116, Update capture() to initialize self.graph to None before entering
wp.ScopedCapture, and guard the capture context so it is used only on CUDA
devices while still running self.simulate() on non-CUDA devices. Preserve
step()’s existing self.graph check and assign the captured graph only when
capture succeeds.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 1 line in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
newton/_src/sim/builder.py 90.90% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Give the Sphinx reference explicit link text because its target is a
labeled paragraph rather than a titled section. This prevents the
documentation build from treating the missing caption as an error.
Keep the issue planning notes local instead of including them in the
pull request.
Mark the nearby setting reference as deprecated and document its SI
unit consistently.
Keep the hydroelastic padding check in shape validation so the public
skip flags work as documented. Preserve the padding calculation used for
SDF generation.
Use one registration path for penetrating and speculative voxel
contacts while retaining their disjoint key ranges. Verify speculative
contacts cannot move into the penetrating margin region.
Record that model finalization now honors the public validation-skip
flags for hydroelastic SDF padding.
# Conflicts:
#	newton/_src/geometry/contact_reduction_hydroelastic.py
#	newton/_src/geometry/sdf_hydroelastic.py
#	newton/tests/test_hydroelastic.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
newton/_src/geometry/sdf_hydroelastic.py (1)

1738-1964: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Pre-prune export path never sets contact_fingerprints, corrupting sort_sub_key in the default configuration.

export_hydroelastic_contact_to_buffer (the direct, non-pre-prune path) correctly stores a fingerprint (tid * MAX_MC_FACES_PER_VOXEL + fi), but the local top‑K compaction path (best_pen0_*/best_pen1_*/best_nonpen_*) never captures or writes a fingerprint anywhere in the selection loop (1847-1899) or the final write-out block (1901-1964) — only position_depth, normal, shape_pairs, contact_area, and contact_pressure are written to reducer_data at out_idx.

GlobalContactReducer.contact_fingerprints is never cleared by clear_active() (only hashtable/aggregate/counter state is), so any out_idx written via this path retains a stale value from whatever contact previously occupied that buffer slot (or 0). Since pre_prune = reduce_contacts and pre_prune_contacts, and both default to True (deterministic mode is the only thing that disables pre-prune), this is the default active code path for HydroelasticSDF. Downstream, contact_fingerprints[contact_id] feeds contact_data.sort_sub_key in export_hydroelastic_reduced_contacts_kernel and the fingerprint tiebreaker in make_contact_value, so pre-pruned contacts get bogus/duplicated sort keys — this can corrupt persistent contact-ID assignment used for solver warm-starting. Note the existing determinism tests (test_deterministic_hydroelastic_contacts*) run with deterministic=True, which forces pre_prune_contacts=False, so they don't exercise this path.

🐛 Proposed fix (representative, apply the pattern to all three candidate slots)
             best_pen0_valid = int(0)
             best_pen0_score = float(-MAXVAL)
             best_pen0_depth = float(0.0)
             best_pen0_area = float(0.0)
             best_pen0_pressure = float(0.0)
+            best_pen0_fingerprint = int(0)
             best_pen0_normal = wp.vec3(0.0, 0.0, 1.0)
@@
             best_pen1_valid = int(0)
             best_pen1_score = float(-MAXVAL)
             best_pen1_depth = float(0.0)
             best_pen1_area = float(0.0)
             best_pen1_pressure = float(0.0)
+            best_pen1_fingerprint = int(0)
             best_pen1_normal = wp.vec3(0.0, 0.0, 1.0)
@@
             best_nonpen_valid = int(0)
             best_nonpen_depth = float(MAXVAL)
             best_nonpen_area = float(0.0)
             best_nonpen_pressure = float(0.0)
+            best_nonpen_fingerprint = int(0)
             best_nonpen_normal = wp.vec3(0.0, 0.0, 1.0)
@@
             for fi in range(num_faces):
+                face_fingerprint = tid * MAX_MC_FACES_PER_VOXEL + fi
                 force_area, geometric_area, normal, face_center, adjusted_sdf_shape_b, pair_separation, face_verts = (
                     mc_calc_face_texture(...)
                 )
@@
                 if pair_separation < 0.0:
                     score = force_area * face_pressure
                     if best_pen0_valid == 0 or score > best_pen0_score:
                         best_pen1_valid = best_pen0_valid
+                        best_pen1_fingerprint = best_pen0_fingerprint
                         ...
                         best_pen0_valid = int(1)
+                        best_pen0_fingerprint = face_fingerprint
                         ...
                     elif wp.static(PRE_PRUNE_MAX_PENETRATING > 1):
                         if best_pen1_valid == 0 or score > best_pen1_score:
                             best_pen1_valid = int(1)
+                            best_pen1_fingerprint = face_fingerprint
                 else:
                     if pair_separation < best_nonpen_depth:
                         best_nonpen_valid = int(1)
+                        best_nonpen_fingerprint = face_fingerprint
@@
                 if best_pen0_valid == 1 and out_idx < reducer_data.capacity:
                     ...
                     reducer_data.contact_pressure[out_idx] = best_pen0_pressure
+                    reducer_data.contact_fingerprints[out_idx] = best_pen0_fingerprint
                     ...

(repeat the contact_fingerprints[out_idx] = ... write for the best_pen1 and best_nonpen output blocks)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@newton/_src/geometry/sdf_hydroelastic.py` around lines 1738 - 1964, Capture
each selected face’s fingerprint, using tid * MAX_MC_FACES_PER_VOXEL + fi,
alongside the candidate fields in the best_pen0_*, best_pen1_*, and
best_nonpen_* selection paths. Write the corresponding fingerprint to
reducer_data.contact_fingerprints at each pre-prune output index in all three
final write-out blocks, matching the existing direct export path.
newton/tests/test_hydroelastic.py (2)

2010-2033: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not use abs() to detect int64 underflow.

Line 2032 passes for np.int64 minimum because np.abs(np.int64(-2**63)) overflows back to a negative value, which is still less than int64.max. Explicitly reject both extrema.

Proposed assertion fix
-    test.assertTrue(np.all(np.abs(fixed_np[1:]) < np.iinfo(np.int64).max))
+    int64_info = np.iinfo(np.int64)
+    test.assertFalse(np.any(fixed_np[1:] == int64_info.min))
+    test.assertFalse(np.any(fixed_np[1:] == int64_info.max))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@newton/tests/test_hydroelastic.py` around lines 2010 - 2033, Update the
assertions in test_fixed_point_extreme_exponents to avoid np.abs when validating
int64 bounds. Explicitly assert that each converted value is neither
np.iinfo(np.int64).min nor np.iinfo(np.int64).max, rejecting both extrema
without overflow.

528-563: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Compare every exported contact field.

Lines 547-563 only compare point0 and normal, so reordered IDs, shape pairs, offsets, margins, or material fields can regress while this “bit-identical contacts” test passes. Snapshot the same exported fields covered by the reduced-path test.

Proposed test expansion
-        snapshots.append(
-            (
-                count,
-                contacts.rigid_contact_point0.numpy()[:count].copy(),
-                contacts.rigid_contact_normal.numpy()[:count].copy(),
-            )
-        )
+        snapshots.append(
+            (
+                count,
+                tuple(getattr(contacts, name).numpy()[:count].copy() for name in contact_fields),
+            )
+        )
...
-    for count, point0, normal in snapshots[1:]:
+    for count, fields in snapshots[1:]:
         test.assertEqual(count, snapshots[0][0])
-        np.testing.assert_array_equal(point0, snapshots[0][1], err_msg="rigid_contact_point0")
-        np.testing.assert_array_equal(normal, snapshots[0][2], err_msg="rigid_contact_normal")
+        for name, expected, actual in zip(contact_fields, snapshots[0][1], fields, strict=True):
+            np.testing.assert_array_equal(actual, expected, err_msg=name)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@newton/tests/test_hydroelastic.py` around lines 528 - 563, Expand
test_deterministic_hydroelastic_contacts_unreduced to snapshot and compare every
exported contact field covered by the reduced-path determinism test, not only
rigid_contact_point0 and rigid_contact_normal. Include contact IDs, shape pairs,
offsets, margins, and material-related fields, preserving the existing count and
bit-identical comparisons across repeated collisions.
newton/_src/geometry/contact_reduction_hydroelastic.py (1)

383-403: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Deterministic normal-bin pre-registration wastes hashtable capacity on speculative contacts.

Unlike reduce_hydroelastic_contacts_kernel, which only registers penetrating (depth < 0.0) contacts into normal bins (speculative contacts are diverted into the disjoint SPECULATIVE_BIN_OFFSET voxel keyspace and never reach normal-bin registration), _register_hydroelastic_normal_bins_kernel registers every buffered contact — including speculative ones — into a normal bin. Downstream consumers (accumulate_reduced_depth_kernel, the export kernel's normal-bin path) all gate on depth < 0.0 before reading contact_nbin_entry, so the normal-bin entry created for a speculative contact is never actually used for anything.

This wastes shared hashtable capacity specifically in deterministic mode, and since this PR's whole purpose is enabling margin/gap-driven speculative contacts (which can be numerous relative to penetrating ones), this risks premature hashtable saturation and ht_insert_failures for legitimate penetrating contacts in gap-heavy scenes — something the stacked-cubes-based determinism tests wouldn't surface.

🐛 Proposed fix
     tid = wp.tid()
     num_contacts = wp.min(reducer_data.contact_count[0], reducer_data.capacity)
     for contact_id in range(tid, num_contacts, total_num_threads):
+        pd = reducer_data.position_depth[contact_id]
+        if pd[3] >= 0.0:
+            continue
         pair = reducer_data.shape_pairs[contact_id]
         normal = decode_oct(reducer_data.normal[contact_id])
         key = make_contact_key(pair[0], pair[1], get_slot(normal))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@newton/_src/geometry/contact_reduction_hydroelastic.py` around lines 383 -
403, Update _register_hydroelastic_normal_bins_kernel to register normal-bin
keys only for contacts with depth < 0.0, matching
reduce_hydroelastic_contacts_kernel. Leave speculative contacts with
contact_nbin_entry unset or otherwise unregistered so they do not consume
normal-bin hashtable capacity, while preserving existing stiffness
initialization and insertion-failure handling for penetrating contacts.
🧹 Nitpick comments (1)
newton/_src/geometry/contact_reduction_hydroelastic.py (1)

675-675: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant int() cast on an already-integer literal.

🧹 Suggested fix
-            entry_idx = int(-1)
+            entry_idx = -1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@newton/_src/geometry/contact_reduction_hydroelastic.py` at line 675, Remove
the redundant int() conversion from the entry_idx initialization in the
contact-reduction logic, assigning the integer sentinel directly while
preserving its existing value and behavior.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@newton/_src/geometry/contact_reduction_hydroelastic.py`:
- Around line 383-403: Update _register_hydroelastic_normal_bins_kernel to
register normal-bin keys only for contacts with depth < 0.0, matching
reduce_hydroelastic_contacts_kernel. Leave speculative contacts with
contact_nbin_entry unset or otherwise unregistered so they do not consume
normal-bin hashtable capacity, while preserving existing stiffness
initialization and insertion-failure handling for penetrating contacts.

In `@newton/_src/geometry/sdf_hydroelastic.py`:
- Around line 1738-1964: Capture each selected face’s fingerprint, using tid *
MAX_MC_FACES_PER_VOXEL + fi, alongside the candidate fields in the best_pen0_*,
best_pen1_*, and best_nonpen_* selection paths. Write the corresponding
fingerprint to reducer_data.contact_fingerprints at each pre-prune output index
in all three final write-out blocks, matching the existing direct export path.

In `@newton/tests/test_hydroelastic.py`:
- Around line 2010-2033: Update the assertions in
test_fixed_point_extreme_exponents to avoid np.abs when validating int64 bounds.
Explicitly assert that each converted value is neither np.iinfo(np.int64).min
nor np.iinfo(np.int64).max, rejecting both extrema without overflow.
- Around line 528-563: Expand test_deterministic_hydroelastic_contacts_unreduced
to snapshot and compare every exported contact field covered by the reduced-path
determinism test, not only rigid_contact_point0 and rigid_contact_normal.
Include contact IDs, shape pairs, offsets, margins, and material-related fields,
preserving the existing count and bit-identical comparisons across repeated
collisions.

---

Nitpick comments:
In `@newton/_src/geometry/contact_reduction_hydroelastic.py`:
- Line 675: Remove the redundant int() conversion from the entry_idx
initialization in the contact-reduction logic, assigning the integer sentinel
directly while preserving its existing value and behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0be00e27-330e-4907-a306-94e5443285ac

📥 Commits

Reviewing files that changed from the base of the PR and between a8e41b4 and 71a4a82.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • docs/concepts/collisions.rst
  • newton/_src/geometry/contact_reduction_global.py
  • newton/_src/geometry/contact_reduction_hydroelastic.py
  • newton/_src/geometry/narrow_phase.py
  • newton/_src/geometry/sdf_hydroelastic.py
  • newton/tests/test_examples.py
  • newton/tests/test_hydroelastic.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/concepts/collisions.rst
  • CHANGELOG.md

Carry each selected face fingerprint through local pre-pruning.

This gives reduced contacts stable sort keys instead of stale buffer values.
Skip penetrating normal-bin registration for speculative contacts.

They remain deterministic in their separate speculative voxel key range.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
newton/_src/geometry/sdf_hydroelastic.py (1)

761-812: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing SI unit for margin in the new shape_data docstring entry.

shape_data: Per-shape scale and collision margin. omits the unit for the physical margin quantity, unlike sibling docstrings for the same quantity elsewhere (e.g. margin_contact_area documents [m^2]).

📝 Suggested doc fix
-            shape_data: Per-shape scale and collision margin.
+            shape_data: Per-shape scale and collision margin [m].

As per coding guidelines, **/*.{py,pyi}: "Include SI units for physical quantities in public API docstrings, including appropriate units for joint-dependent values, spatial vectors, and compound arrays; omit units for non-physical fields."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@newton/_src/geometry/sdf_hydroelastic.py` around lines 761 - 812, Update the
launch method’s shape_data docstring entry to specify the SI unit [m] for the
physical collision margin, while retaining the scale description and leaving
non-physical fields unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@newton/_src/geometry/sdf_hydroelastic.py`:
- Around line 761-812: Update the launch method’s shape_data docstring entry to
specify the SI unit [m] for the physical collision margin, while retaining the
scale description and leaving non-physical fields unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6026368c-a0c1-4f78-8b5a-55f34867ae5f

📥 Commits

Reviewing files that changed from the base of the PR and between 71a4a82 and 9b39ec2.

📒 Files selected for processing (3)
  • newton/_src/geometry/contact_reduction_hydroelastic.py
  • newton/_src/geometry/sdf_hydroelastic.py
  • newton/tests/test_hydroelastic.py

@nvtw

nvtw commented Jul 30, 2026

Copy link
Copy Markdown
Member

Looks good in general. An AI review found the following:

  • P2 — The new documented example fails on CPU-only systems. /C:/tmp/newton-pr3719-review/newton/examples/contacts/
    example_hydroelastic_margin_gap.py:113 unconditionally enters wp.ScopedCapture(), which requires CUDA, and
    self.graph is not initialized if capture fails. The test is CUDA-only, so CI does not cover the advertised default
    invocation. Either remove the verification example as the author already intends, or initialize self.graph = None
    and capture only on CUDA.

  • P2 — skip_all_validations=True is not consistently honored for attached SDFs. The new checks in /C:/tmp/newton-
    pr3719-review/newton/_src/sim/builder.py:11516 execute unconditionally and reject missing/insufficient construction-
    padding metadata. This contradicts both the flag’s “skip all validation checks” contract and the new changelog
    claim. Move these checks into the validation path, or explicitly narrow the documented promise.

  • P3 — The deprecation warning misses explicit use of the old default. /C:/tmp/newton-pr3719-review/newton/_src/
    geometry/sdf_hydroelastic.py:457 warns only when margin_contact_area != 1e-2; therefore
    Config(margin_contact_area=1e-2) uses the deprecated option without warning. Documentation and changelog still
    provide notice, so I would not block solely on this.

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.

# Improve hydroelastic behavior to consider margin + gap

2 participants