One vertex P of the upper sheet, two triangles of the lower sheet seen edge-on (the one below P and its neighbour), the stored division plane of each pair, and the same motion of P in both panels. Left: the force along the plane normal n. Right: the force along the closest-point direction d. Drag the slider or press play. 中文:左边是沿分割平面法线 n 推,右边是沿最近点方向 d 推,同一个顶点同一段运动。
The words
P
the vertex we look at (upper sheet).
witness w
the point of the triangle closest to P. Nothing more. The gap of the pair is measured between P and w. 中文:witness 就是三角形上离 P 最近的那个点。
division plane, normal n
the certified separator stored for the pair: the triangle is entirely on one side, P on the other. The truncation keeps the anchor from crossing it. n is fixed between two commits.
d
the unit vector from w to P, the closest-point direction. It changes whenever P or the triangle moves.
gap along n
n · (P − w): how far P is from w measured along n (the design's constraint).
gap along d
|P − w|: the plain distance (what I had switched to).
t = 0.00
Why the direction is not a free choice. The ALM row carries one multiplier λ for one constraint C(x) ≥ 0, and its force is λ times the gradient of that constraint. With the design's constraint C = n·(P − w) the gradient on P is n, so the force is along the plane normal. With C = |P − w| the gradient is d, so the force is along the closest-point direction. Picking the direction means picking the constraint. The division plane is what the truncation certifies, so the row that belongs to it pushes along n. That is what the code does again since dd26a7264.
Why the planes are tilted, and why that is right
The ground is made of triangles. A vertex P within the detection reach of several triangles has one contact pair with each of them: with the triangle directly below it, and with its neighbours, which P is not above but beside. Each pair gets its own division plane between P and that triangle.
The pair below gives a level plane, normal straight up. A neighbour pair gives a tilted plane: the closest point of the neighbour to P is on its edge or corner, the separator between P and that triangle leans, and the force of that pair leans with it. That is the correct contact force against the neighbour; the penalty path has exactly the same tilted forces, one per triangle within reach. On a level sheet the sideways parts of the neighbours cancel; the small asymmetry of the triangulation leaves a few millimetres of settling, which both runs show (0.6 cm).
Where n and d really differ. The planes are rebuilt at the anchor at every commit, so right after a commit n = d for every pair. Between commits n is fixed while d follows P every sweep. The lag is tiny for a resting patch and grows with the motion per commit. The other difference is the gap: n·(P − w) along the plane normal against |P − w| along the line to the witness.
When P leaves the contact radius. The force must go to zero, and it does, with two small tails. Between commits the gap is measured along the stored n, so when P slides sideways past a triangle's corner the plane gap n·(P − w) stays small while the real distance grows, and that row keeps pushing until the next commit rebuilds its plane. With the code default, a commit every sweep, that is one sweep; the animation's "rarely" setting exaggerates it so it can be seen, and the "every sweep" setting shows it vanish. The second tail is the ALM memory: the row releases when gap − λ/ρ ≥ R, so a multiplier carried from the last step fades over the distance λ/ρ beyond the margin instead of switching off exactly at R. The penalty path has neither tail.
The October story. The commit of 2026-10-05 blamed the drift on tilted planes and moved the force to d. The ablation on this page's companion shows the drift was the small penalty metric, not the direction, and that on the current separator the stored normals are the closest-point directions anyway. The tilted neighbour planes were never the problem.