@woosh/meep-engine 3.25.0 → 3.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +59 -0
  3. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -1
  4. package/src/core/geom/3d/shape/HeightMapShape3D.js +285 -0
  5. package/src/core/geom/3d/shape/PosedShape3D.d.ts.map +1 -1
  6. package/src/core/geom/3d/shape/PosedShape3D.js +57 -0
  7. package/src/core/geom/3d/shape/TransformedShape3D.d.ts.map +1 -1
  8. package/src/core/geom/3d/shape/TransformedShape3D.js +102 -0
  9. package/src/core/geom/3d/shape/UnionShape3D.d.ts.map +1 -1
  10. package/src/core/geom/3d/shape/UnionShape3D.js +64 -0
  11. package/src/engine/physics/cloth/MEASUREMENTS.md +582 -0
  12. package/src/engine/physics/cloth/PLAN.md +330 -49
  13. package/src/engine/physics/cloth/collider/ClothColliderIndex.d.ts +84 -17
  14. package/src/engine/physics/cloth/collider/ClothColliderIndex.d.ts.map +1 -1
  15. package/src/engine/physics/cloth/collider/ClothColliderIndex.js +451 -42
  16. package/src/engine/physics/cloth/ecs/ClothInstance.d.ts +65 -7
  17. package/src/engine/physics/cloth/ecs/ClothInstance.d.ts.map +1 -1
  18. package/src/engine/physics/cloth/ecs/ClothInstance.js +72 -8
  19. package/src/engine/physics/cloth/ecs/ClothSystem.d.ts +25 -4
  20. package/src/engine/physics/cloth/ecs/ClothSystem.d.ts.map +1 -1
  21. package/src/engine/physics/cloth/ecs/ClothSystem.js +75 -8
  22. package/src/engine/physics/cloth/ecs/ClothWorld.d.ts +41 -4
  23. package/src/engine/physics/cloth/ecs/ClothWorld.d.ts.map +1 -1
  24. package/src/engine/physics/cloth/ecs/ClothWorld.js +207 -63
  25. package/src/engine/physics/cloth/ecs/WorkerClothSystem.d.ts +10 -0
  26. package/src/engine/physics/cloth/ecs/WorkerClothSystem.d.ts.map +1 -1
  27. package/src/engine/physics/cloth/ecs/WorkerClothSystem.js +17 -1
  28. package/src/engine/physics/cloth/ecs/cloth_dynamics_map.d.ts +27 -3
  29. package/src/engine/physics/cloth/ecs/cloth_dynamics_map.d.ts.map +1 -1
  30. package/src/engine/physics/cloth/ecs/cloth_dynamics_map.js +28 -3
  31. package/src/engine/physics/cloth/ecs/cloth_gather_colliders.d.ts +75 -10
  32. package/src/engine/physics/cloth/ecs/cloth_gather_colliders.d.ts.map +1 -1
  33. package/src/engine/physics/cloth/ecs/cloth_gather_colliders.js +131 -12
  34. package/src/engine/physics/cloth/ecs/cloth_seed_rig.d.ts +19 -0
  35. package/src/engine/physics/cloth/ecs/cloth_seed_rig.d.ts.map +1 -1
  36. package/src/engine/physics/cloth/ecs/cloth_seed_rig.js +249 -201
  37. package/src/engine/physics/cloth/ecs/cloth_seed_subtree.d.ts +21 -0
  38. package/src/engine/physics/cloth/ecs/cloth_seed_subtree.d.ts.map +1 -1
  39. package/src/engine/physics/cloth/ecs/cloth_seed_subtree.js +62 -1
  40. package/src/engine/physics/cloth/ecs/cloth_write_back.d.ts.map +1 -1
  41. package/src/engine/physics/cloth/ecs/cloth_write_back.js +18 -10
  42. package/src/engine/physics/cloth/playground/collide_main.js +1 -1
  43. package/src/engine/physics/cloth/solver/cloth_contact_find.d.ts +29 -8
  44. package/src/engine/physics/cloth/solver/cloth_contact_find.d.ts.map +1 -1
  45. package/src/engine/physics/cloth/solver/cloth_contact_find.js +37 -9
  46. package/src/engine/physics/cloth/solver/cloth_solver_constants.d.ts +12 -2
  47. package/src/engine/physics/cloth/solver/cloth_solver_constants.d.ts.map +1 -1
  48. package/src/engine/physics/cloth/solver/cloth_solver_constants.js +12 -2
  49. package/src/engine/physics/cloth/solver/cloth_step_instance.js +1 -1
  50. package/src/engine/physics/cloth/solver/constraint/cloth_backstop_row.d.ts +9 -2
  51. package/src/engine/physics/cloth/solver/constraint/cloth_backstop_row.d.ts.map +1 -1
  52. package/src/engine/physics/cloth/solver/constraint/cloth_backstop_row.js +9 -2
  53. package/src/engine/physics/cloth/wind/AbstractClothWind.d.ts +23 -9
  54. package/src/engine/physics/cloth/wind/AbstractClothWind.d.ts.map +1 -1
  55. package/src/engine/physics/cloth/wind/AbstractClothWind.js +23 -9
  56. package/src/engine/physics/cloth/wind/ClothAmbientWind.d.ts +7 -5
  57. package/src/engine/physics/cloth/wind/ClothAmbientWind.d.ts.map +1 -1
  58. package/src/engine/physics/cloth/wind/ClothAmbientWind.js +12 -9
  59. package/src/engine/physics/cloth/wind/ClothFluidWind.d.ts.map +1 -1
  60. package/src/engine/physics/cloth/wind/ClothFluidWind.js +5 -2
  61. package/src/engine/physics/cloth/wind/cloth_sample_air.d.ts +7 -2
  62. package/src/engine/physics/cloth/wind/cloth_sample_air.d.ts.map +1 -1
  63. package/src/engine/physics/cloth/wind/cloth_sample_air.js +12 -5
