Resting contact on a pinned cloth table: elastic cube, stacked sheets, fast impact

Vanilla VBD contact (penalty_dat) against the ALM-DAT contact row (alm_dat, with Coulomb friction and gap-rate damping) on three scenes: a dropped elastic cube, four stacked sheets (python -m newton.examples vbd_rest_on_contact), and five sheets falling freely at 1 m/s with no table. Branch ankac/alm-dat-fable-review, snapshot see git plus the example commit, CPU run, Warp 1.17.0, 2026-10-05.

1. Simplest test: an elastic cube dropped on the pinned cloth plane

z trajectory of the cube on both contact methods

A 4x4x4 cm tetrahedral cube (density 1 g/cm3, 125 g lumped, k_mu = k_lambda = 1e+05 dyn/cm2, no material damping) starts with its bottom face 1 cm above the pinned 16x16 cm cloth table and falls under gravity. Contact settings shared by every scene on this page (margin 0.2 cm, gap 0.1 cm, ke 1e+04, kd 1e+02, mu 0.5), 5 iterations, 10 substeps, 60 fps. Curves are the lowest cube vertex above the table and the largest vertex speed; the dashed line is the margin.

penalty_dat (vanilla)alm_dat
first contact (lowest vertex reaches the margin)0.05 s0.05 s
highest rebound of the lowest vertex after contact [cm]0.2770.274
settled (every vertex below 1 cm/s from then on)0.27 snever
rest height of the lowest vertex, mean over the last second [cm]0.18600.1318
rest jitter of the lowest vertex, std / range over the last second [cm]7.2e-05 / 4.1e-041.3e-03 / 6.2e-03
largest vertex speed over the last second [cm/s]0.2403.277
lowest vertex height over the whole run [cm] (0 is the table)0.16620.1300
detector coverage flag true on every frameTrueTrue

Both paths land and rebound alike. The vanilla path settles within a quarter second and rests one cushion deflection below the margin with sub-micron jitter. The ALM path rests lower because its linear cushion (K = 1e4 dyn/cm per row) is softer than the penalty barrier near the rest point, and it keeps a few cm/s of elastic ringing: the cube has no material damping and the contact damping only acts on closing motion. Before friction and damping were added the ALM cube rested at 0.115 cm with five times the jitter and was still drifting down at 3 s.

2. Stacked sheets: default path versus alm_dat, both with friction 0.5

Left: the shipped behavior, penalty contact with Coulomb friction mu = 0.5, gap-rate damping kd = 100 and per-color Divide-and-Truncate. Right: the ALM contact row with stored division planes, now with Coulomb friction (a tangential multiplier capped at mu times the row's normal force) and gap-rate damping of the closing motion inside the margin. Both stacks land, settle and rest one margin apart; the mean sheet heights agree to 0.002 cm (0.195 / 0.391 / 0.563 / 0.771 versus 0.196 / 0.393 / 0.565 / 0.773 cm), the vertical jitter over the last two seconds is of the same size on both (0.010 to 0.015 versus 0.004 to 0.016 cm), and the largest sheet speed over the last two seconds is 5.4 cm/s on the default path and 8.7 cm/s on alm_dat. No vertex touches the table on either path and the detector's coverage flag stayed true on alm_dat for the whole run.

3. Why friction and damping were needed: the first alm_dat version

Left: the alm_dat row as first rendered, without friction or damping. The slightly rotated sheets slide apart at 20 to 25 cm/s, the first one reaches the 30 cm table edge at 0.85 s and tips over it, and the others follow; once off the table a sheet falls at exactly 76.5 cm/s, the per-substep DAT motion budget. Nothing interpenetrates, it is sliding. Right: the same row with friction and damping.

Friction-matched control: the default path with mu = 0 (left) against the first alm_dat version (right). Both frictionless runs slide off (first sheet vertex below the table surface at 1.12 s versus 0.85 s; peak sliding speed before leaving the table 24.2 versus 27.0 cm/s), so the dispersal was frictionless physics, not a contact-method defect.

Stacked sheets, timeline (max sheet speed [cm/s] / lowest sheet vertex above the table [cm])

timepenalty_dat, mu = 0.5penalty_dat, mu = 0alm_dat, first version (no friction, no damping)alm_dat, mu = 0.5 (friction + damping)
0.25 s3.08 / +0.16110.92 / +0.17816.17 / +0.1944.97 / +0.180
0.50 s3.15 / +0.16815.68 / +0.19523.30 / +0.1943.45 / +0.187
0.75 s1.56 / +0.17619.69 / +0.19425.31 / +0.1924.51 / +0.186
1.00 s2.38 / +0.16918.73 / +0.19443.05 / -4.2053.90 / +0.187
1.50 s3.01 / +0.17187.07 / -16.31376.50 / -35.4075.96 / +0.189
2.00 s4.02 / +0.18682.45 / -56.28776.50 / -73.6576.25 / +0.178
3.00 s1.75 / +0.17288.50 / -136.31676.50 / -150.1585.22 / +0.187
4.00 s3.31 / +0.16484.95 / -216.30976.50 / -226.6596.07 / +0.187
4.98 s3.54 / +0.17681.59 / -295.25076.50 / -301.8868.70 / +0.186
penalty_dat, mu = 0.5penalty_dat, mu = 0alm_dat, first version (no friction, no damping)alm_dat, mu = 0.5 (friction + damping)
first sheet vertex below the table surfacenever1.12 s0.85 snever
largest sheet speed over the last 2 s [cm/s]5.41151.5476.508.70
render wall time (CPU, 300 frames)365 s353 s273 s375 s

Stacked sheets, rest metrics over the last 2 s (mean height [cm] / max vertical std [cm])

Only meaningful for runs whose sheets are still on the table; falling sheets show large negative heights.

sheetpenalty_dat, mu = 0.5penalty_dat, mu = 0alm_dat, first version (no friction, no damping)alm_dat, mu = 0.5 (friction + damping)
sheet 00.1948 / 0.010010.1997 / 0.00006-171.0260 / 44.166610.1958 / 0.00362
sheet 10.3907 / 0.01353-10.2292 / 19.11110-219.9434 / 44.166610.3925 / 0.00683
sheet 20.5631 / 0.01474-210.6022 / 46.40108-220.8856 / 44.166610.5649 / 0.01438
sheet 30.7710 / 0.01446-207.4097 / 45.60289-119.5769 / 44.166610.7731 / 0.01581

4. Free fall: five sheets 2.5 mm apart, all at 100 cm/s, no table

stack velocity against exact free fall, kept fraction, and sheet spacing for the four runs

Five sheets start 0.25 cm apart with the same downward velocity of 100 cm/s (3.3 mm per step at dt = 1/300 s, more than their spacing) and fall under gravity with nothing below them. Every sheet should accelerate at g and the spacing should stay 2.5 mm; there is no physical reason for any contact force, so whatever slows the stack is artificial damping of the contact method. The division planes between neighbouring sheets sit at the midpoints (nobody approaches), 1.25 mm below each sheet, while every sheet wants to move 3.3 mm per step: a single truncation round can admit at most 0.85 x 1.25 mm, a second round re-slides the planes with the committed motion and admits the same fraction of the remainder, and so on. Video at 5x slow motion (one simulation step per frame), static camera; margin 0.2 cm, detection gap 3 cm, friction 0.5, damping 100, 5 iterations.

penalty_dat (vanilla)alm_dat, 1 roundalm_dat, 2 roundsalm_dat, 5 rounds
stack velocity at 0.1 s [cm/s] (free fall -198.1); kept fraction-36.8 (19%)-198.1 (100%)-198.1 (100%)-198.1 (100%)
stack velocity at 0.2 s [cm/s] (free fall -296.2); kept fraction-66.2 (22%)-296.2 (100%)-296.2 (100%)-296.2 (100%)
stack velocity at 0.3 s [cm/s] (free fall -394.3); kept fraction-82.3 (21%)-394.3 (100%)-394.3 (100%)-394.3 (100%)
distance fallen by the lowest sheet [cm] (free fall 74.1)74.374.374.374.3
adjacent spacing over the run, min / max [mm] (start 2.5)-4.58 / 679.472.50 / 2.502.50 / 2.502.50 / 2.50

The final truncation count on its own

the same free fall with the in-iteration plane refresh switched off: 1, 2 and 5 final rounds

Same scene, but the alm_dat runs skip the in-iteration plane refresh (--dat-plane-update-interval 0), so the only truncations are the initial one and the 1, 2 or 5 final rounds (--dat-truncation-iterations). With the default refresh (one round after each of the 5 iterations, the row above) every alm_dat run already resolves the whole stack within the step, which is why the three counts were indistinguishable there.

penalty_dat (vanilla)alm_dat, 1 roundalm_dat, 2 roundsalm_dat, 5 rounds
stack velocity at 0.1 s [cm/s] (free fall -198.1); kept fraction-36.8 (19%)-196.8 (99%)-198.1 (100%)-198.1 (100%)
stack velocity at 0.2 s [cm/s] (free fall -296.2); kept fraction-66.2 (22%)-291.3 (98%)-296.2 (100%)-296.2 (100%)
stack velocity at 0.3 s [cm/s] (free fall -394.3); kept fraction-82.3 (21%)-349.5 (89%)-394.2 (100%)-394.3 (100%)
distance fallen by the lowest sheet [cm] (free fall 74.1)74.374.374.374.3
adjacent spacing over the run, min / max [mm] (start 2.5)-4.58 / 679.47-10.45 / 36.412.28 / 2.722.50 / 2.50

Why the detection gap had to be widened

With the example's default detection gap of 0.1 cm the per-step motion budget 0.5 x 0.85 x (0.2 + 0.1) cm = 1.275 mm caps the committed speed at 38.2 cm/s on both paths, so every run fell at that speed and the truncation rounds could not matter (alm_dat kept the spacing at exactly 2.50 mm; vanilla spread it to 2.47 to 3.97 mm). The variant above widens the gap to 3 cm so the budget (408 cm/s) does not bind during the 0.3 s shown and only the plane truncation acts.

the same free fall with the default 0.1 cm detection gap: every run capped at the motion budget
penalty_dat (vanilla)alm_dat, 1 roundalm_dat, 2 roundsalm_dat, 5 rounds
stack velocity at 0.25 s [cm/s] (free fall -328.9); kept fraction-39.2 (12%)-38.2 (12%)-38.2 (12%)-38.2 (12%)
stack velocity at 0.5 s [cm/s] (free fall -574.1); kept fraction-39.1 (7%)-38.3 (7%)-38.3 (7%)-38.3 (7%)
stack velocity at 1 s [cm/s] (free fall -1064.7); kept fraction-39.1 (4%)-38.2 (4%)-38.2 (4%)-38.2 (4%)
distance fallen by the lowest sheet [cm] (free fall 1227.6)58.356.756.756.7
adjacent spacing over the run, min / max [mm] (start 2.5)2.47 / 3.972.50 / 2.502.50 / 2.502.50 / 2.50

5. Folded sheets: four sheets folded in half, stacked on the table

Result. With the fold construction of the original grid_on_table script, the vanilla path rests and the ALM-DAT path does not: its two middle sheets are squeezed out of the stack sideways within 0.1 s, crumple, and are flung off the table edge at the motion-budget speed. The first sheet vertex below the table surface (0.41 s, sheet 2) is 15.8 cm from the centre, past the 15 cm table edge, so the sheets left the table rather than crossing it. The detector never overflowed. Whether the middle sheets crossed a neighbouring sheet while being squeezed out was not checked. With a clean fold along a mesh column (5b) both paths settle, sheet means within 0.15 cm of each other.

5a. Fold of the original script

Each sheet is folded by reflecting every vertex right of the sheet's world-x mid-line over it and lifting it by a 0.3 cm fold gap (the construction of the original script). Because every sheet is rotated by up to 0.1 rad, the crease cuts across mesh columns: a vertex just right of the line lands just left of it, 0.3 cm up, so the triangles the crease passes through start crushed to a fraction of their rest size. 4 folded sheets start 0.8 cm apart above the pinned table; same contact settings as section 2, 5 s.

penalty_dat (vanilla), mu 0.5alm_dat, mu 0.5 (friction + damping)
sheet 0: mean height over the last 2 s [cm]0.2700.294
sheet 1: mean height over the last 2 s [cm]0.626-209.617
sheet 2: mean height over the last 2 s [cm]0.908-222.306
sheet 3: mean height over the last 2 s [cm]1.3280.632
largest sheet speed over the last 2 s [cm/s]25.1176.51
lowest sheet vertex over the run [cm] (0 is the table surface)+0.094-304.004
detector coverage flag at the endn/aTrue

5b. The same stack with a clean fold (crease along a mesh column)

Same sheets, same perturbations, same contact settings; the only change is that each sheet is reflected in its own frame, so the crease follows the middle mesh column and no triangle is crushed. Both paths settle to stable heights; on both, a few vertices keep jittering (largest sheet-vertex speed 21 to 27 cm/s over the last 2 s), as on the vanilla run of 5a.

penalty_dat (vanilla), mu 0.5alm_dat, mu 0.5 (friction + damping)
sheet 0: mean height over the last 2 s [cm]0.2660.279
sheet 1: mean height over the last 2 s [cm]0.6250.656
sheet 2: mean height over the last 2 s [cm]0.9330.977
sheet 3: mean height over the last 2 s [cm]1.2811.428
largest sheet speed over the last 2 s [cm/s]21.4026.79
lowest sheet vertex over the run [cm] (0 is the table surface)+0.076+0.148
detector coverage flag at the endn/aTrue

What goes wrong, from a per-substep probe

largest sheet speed per frame, folded stacks

Verified cause: the motion budget locks fast vertices at the cap speed. ALM-DAT commits at most one budget of displacement per substep: 0.5 x 0.85 x the 0.3 cm detection radius = 0.1275 cm, which at 600 substeps per second is 76.5 cm/s. The crushed crease triangles push 52 vertices further than that in the very first substep. The commit kernel clips each of them to the budget, so their velocity becomes exactly 76.5 cm/s. In the next substep the inertial target of such a vertex is again exactly one budget away. The step-start truncation spends the whole budget on it, and the commit rule used + |step| <= budget then rejects every later correction in that substep: elastic force, contact row, damping and gravity cannot change its path. Only a stored plane can shorten the step. The vertex therefore stays at the cap until it hits a plane; after the plane releases it, the still-crushed crease asks for more than one budget again and it locks again in the new direction. In the default run 50 to 77 vertices are clamped in every one of the 180 probed substeps, and the largest sheet speed reads 76.50 cm/s in all 300 rendered frames.

The vanilla path uses the same 0.1275 cm constant, but twice per substep and as a plain magnitude clamp. Its self-contact schedule is PRE_POST_INIT: it detects at the substep start, clamps the inertial step to 0.1275 cm from there, detects again at the inertial pose and resets the reference, then clamps the VBD corrections to 0.1275 cm from that second pose. So a vanilla vertex may move up to 0.255 cm per substep (153 cm/s), and because the clamp rescales the whole correction vector, a correction that points back against the inertial motion is always admitted: forces can stop a fast vertex inside the substep. ALM-DAT detects once per substep (PRE_INIT, required by the implementation and by the algorithm's single InitialSphericalQuery(x_start)), so one 0.1275 cm budget covers inertia and corrections together, and its path-length rule rejects backward corrections once the budget is spent. The vanilla path relaxes the same crease at up to 129 cm/s, under its 153 cm/s limit, and is quiet after 0.1 s.

Tests. Raising the cap above the crease's natural relaxation speed removes the failure: with a 0.4 cm detection gap (budget 0.255 cm, cap 153 cm/s) 15 vertices are clamped in the first substep and none afterwards; with 40 substeps (cap 306 cm/s) 7 vertex-substeps are clamped in the first three substeps and none afterwards; both stacks settle. Setting every multiplier to zero after each substep changes nothing, so the multiplier memory across substeps is not the cause. A 100x softer ALM metric still fails but calms down later (no clamped vertices by 0.3 s), so the within-substep multiplier overshoot amplifies the loop but does not start it. The clean fold never clamps a vertex. The re-lock loop between plane releases is my reading of the probe; the per-substep lock itself is in the kernel.

probe (CPU, 0.2 to 0.5 s)speed cap [cm/s]largest speed at substep 1 [cm/s] vertices clamped at the budget, substep 1clamped vertex-substeps after 0.1 slargest speed at the end [cm/s] sheets swap order
fold of the original script, penalty_datnone78.1n/an/a6.4 at 0.50 snone
fold of the original script, alm_dat (default settings)76.576.5529627 in 120 substeps76.5 at 0.30 s0.13 s
same, every multiplier set to zero after each substep76.576.5526701 in 120 substeps76.5 at 0.30 s0.12 s
same, ALM metric 100x softer and no stiffness floor76.576.55278 in 120 substeps52.9 at 0.30 s0.18 s
same, detection gap 0.4 cm (budget 0.255 cm)153.0153.0150 in 120 substeps16.4 at 0.30 snone
same, 40 substeps instead of 10 (same budget, 4x shorter substep)306.0250.600 in 240 substeps5.2 at 0.20 snone
clean fold along the mesh column, alm_dat76.516.200 in 120 substeps14.3 at 0.30 snone
no fold (section 2 geometry), alm_dat76.51.6n/an/a6.9 at 0.50 snone

Scene

Four 10x10 cm cloth sheets (1 cm cells, 0.02 g/cm2, tri_ke = tri_ka = 1e4, edge_ke = 5, absolute damping tri_kd 0.015 and edge_kd 0.05, contact ke 1e4 and kd 100) start 0.5 cm apart above a pinned 30x30 cm cloth sheet that acts as the table, each shifted by up to 0.2 cm and rotated by up to 0.1 rad. The table is cloth with zero mass, so sheet-on-table and sheet-on-sheet contacts both run through the soft self-contact pipeline and the contact method is the only difference between runs. Self-contact margin 0.2 cm, detection gap 0.1 cm, the table itself has no thickness. 60 fps, 10 substeps, 5 VBD iterations, 300 frames.

Old path (penalty_dat): penalty force along the closest-point direction with friction and damping; the accumulated displacement is clipped against freshly built division planes after every color. New path (alm_dat): one compliant ALM contact row per detected pair with its multiplier carried across steps, division planes stored and rebuilt only at certified anchors, and the committed state is the truncated anchor. The ALM row carries gap-rate damping and Coulomb friction as a capped tangential multiplier.

How it was produced

python -m newton.examples vbd_rest_on_contact
python -m newton.examples vbd_rest_on_contact --contact-method alm_dat
python -m newton.examples vbd_rest_on_contact --contact-mu 0.0
notes/vbd-rest-on-contact/render_comparison.py render --method ... --frames 300   (headless ViewerGL under Xvfb, CPU)
notes/vbd-rest-on-contact/render_comparison.py compose --left penalty_dat --right alm_dat --name comparison
notes/vbd-rest-on-contact/render_comparison.py compose --left penalty_dat_mu0 --right alm_dat --name comparison_frictionless
notes/vbd-rest-on-contact/render_comparison.py render --fold --fold-gap 0.3 --layer-spacing 0.8 --first-layer-height 0.3 --contact-buffer-scale 2 --method ... --tag fold_...
notes/vbd-rest-on-contact/render_comparison.py render --fold --fold-clean ... --tag foldc_...
notes/vbd-rest-on-contact/fold_probe.py --method alm_dat --fold-gap 0.3 --layer-spacing 0.8 --frames 30 [--fold-clean | --no-fold]
notes/vbd-rest-on-contact/plot_fold_speed.py