@@ -7,6 +7,7 @@ import { min2 } from "../../../math/min2.js";
7
7
  import { seededRandom } from "../../../math/random/seededRandom.js";
8
8
  import { number_compare_ascending } from "../../../primitives/numbers/number_compare_ascending.js";
9
9
  import { v3_dot } from "../../vec3/v3_dot.js";
10
+ import { Ray3 } from "../ray/Ray3.js";
10
11
  import { AbstractShape3D } from "./AbstractShape3D.js";
11
12
  import { compute_signed_distance_gradient_by_sampling } from "./util/compute_signed_distance_gradient_by_sampling.js";
12
13
 
@@ -326,6 +327,69 @@ export class UnionShape3D extends AbstractShape3D {
326
327
 
327
328
  }
328
329
 
330
+ /**
331
+ * Nearest hit across the children, which all share the union's own frame — so, unlike the other
332
+ * two wrappers, there is no ray to re-frame and no answer to map back.
333
+ *
334
+ * Each child is handed the best distance so far as its `tMax`, so a child can only report a hit by
335
+ * beating every child before it. That is what makes it safe to let the children write straight into
336
+ * `hit`: a child that writes is a child that won, and a child that misses leaves `hit` untouched by
337
+ * the family convention, so the standing answer survives it.
338
+ *
339
+ * The distance to narrow on is recovered from the point the child wrote, as
340
+ * `(p − origin)·direction` — the shape-level API hands back a point where the narrowphase's
341
+ * `refine_ray_union` has the distance already. Two children closer together than a float32 ulp
342
+ * of the coordinates involved are therefore a tie, settled by whichever is listed last.
343
+ *
344
+ * That ulp is the API's, not this method's: a child writes `origin + direction · t` into a float32
345
+ * `hit` before anything here reads it, so the resolution is already spent by the time a LONE shape
346
+ * answers. Measured against the narrowphase over 4,000 rays per scale, this union's error tracks a
347
+ * lone sphere's within a factor of two everywhere — 3e-6 near the origin, 1e-3 at 1e4 out, 0.1 at
348
+ * 1e6, where one float32 ulp IS 0.06 world units. A compound collider that far out wants a float64
349
+ * ray, which is what the narrowphase path already gives it; it does not want a different union.
350
+ *
351
+ * DIVERGENCE from `refine_ray_union`: that one carries a sentinel for a shape it cannot answer and
352
+ * poisons the entire union with it, because reporting the nearest of the children it COULD answer
353
+ * would be a hit confidently too far. There is no sentinel in this API — a child that does not
354
+ * implement `raycast` throws from {@link AbstractShape3D#raycast} — but the effect is the one that
355
+ * matters: every child is visited unconditionally, so such a child is never quietly skipped, and
356
+ * the throw leaves the union with no answer rather than a partial one.
357
+ *
358
+ * @param {Float32Array|number[]} hit out: [px, py, pz, nx, ny, nz]
359
+ * @param {Ray3} ray union-frame ray (origin in [0..2], unit direction in [3..5], tMax in [6])
360
+ * @returns {boolean} whether anything was hit
361
+ */
362
+ raycast(hit, ray) {
363
+ const children = this.children;
364
+ const child_count = children.length;
365
+
366
+ const ox = ray[0], oy = ray[1], oz = ray[2];
367
+ const dx = ray[3], dy = ray[4], dz = ray[5];
368
+
369
+ // Allocated per call rather than kept as a module scratch: a union may hold another wrapper,
370
+ // and every level of that recursion needs its own narrowing ray alive across the loop below.
371
+ const child_ray = new Ray3();
372
+
373
+ child_ray[0] = ox; child_ray[1] = oy; child_ray[2] = oz;
374
+ child_ray[3] = dx; child_ray[4] = dy; child_ray[5] = dz;
375
+ child_ray[6] = ray[6];
376
+
377
+ let found = false;
378
+
379
+ for (let i = 0; i < child_count; i++) {
380
+ if (!children[i].raycast(hit, child_ray)) {
381
+ continue;
382
+ }
383
+
384
+ // The direction is unit, so the hit's projection onto it IS the distance travelled.
385
+ child_ray[6] = (hit[0] - ox) * dx + (hit[1] - oy) * dy + (hit[2] - oz) * dz;
386
+
387
+ found = true;
388
+ }
389
+
390
+ return found;
391
+ }
392
+
329
393
  /**
330
394
  *
331
395
  * @param {UnionShape3D} other
@@ -3096,6 +3096,14 @@ construction rather than by arrangement. It sits **above** the sleep tests
3096
3096
  deliberately: a world whose every cloth is asleep still has weather, and §10's
3097
3097
  "sleep wakes on a wind change" is the whole reason it needs to.
3098
3098
 
3099
+ > **Corrected by §57.** Everything above is right about a scene with *one* cloth
3100
+ > in it, which is the scene this was measured in. A second cloth registering
3101
+ > while the first is running spends its registration ticks in a world whose clock
3102
+ > is moving, so the world clock — however it counts — hands it a different gust
3103
+ > under the two systems. The gust now reads the asking cloth's own age and
3104
+ > `ClothWorld#time` keeps only the job below: telling a source that reads the
3105
+ > scene when to walk it.
3106
+
3099
3107
  ### The number
3100
3108
 
3101
3109
  ```
@@ -3287,3 +3295,577 @@ stands and M6 adds one clause to it: re-measure the absolute column on a quiet
3287
3295
  machine, take a paired reading even then, and if the answer is that the library
3288
3296
  is genuinely at its ceiling, the lever to spend is the one §17 prices rather than
3289
3297
  the feature that happened to be measured last.
3298
+
3299
+ ---
3300
+
3301
+ # Cloth — post-M6 measurements
3302
+
3303
+ > Written 2026-09-13, against the fixes listed in §17's post-M6 entry.
3304
+ > Everything above this line is M0's through M6's and is unchanged, except where
3305
+ > a section below is named *inside* the one it corrects.
3306
+ >
3307
+ > The scripts are the ones the milestones already carry; where a number here
3308
+ > came from something else, the section says what.
3309
+
3310
+ ---
3311
+
3312
+ ## 53. A cloth resting on a collider never slept, and the speed was not the thing to read
3313
+
3314
+ §7 P5's gate is free-hanging chains, and every library fabric passes it. Put the
3315
+ same chain on a floor and four of the seven never sleep at all.
3316
+
3317
+ ```
3318
+ 8-link rope, 10 cm links, 2 % of lateral lean, dropped for 2,400 steps
3319
+ "-1" is "still awake when the run ended"
3320
+
3321
+ entry free floor mu=0 floor mu=0.5 sphere mu=0
3322
+ SILK 386 1029 398 631
3323
+ COTTON 236 -1 -1 362
3324
+ DENIM 101 198 164 220
3325
+ LEATHER 139 192 -1 2312
3326
+ ROPE 283 911 -1 427
3327
+ CHAIN 144 419 245 293
3328
+ HAIR 143 271 2129 280
3329
+ ```
3330
+
3331
+ A cloth that sleeps in 283 steps hanging free and never sleeps lying on the
3332
+ ground is not a cloth that is behaving differently; it is a **test** that is
3333
+ reading the wrong quantity.
3334
+
3335
+ ### What is actually happening
3336
+
3337
+ Traced on cotton on a frictionless floor, six hundred steps after it has come to
3338
+ rest, the configuration is a clean **period-4 limit cycle**:
3339
+
3340
+ ```
3341
+ step peak speed peak travel contact penalty, four live slots
3342
+ 0 6.806e-3 5.801e-5 628.4 1544.9 1553.7 561.1
3343
+ 1 9.568e-3 5.328e-5 628.9 1545.1 1554.0 561.6
3344
+ 2 1.443e-2 6.116e-5 629.3 1545.7 1554.3 562.1
3345
+ 3 1.229e-2 1.061e-4 629.8 1546.3 1554.8 562.6
3346
+ 4 6.806e-3 5.801e-5 630.2 1546.6 1555.3 563.2 <- repeats
3347
+ ```
3348
+
3349
+ The cloth travels **a tenth of a millimetre a step**, which nobody can see, at a
3350
+ peak particle speed of **1.5e-2 m/s**, which is **eighteen times** the sleep
3351
+ threshold of 8.0e-4 m/s. Both numbers are correct and they disagree about
3352
+ whether the cloth is at rest, because a limit cycle has bounded displacement and
3353
+ un-bounded-below speed.
3354
+
3355
+ The penalty column says where the cycle gets its energy: it climbs
3356
+ monotonically — `k + β·|C|` in `cloth_contact_dual_update` — and **never comes
3357
+ back down**, because `γ = 1` and `clamp_band`'s floor is the contact's own
3358
+ `0.25·(m/h²)`. Two of the four slots are already at the ceiling of `8·(m/h²)`,
3359
+ which is exactly the ratio §6 measured a row adding energy above.
3360
+
3361
+ ### Two candidates, and only the second survives
3362
+
3363
+ The *friction re-anchor* — `cloth_contact_row.js` moving the anchor to the
3364
+ particle whenever the row's force clamps out — is **not** it, and no experiment
3365
+ is needed to say so: cotton fails on a floor whose friction is **zero**, where
3366
+ `friction <= 0` zeroes the friction multiplier before the anchor is ever read.
3367
+ It is ruled out by the μ = 0 column of the table above.
3368
+
3369
+ The *penalty ramp* is it, and the fix for it is not in scope: `γ` is
3370
+ unmeasured-by-decision (§5.2), a `γ < 1` would have to be re-measured against
3371
+ every hard row in the system, and the motion it sustains is a tenth of a
3372
+ millimetre. **The defect worth fixing is that the sleep test disagrees with the
3373
+ plan.** §7 P5 has said "maximum particle displacement over a window" since M0 and
3374
+ M1 implemented an instantaneous speed.
3375
+
3376
+ ### The rule, and the threshold
3377
+
3378
+ Displacement from an anchor taken when the window opened, re-anchored the moment
3379
+ any particle leaves the threshold; `CLOTH_SLEEP_STEPS` consecutive steps inside
3380
+ it is sleep. Swept over the ratio, every entry × every scene, "steps to sleep":
3381
+
3382
+ ```
3383
+ ratio of reach worst where any awake at 2,400?
3384
+ 1e-3 (as speed) -1 COTTON on a floor 4 of 28
3385
+ 1e-3 872 SILK on a frictionless floor no
3386
+ 5e-4 961 SILK on a frictionless floor no
3387
+ 2e-4 965 SILK on a frictionless floor no
3388
+ 1e-4 1773 COTTON on a frictionless floor no
3389
+ ```
3390
+
3391
+ **5e-4 is the value**, and it is chosen to be *no looser* than the speed test it
3392
+ replaces rather than to be the fastest: over a 30-step window at the engine's
3393
+ 60 Hz fixed step, half a thousandth of a reach per window is the same millimetre
3394
+ a second per step that `CLOTH_SLEEP_SPEED_RATIO` named. 1e-3 would sleep sooner
3395
+ and would be a real relaxation of the gate; 1e-4 costs nearly twice the worst
3396
+ case and buys nothing visible.
3397
+
3398
+ With it in place, the table at the top of this section reads:
3399
+
3400
+ ```
3401
+ entry free floor mu=0 floor mu=0.5 sphere mu=0
3402
+ SILK 325 961 309 573
3403
+ COTTON 180 361 277 325
3404
+ DENIM 92 195 128 194
3405
+ LEATHER 127 175 109 151
3406
+ ROPE 235 840 941 393
3407
+ CHAIN 143 425 201 261
3408
+ HAIR 132 260 177 246
3409
+ ```
3410
+
3411
+ Nothing is awake, the worst case is 961 steps and the free-hanging column has
3412
+ not got worse — it is uniformly *faster*, because a chain settling through a
3413
+ swing covers less ground per window than its instantaneous speed suggested.
3414
+
3415
+ ### What it costs
3416
+
3417
+ One compare-only pass over the particle positions per cloth per step, on the
3418
+ main thread, in `ClothWorld#finish`, with no square root — the displacements are
3419
+ compared squared — and one `Float32Array` of `3·particle_count` per instance. It
3420
+ does not enter the step, so `cloth_library_bench.mjs` cannot see it and does not:
3421
+ the library column is unchanged across this commit.
3422
+
3423
+ *Reproduce:* `cloth_pathology.spec.js`, the two P5 sleep tests. The table above
3424
+ is the same scenario driven from a script rather than through vitest.
3425
+
3426
+ ---
3427
+
3428
+ ## 54. A collider that does not move is not a collider a cloth cannot hit
3429
+
3430
+ §20 measured the discovery reach missing a **moving collider** and fixed it by
3431
+ folding the collider's travel into the reach. The mirror case was argued away in
3432
+ the same docblock rather than measured: a fixed margin of `2·(thickness +
3433
+ inflation)` "covers half a metre a second of approach", and anything faster is
3434
+ "one the velocity clamp of §7 P1 is already the bound on".
3435
+
3436
+ **It is not.** That clamp is `4 · shortest_row · substeps · iterations / dt`,
3437
+ which for a garment's rows at 2 × 4 is about **154 m/s** — three orders of
3438
+ magnitude above the half metre a second the margin actually covers. Everything
3439
+ between is uncovered, and a character walks through it.
3440
+
3441
+ ### The reproduction
3442
+
3443
+ One particle at 8 mm thickness, thrown along +X at a **static** 3 cm capsule,
3444
+ no gravity so that nothing but the contact can change its velocity, 2 substeps ×
3445
+ 4 iterations. "Warned" is whether a row existed by the time the particle was
3446
+ first sampled inside the contact offset; "far side" is whether it ended past the
3447
+ capsule rather than bounced off it.
3448
+
3449
+ ```
3450
+ with |v|·dt in the reach as M2 shipped
3451
+ speed warned far side x_final warned far side x_final
3452
+ 1 m/s yes no -0.672 no no -1.464
3453
+ 2 yes no -1.268 no no -2.058
3454
+ 3 yes no -1.371 no YES +13.315
3455
+ 4 yes no -2.220 no YES +14.294
3456
+ 5 yes no -0.532 no YES +25.516
3457
+ 6 yes no -1.287 no YES +19.800
3458
+ 8 yes YES +26.285 — YES +26.467
3459
+ 10 yes YES +31.285 no YES +33.133
3460
+ ```
3461
+
3462
+ Two separate readings. **`warned` is never true without the term, at any
3463
+ speed** — the first row a particle ever gets is the one that finds it already
3464
+ inside, which is the opposite of what a speculative contact is for. And from
3465
+ **3 m/s** the particle is not stopped at all: the row the next step finally
3466
+ creates is evaluated where the particle now is, which is on the far side, and
3467
+ its normal points away from the collider, so the first thing the contact does is
3468
+ push it the rest of the way out. §7 P4(a) chose orientation over history
3469
+ deliberately and this is the price of that choice when discovery is late.
3470
+
3471
+ ### What the fix does and does not buy
3472
+
3473
+ Swept, in half-metre steps from 1 to 12 m/s, whether the particle is stopped:
3474
+
3475
+ ```
3476
+ substeps stopped up to through from
3477
+ 2 6.0 m/s 6.5 m/s
3478
+ 4 12.0 m/s — nothing in range
3479
+ ```
3480
+
3481
+ A clean threshold, and it is `substeps` rather than discovery: a row that exists
3482
+ is still evaluated at substep positions, so a particle that crosses a whole
3483
+ collider inside one substep has a row that never sees it touching. §7 P3 has
3484
+ always said "more substeps in the affected `ClothDynamics`"; this is the first
3485
+ measurement of where that lands. Discovery being right is necessary and not
3486
+ sufficient, and the two are now separable — unswept at four substeps still
3487
+ tunnels at 8 m/s.
3488
+
3489
+ ### What it costs
3490
+
3491
+ Interleaved A/B in one process, alternating which side runs first, `dt` against
3492
+ zero — zero being exactly the reach M2 shipped:
3493
+
3494
+ ```
3495
+ 1,024 particles, one ground plane, best of eight interleaved pairs
3496
+
3497
+ approach with travel as M2 shipped ratio
3498
+ at rest 45.16 ns 45.35 ns 0.996
3499
+ falling at 3 m/s 92.32 ns 55.88 ns 1.652
3500
+ ```
3501
+
3502
+ **At rest it is free** — the square root disappears into the measurement's own
3503
+ noise. Moving, it is 36 ns a particle a step, and that is not the arithmetic: it
3504
+ is the records a wider reach lets past the AABB reject, which is the feature
3505
+ rather than its overhead. Against the 4.5 ms §23 measures for this workload it
3506
+ is **0.8 % of a step**, and `cloth_library_bench.mjs` cannot see it at all,
3507
+ because the bench drives `cloth_step` with no collider table — the library
3508
+ column is unchanged across this commit, which is the regression check rather
3509
+ than the measurement.
3510
+
3511
+ ### And it moved a sleep number, which is a coupling worth naming
3512
+
3513
+ `cloth_contact_dual_update` ramps the penalty by `β·|C|` on the **unsigned**
3514
+ residual, so a row that is not touching ramps too. That was nearly invisible
3515
+ while rows only appeared within 16 mm; with the reach carrying a falling cloth's
3516
+ travel, rows now appear tens of centimetres out and reach `k_max` long before
3517
+ contact, and `γ = 1` never brings them down again. §53's table moves with it:
3518
+ most entries settle *sooner*, and hair on a floor with friction goes from 177
3519
+ steps to **2,202** — a creep at 1.6 times the sleep window's threshold rather
3520
+ than a cloth that has stopped.
3521
+
3522
+ Making the ramp one-sided is the obvious repair and **the measurement says not
3523
+ to**:
3524
+
3525
+ ```
3526
+ stopped up to, half-metre steps from 1 to 12 m/s
3527
+
3528
+ 2 substeps 4 substeps
3529
+ ramp on |C| 6.0 m/s 12.0 m/s, no holes
3530
+ ramp on max(C, 0) 6.0 m/s, hole at 5 9.5 m/s, holes at 10–11.5
3531
+ ```
3532
+
3533
+ The pre-stiffening of a speculative row is what gives a fast arrival something
3534
+ solid to hit; removing it trades a visible failure — cloth through a character —
3535
+ for an invisible one — a hair rope taking thirty-seven seconds to stop creeping
3536
+ instead of three. So the ramp is left alone and the coupling is recorded in §18.
3537
+
3538
+ ---
3539
+
3540
+ ## 55. A collider arriving and a collider retiring, and what each one was not told
3541
+
3542
+ Two defects at the two ends of a collider's life, both of them a *set* question
3543
+ answered with a *pose*.
3544
+
3545
+ ### Arriving
3546
+
3547
+ §7 P5's wake test compares each overlapping collider's previous pose with its
3548
+ current one. `ClothColliderSystem#link` calls `placePose`, which sets both — on
3549
+ purpose, so a new collider's first step sweeps nothing, because it did not move,
3550
+ it appeared. The two facts together say a collider that appears inside a
3551
+ sleeping cloth has not moved.
3552
+
3553
+ ```
3554
+ 6-link rope, 10 cm links, left to settle: asleep at step 236
3555
+ a sphere of radius 0.2 spawned around its lower half, never moved
3556
+
3557
+ 300 steps later: asleep links inside the sphere
3558
+ before yes 4 of 6
3559
+ after no 0 of 6
3560
+ ```
3561
+
3562
+ §1's first commitment names this case in its own words, and the gate for it was
3563
+ `cloth_collision.spec.js`'s "a collider that appeared but has not moved does not
3564
+ wake anything" — a test that was passing because the thing it asserted was the
3565
+ defect.
3566
+
3567
+ The fix is the **handle set**: `ClothInstance` keeps the handles the last step
3568
+ gathered, and the wake test compares this step's against them before it compares
3569
+ poses. One integer per record, on a path that was already doing a broadphase
3570
+ query, against a step three orders of magnitude more expensive.
3571
+
3572
+ Two details it turns on. The wake test's query has to use the **same reach** the
3573
+ last step's gather used, or it is comparing the answers to two different
3574
+ questions and wakes on the margin rather than on the world; `gather_reach` is
3575
+ recorded beside the set. And a `PlaneShape3D`'s AABB is unbounded, so a plane is
3576
+ in *every* cloth's set the moment it exists: adding one anywhere wakes every
3577
+ sleeping cloth once, which costs the thirty steps it takes to find nothing and
3578
+ go back to sleep. That is stated rather than worked around — a bounded collider
3579
+ five metres away does not wake anything, and the spec pins both.
3580
+
3581
+ ### Retiring
3582
+
3583
+ `ClothColliderIndex#add` popped a retired slot immediately. A handle is a slot
3584
+ index, and `cloth_contact_find` matches warm-started contacts on the handle
3585
+ alone — so a collider removed and another added within one tick are, to every
3586
+ cloth in the scene, the same collider.
3587
+
3588
+ ```
3589
+ a rope settled on a floor for 400 steps, then in one step:
3590
+ remove the floor, add a different collider
3591
+
3592
+ the floor's handle: 0
3593
+ the replacement's handle: 0 <- the cloth cannot tell
3594
+ the particle's multiplier: 0.0981 before, 0.0970 after
3595
+ the friction anchor: carried, still in the old floor's frame
3596
+ ```
3597
+
3598
+ Both index docblocks claimed the opposite — "any cloth holding warm-started
3599
+ contacts against it drops them on its next step" — and the claim is true only if
3600
+ the handle is *absent* for a step, which nothing made it. `remove` now retires
3601
+ into a list `advance` drains one step later. The cost is one slot held back for
3602
+ one step; the alternative, a generation word beside the handle, is the same
3603
+ guarantee threaded through the per-cloth table, the key array and the warm-start
3604
+ scan.
3605
+
3606
+ ### They compose, and that is not a coincidence
3607
+
3608
+ The deferral makes the handle absent for a step; the set comparison is what makes
3609
+ a **sleeping** cloth notice that absence, wake, and clear its slots before
3610
+ anything else can take the number. Either alone leaves a case open: without the
3611
+ deferral the slot is reused before the cloth steps, and without the set test a
3612
+ sleeping cloth never steps at all.
3613
+
3614
+ *Reproduce:* `cloth_collision.spec.js` — the far collider, the overlapping
3615
+ collider, the leaving collider and the replaced collider — and
3616
+ `ClothColliderIndex.spec.js`'s retirement test.
3617
+
3618
+ ---
3619
+
3620
+ ## 56. The parity gate had one cloth in it, and a second cloth is a different gate
3621
+
3622
+ §40 made the gate structural: one parameter block, one `cloth_step_instance`,
3623
+ neither system computing a number of its own. Every scene it was measured on has
3624
+ exactly one cloth in it, and two of the three things that can differ between the
3625
+ systems only exist when there are two.
3626
+
3627
+ ### A nested pair
3628
+
3629
+ `ClothSystem` ran advance, step and finish **per cloth**, walking
3630
+ `ClothWorld#instances` in hierarchy-depth order — a sort that existed precisely
3631
+ so a nested cloth would advance against an anchor the cloth above it had already
3632
+ written this step. `WorkerClothSystem` cannot do that: the step it writes back at
3633
+ the top of a tick happened on another thread during the last one, so its `apply`
3634
+ writes T−1 and its `collect` then advances everything for T.
3635
+
3636
+ ```
3637
+ a 4-link rope hung from link 3 of a 6-link rope, no wind, 120 steps
3638
+
3639
+ outer cloth: identical, every step
3640
+ inner cloth: differs at step 1, byte 24
3641
+ ```
3642
+
3643
+ Byte 24 is inside the particle positions, which is the honest place for it: the
3644
+ child is simulating against a different anchor, not reporting a different number
3645
+ about the same one.
3646
+
3647
+ **Three loops on both sides** — advance every cloth, then step every cloth, then
3648
+ finish every cloth — and the depth sort has nothing left to decide, so it is
3649
+ gone. What a nested cloth reads is its parent one fixed step stale, which is the
3650
+ same trade §4.1 already makes for an anchor and measures at a few centimetres.
3651
+
3652
+ **That was necessary and not sufficient**, and the second half is more
3653
+ interesting than the first. `cloth_write_back` composed every node's world pose
3654
+ as `anchor ⊗ local particle position`, reading the anchor from the live
3655
+ `Transform64`. For `ClothSystem` that is the anchor the step ran on, because the
3656
+ step happened between the two. For the worker it is not: it writes step T−1's
3657
+ particles against tick T's anchor.
3658
+
3659
+ ```
3660
+ the inner cloth's anchor, as each system's write-back left it
3661
+
3662
+ step reference worker (before) worker (after)
3663
+ 2 0.038945 0.069849 0.038945
3664
+ 3 0.038341 0.069079 0.038341
3665
+ 4 0.037680 0.068171 0.037680
3666
+ ```
3667
+
3668
+ The worker's column before the fix is the parent's *current* anchor plus a
3669
+ one-step-old shape — a pose that describes neither step. Nothing rendered can
3670
+ tell, because the attachment cascade re-derives position from the anchor and the
3671
+ local transforms either way (§4.1); a nested cloth reading that pose as its own
3672
+ anchor can. The write-back now uses `ClothInstance#anchor_translation` and
3673
+ `#anchor_rotation`, which `advance` already caches and which the collider gather
3674
+ already reads for the same reason.
3675
+
3676
+ ### What the gate is now
3677
+
3678
+ `WorkerClothSystem.spec.js` compares the whole buffer of **both** cloths at every
3679
+ one of 120 steps, with the parent's anchor walked through a turn so the child has
3680
+ something to be wrong about, and self-checks that the child actually moved.
3681
+
3682
+ ---
3683
+
3684
+ ## 57. A world clock is right for the first cloth in a scene and wrong for the second
3685
+
3686
+ §50 put the wind clock on steps rather than ticks so that a cloth's first step
3687
+ got the same weather under both systems. It did, and the scene it was measured
3688
+ in had one cloth.
3689
+
3690
+ With cloth A already running, cloth B's registration takes k ticks — and A is
3691
+ advanced on every one of them, so the world clock moves while B waits. B
3692
+ therefore reaches **its own first step** at a different clock value under the two
3693
+ systems:
3694
+
3695
+ ```
3696
+ A primed and running, B spawned, a two-tick delivery for B's registration
3697
+
3698
+ B's own step 1: reference clock 0.1000 s
3699
+ worker clock 0.1333 s
3700
+
3701
+ differing bytes in B's buffer: step 1, byte 0, and every step after
3702
+ the same run with gust = 0: none
3703
+ ```
3704
+
3705
+ The `gust = 0` row is what identifies the cause. Latency is not the problem —
3706
+ §39 already made an unacknowledged cloth neither stepped nor advanced, so B's
3707
+ step *sequence* is identical — the problem is that a shared clock is not a
3708
+ property of the cloth asking.
3709
+
3710
+ **The clock is now the asking cloth's own age**, `ClothInstance#age`, seconds
3711
+ *this instance* has stepped, handed to `AbstractClothWind#sample` per call. The
3712
+ source holds no clock: `ClothAmbientWind#__time` is gone and `begin` keeps only
3713
+ the job it can do correctly, which is telling a source that reads the scene —
3714
+ `ClothFluidWind`'s field walk — when to walk it. `ClothWorld#time` stays for
3715
+ exactly that.
3716
+
3717
+ *What it changes behaviourally:* two garments spawned a second apart are a second
3718
+ apart in the gust rather than in phase with it. For a bounded sum of three
3719
+ sinusoids with no spatial structure (§48) that is not a weaker model, it is the
3720
+ same model sampled at a different phase — and a scene in which every banner
3721
+ gusts in lockstep was the odder of the two pictures.
3722
+
3723
+ *What it does not change:* a fluid field's velocities are a snapshot of the tick,
3724
+ not a function of a clock, so `ClothFluidWind` passes the age through to its
3725
+ ambient fallback and ignores it otherwise.
3726
+
3727
+ *Reproduce:* `WorkerClothSystem.spec.js`, the staggered-spawn parity test.
3728
+
3729
+ ---
3730
+
3731
+ ## 58. The library, re-measured, and what the bench can and cannot see
3732
+
3733
+ `cloth_library_bench.mjs`, before this pass and after it, on the same machine
3734
+ within the hour:
3735
+
3736
+ ```
3737
+ entry budget before after delta
3738
+ SILK 2x4 1.561 ms 1.649 ms +5.6%
3739
+ COTTON 2x4 1.655 ms 1.546 ms -6.6%
3740
+ DENIM 2x2 1.261 ms 1.343 ms +6.5%
3741
+ LEATHER 2x2 1.346 ms 1.246 ms -7.4%
3742
+ ROPE 2x4 0.043 ms 0.042 ms -2.3%
3743
+ CHAIN 2x6 0.063 ms 0.063 ms +0.0%
3744
+ HAIR 2x4 1.537 ms 1.702 ms +10.7%
3745
+ ```
3746
+
3747
+ **Both signs, no pattern, ±11%** — which is §42's and §52's lesson arriving a
3748
+ third time, and here it is not merely a disclaimer: **the bench cannot see any
3749
+ of this pass's solver work**, and reading the code is what says so rather than
3750
+ reading the column. It builds a `ClothState` and calls `cloth_step` with a null
3751
+ collider table, so `cloth_contact_find` — the one function §54 changed — never
3752
+ runs; and it never constructs a `ClothWorld`, so the sleep window, the wake test,
3753
+ the write-back's anchor and the wind clock are all outside it. The column is a
3754
+ **regression check** on the paths this pass did not touch, and it passes: no
3755
+ entry moves outside the spread three runs of the unchanged bench already produce
3756
+ (§52 put the §17 baseline row at 3.49, 3.83 and 4.26 ms within an hour).
3757
+
3758
+ What *is* priced, and is priced by a paired measurement rather than by this
3759
+ column, is §54's discovery reach: free at rest, 1.65× on the discovery pass for a
3760
+ cloth falling at 3 m/s, which is 0.8% of a step. §53's sleep window is one
3761
+ compare-only pass over positions on the main thread, in `ClothWorld#finish`,
3762
+ which the bench also cannot see and which sits beside a write-back that already
3763
+ walks every node.
3764
+
3765
+ **No entry is re-sized.** Three passes have now said the same thing about this
3766
+ column and the advice does not change: re-measure it on a quiet machine, take a
3767
+ paired reading even then, and check the process count first.
3768
+
3769
+ ---
3770
+
3771
+ ## 59. The broadphase was a scan, and the margin it scanned with was metres wide
3772
+
3773
+ Two defects, found from one downstream console line: a cape overlapping **80**
3774
+ cloth colliders against a per-cloth table of 64. Both are about the gather, and
3775
+ only one of them is the one the warning names.
3776
+
3777
+ ### The margin
3778
+
3779
+ `cloth_gather_colliders` fattened the cloth's particle bounds by the §7 P1
3780
+ velocity clamp read as a distance — `4 · shortest_row · substeps · iterations`.
3781
+ That is not a distance the cloth can travel; it is a bound on what the solver
3782
+ could undo. Over the shipped library at 60 Hz:
3783
+
3784
+ ```
3785
+ entry sub x it row 2cm row 4cm row 8cm
3786
+ SILK 2 x 4 0.64 m 1.28 m 2.56 m
3787
+ COTTON 2 x 4 0.64 m 1.28 m 2.56 m
3788
+ DENIM 2 x 2 0.32 m 0.64 m 1.28 m
3789
+ CHAIN 2 x 6 0.96 m 1.92 m 3.84 m
3790
+ ```
3791
+
3792
+ The fattening is routinely larger than the garment. `cloth_contact_find` had
3793
+ already reached this conclusion for itself at §54 — it calls the same clamp
3794
+ "about 154 m/s … three orders of magnitude" past the speeds that matter and
3795
+ reaches with `|v_i|·dt` instead — and the gather was never brought along.
3796
+
3797
+ Being enormous also hid what it was **missing**. The narrowphase reaches
3798
+ `(radius + inflation) · (1 + CLOTH_CONTACT_MARGIN) + collider travel +
3799
+ |v_i|·dt`; the gather's fixed term was `2 · contact_radius` and nothing else, so
3800
+ a wide enough `ClothCollider#inflation` was a contact the gather never handed
3801
+ over. `cloth_gather_reach` is now that expression, maximised over the records
3802
+ the gather cannot yet know it will get — which is what `ClothColliderIndex`'s
3803
+ `max_inflation` and `max_collider_travel` are for. It is a **bound** and not an
3804
+ estimate: the gather reads velocity before the step and `cloth_contact_find`
3805
+ reads it after `damp`, which only ever scales down.
3806
+
3807
+ Measured on the rope of §20 — eight 10 cm links at 2 × 4, five colliders placed
3808
+ 2 m away and one against the tail:
3809
+
3810
+ ```
3811
+ gather margin, hanging 3.2006 m -> 0.0209 m
3812
+ colliders gathered 6 -> 1
3813
+ ```
3814
+
3815
+ The 3.2006 m does not move when the rope is thrown at 9 m/s. The new one does,
3816
+ which is the entire point of it.
3817
+
3818
+ ### The scan
3819
+
3820
+ `ClothColliderIndex#query` was a linear scan over every live record, run once
3821
+ per cloth per step — `clothes × colliders`. The docblock defended it on two
3822
+ grounds and both were wrong. The set size ("twenty or so, and never the whole
3823
+ world") was §41's count of colliders overlapping **one garment**, which is a
3824
+ narrowphase input being used to size a world-wide structure. The churn argument
3825
+ ("a tree over twenty leaves that all move every frame costs more to keep than to
3826
+ skip") is answered by `PhysicsSystem`, which keeps two dynamic BVHs over bodies
3827
+ that all move every frame and pays for it with `compute_fat_world_aabb`'s refit
3828
+ skip. The third ground, `PlaneShape3D`'s unbounded AABB, is real and is not a
3829
+ reason for anything: the half-space is constructed in exactly one place in this
3830
+ engine — the JSON shape adapter — and no scene builds one, so the planes sit in
3831
+ a list beside the tree that is empty in every scene that has ever run.
3832
+
3833
+ Colliders drifting 0.4 to 2 mm a step, every cloth gathering every step, paired
3834
+ runs with the order alternated:
3835
+
3836
+ ```
3837
+ colliders cloths scan pose scan gather tree pose tree gather gather
3838
+ 64 8 0.012 0.001 0.010 0.004 0.4x
3839
+ 256 16 0.047 0.009 0.046 0.011 0.8x
3840
+ 1024 32 0.207 0.073 0.205 0.029 2.5x
3841
+ 4096 64 0.961 1.090 0.912 0.076 14.4x
3842
+ 16384 128 4.684 9.930 4.293 0.205 48.5x
3843
+ ```
3844
+
3845
+ `pose` is `advance` plus a `setPose` per collider, which is O(colliders) once a
3846
+ step in both and is unchanged — tree maintenance is inside the noise. `gather`
3847
+ is the `clothes × colliders` term, and it is gone. Below a few hundred
3848
+ colliders the scan is very slightly ahead, on numbers too small to matter.
3849
+
3850
+ ### Two things the tree got wrong first, both measured
3851
+
3852
+ **A leaf inserted before its pose homes at the origin.** `add` built the leaf
3853
+ and `link` placed it immediately afterwards — but `insert_leaf` picks a sibling
3854
+ from where the box *is*, and `node_move_aabb` refits ancestors without ever
3855
+ asking whether the leaf still belongs under them. So a scene added-then-placed
3856
+ built its whole tree out of one coincident point and never rebuilt it: a root 21
3857
+ deep whose every branch overlapped every query, **128 gathers at 128 ms against
3858
+ 0.2 ms** for the same leaves inserted at their real positions. The leaf is now
3859
+ created on the first `setPose`, and a leaf that escapes its slack is removed and
3860
+ re-inserted rather than refit.
3861
+
3862
+ **A `Float32` leaf over a `Float64` world loses a bound.** The tree's nodes are
3863
+ `Float32` and the table is `Float64` precisely because a world coordinate can be
3864
+ far from the origin. One `Float32` ulp is a proportion of the magnitude — a
3865
+ millimetre at 10 km, a whole metre at 10,000 km — so a box widened by a fixed
3866
+ 5 cm and then stored can come back **45 cm inside its own record**, and a leaf
3867
+ smaller than its record is a collider the tree silently stops answering with.
3868
+ The slack now carries a relative term of two ulps as well as the constant. It
3869
+ fails without it, and only for a cloth grazing a face: a query box as wide as
3870
+ the collider survives losing one side, which is why the first spec written for
3871
+ this passed and pinned nothing.