Repository navigation
Expand file tree
/
Copy pathwarp_cloth_catch.py
More file actions
4358 lines (4005 loc) · 221 KB
/
Copy pathwarp_cloth_catch.py
File metadata and controls
4358 lines (4005 loc) · 221 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Four arms catch a cannonball in a cloth, throw it up, and four drones net it.
Aim the cannon, fire, and a rig of four anchors holding a sheet works out where
the ball will be and gets there in time. The rig is never told the ball's true
position: it sees only two 320x240 pinhole frame cameras standing in the scene,
and everything else is inferred from what changed between their frames.
Three things make this work, and each of them is a measurement, not a guess:
* SYNCHRONIZED STEREO, not one camera guessing range. Both sensors are
secondary views on the same renderer, so one render() produces both of them
from ONE scene build at the SAME simulated instant -- which two render()
calls can never give, and which is the whole reason the two bearings may be
intersected at all. Placed 87 degrees apart as seen from the catch zone,
they turn two bearings into a 3-D point by least-squares midpoint, and the
residual gap between the two rays is a free quality signal: the fit throws
away any instant whose rays miss each other by more than 12 cm.
That kills the old range-from-apparent-size crutch, which carried a
systematic -0.43 m bias (the RMS radius of a blob is only PROPORTIONAL to
the ball's apparent radius, and the constant depends on how the ball happens
to look). |g| is still fixed at 9.81 in the arc fit, but it is now a
consistency constraint on six well-observed unknowns rather than the only
thing pinning the scale.
* FRAME DIFFERENCING, per camera, on pixels only. Grayscale, |I_t - I_t-1|,
threshold, then seed on the DENSEST 4-pixel cell rather than the centroid:
the sheet and the arms are a far bigger source of changed pixels than the
ball, but they change as thin outlines while the ball changes as a solid
disc, so pixels-per-cell separates them cleanly. Once the arc is fitted the
search window is re-anchored on the PREDICTED pixel, not on the last blob:
a window that follows its own measurement walks onto the cloth and never
comes back, and that alone was worth 0.96 m on the az -12 shot. Measured:
1.5 and 1.9 px median bearing error, 90th percentiles under 3.5 px, and
3-D observations 0.039 m from truth against the old sensor's 0.088 m.
The last 80 ms are dropped on purpose. There the ball and the sheet occupy
the same pixels in both cameras and no difference detector can say which of
two overlapping movers it is centred on; by then the plan is 150 frames old
and the rig is committed, so nothing is lost.
* The rig arrives MATCHING THE BALL'S HORIZONTAL VELOCITY, and TILTED. The
bowl in a slack sheet is only ~0.3 m deep, worth about 3 J/kg, while a ball
crossing it at 3 m/s carries 4.5 J/kg -- park the rig at the landing spot
and the ball skips straight out the far side. So the approach is a cubic
Hermite (position AND velocity at arrival), re-solved every frame from the
rig's current state as the track sharpens, and over the last 0.45 s of it
the square pitches 8 degrees with its DOWNRANGE corners high, about an axis
perpendicular to the fitted horizontal velocity. The scoop is a small
effect and it is reported as one: on the default shot it takes the ball's
first contact from 0.063 m off the sheet centre to 0.052.
* AND THEN IT THROWS THE BALL BACK, into a basket 1.35 m away. The stroke is
planned without looking at the ball at all: the launch point is the rig's
OWN centre, which it knows from its four tool frames, and v0 comes out of
(B - p - g T^2 / 2) / T solved for the cheapest T the arms can deliver.
Then the sheet is pulled taut, dipped 15 cm along -v0, driven along +v0 to
the commanded speed and braked. Nothing touches the ball: it rides the same
contact and friction that caught it, and leaves when the impulse stops.
That plan is only true if the ball is AT the rig's centre, so the rig puts
it there first. The catch ends wherever the ball was -- 0.98 m from home on
the default shot, 0.68 m at az +8 -- and a stroke launched from there threw
something that was not on it: the az +8 ball left at 0.17 of the commanded
speed, 145 degrees off plan, and landed 1.93 m from the bin. So after the
absorb the rig CARRIES THE BALL HOME, walking back at 1.6 m/s while the
sheet re-tensions under it. The bowl's low point is the rig centre, so the
ball rolls to the middle by construction rather than by being looked at:
0.98 m of catch offset becomes 0.12 m of residual, and the throw leaves
within 8 degrees of plan on every shot the arms can reach.
A trampoline is not a hand, and the measured transfer says so. The ball is
NOT free at the top of the stroke -- scoring it there read 2.60 m/s while
the ball was actually still in the cloth and left at 8.7 m/s once the
long-range attachment snapped taut -- so the release is measured at
separation, after 80 ms of daylight. Two numbers are calibrated on that
measurement and nothing else: the transfer ratio (1.02-1.11 x the anchors'
own speed, so the stroke is commanded at speed/0.85) and the launch POINT,
which is where the ball sat and not where the stroke ends, because the sheet
slides under the ball for most of the stroke. Assuming the ball rides
forward with the rig put the planned launch point 0.285 m from the truth and
that is most of a basket at 1.35 m. The verdict is truth, and only the
verdict: DELIVERED if the ball comes to rest in the bin, and the miss
distance either way.
* AND THEN FOUR DRONES CATCH IT AGAIN, in a net, in the air. The net is the
SAME CLOTH a second time -- the same kernels over a second set of arrays,
625 particles instead of 2401, with its four anchors on four quadrotors
instead of four arms. The ball can only be inside one of the two cloths at
once, so both of them accumulate into one impulse buffer and whichever is
far away contributes exactly zero; no test decides which is which.
The drones fly the rig's own Hermite in 3-D, and their limits are a thrust
vector and a lean: the commanded acceleration becomes a thrust direction,
the thrust is capped at 22 m/s^2 and the lean at 40 degrees, and the body is
then DRAWN along the vector that survived both. The two caps are not
independent, which is the whole point -- holding altitude at 40 degrees buys
g tan 40 = 8.2 m/s^2 sideways and no more. Clamping the lean at constant
thrust magnitude instead, which is what a rotation does, quietly hands back
an extra 7 m/s^2 of climb with every hard turn: the formation flew 33 m
straight up chasing an intercept, and the picture said 90 degrees of lean.
The ball is found again by the SAME two cameras and a tracker that knows
nothing: a fresh instance, armed by the rig's own launch clock and given one
prior, the throw the rig itself just commanded. The lob is planned like the
basket throw and calibrated the same way -- open loop, at separation -- but
NOT with the same numbers, because a near-vertical stroke slings far harder
than a flat one: 1.39 x the anchors' own speed against 1.02, and the ball is
gone 45 ms into a 170 ms stroke. Planning the lob on the flat throw's
calibration put the seed window 0.7 m ahead of the ball and cost half a
second of looking in the wrong place; with the vertical numbers measured the
first crossing call lands 267 ms after the stroke, 0.7 s before the catch.
Measured on the default shot: 48 observations at 0.032 m of 3-D error, and
the ball settles 0.05-0.20 m from the middle of a net 0.90 m across.
The drones are also the biggest movers in both frames while all this is
happening, and they are in the detector's own search window on about 100 of
150 ticks. Two things keep them out of the track. They sit ON THE PAD until
the rig is holding the ball -- there is nowhere to hover that the cannon's
arc does not sweep, and check_drones() rejected the first station at 0.10 m
of overlap -- so during the first catch they are four parked objects putting
nothing into a difference detector. And once flying, each hull is excluded
by its own small disc, the same prior as the cannon's: the drones are this
robot's own hardware and it knows where it commanded them. Masking them took
the toss leg from 0.116 m of observation error to 0.032 and the catch from
0.335 m off the middle to 0.05.
* THE ARMS ARE THE ENVELOPE, and they are now allowed to say so. --arm-reach
was a flag with 2.0 m typed into it while four Franka arms on 0.5 m
pedestals ran out at about 1.1 m downrange and 0.16 m sideways, so every
off-axis catch was executed with one corner up to a metre behind where it
was commanded -- the sheet stopped being a sheet, and the ball left at
whatever the wreckage gave it. The tool reach is now MEASURED at startup by
walking a target away from one arm's base until it stops converging (1.08 m
for an FR3 to fr3_hand_tcp), the travel envelope is solved from it per
direction, and the rig is clamped to that envelope instead of to a flag.
Standing the pedestals at 0.70 m and tucking them 0.34 m out took the
default shot's worst tracking error from 0.186 m to 0.019 m. Shots outside
the envelope are named at startup and reported as reach-capped rather than
attempted: az -12 el 58 at 7.0 m/s crosses 2.06 m from home against a 1.11 m
boundary. That shot was thought to be a regression; it is not. Re-running
the pre-sensor build on it misses by the same margin with the same 1.01 m of
arm error, so it has never been caught, and it now says so on the first line
of the run instead of on the last.
* THE ARMS CAN BE EXECUTED BY DYNAMICS, not posed. --physics-arms loads the
same FR3 file a second time as a PhysX reduced-coordinate articulation, one
per arm, all four in one world with self-collision on and colliders for the
floor and the pedestals. IK still plans the corner; the plan becomes the
joints' PD drive targets, PhysX steps twice per sensor tick, and the
ACHIEVED joint vector is what the visual robot and the cloth anchor get. A
position-only PD in force mode carries a velocity lag of D*qd/K, which at
3 rad/s was 0.3 rad and 0.14 m of tool error, so the plan's own joint
velocity is fed forward through the position target (target + (D/K)*qd).
That cancels the lag term and leaves the inertial residual: the drives hold
the IK plan to 1.3 mm mean and 12 mm peak through the whole run, and every
catch verdict is unchanged. Commanded to do something impossible -- two arms
sent to the same point in the air -- the kinematic pair close to 1 mm and
occupy the same metre, while the simulated pair stop 29 mm apart and hold
there with 13 and 16 mm of standing drive error. It is opt-in because the
BASKET throw is not a tracking task but a whip: TOSS_GAIN was calibrated
against a stroke with no execution error in it, and the same command
executed by drives releases at 0.97x the rig instead of 1.06x, which turns
--no-drones from DELIVERED into a throw that sails past the bin. The drone
path, which is the default, is unaffected.
The range around all this is procedural and static: a painted concrete pad with
a hazard ring on the catch zone and a lane out of the cannon, a backstop, crates
and drums, four lamp poles and a pennant line. The markings live on their OWN
mesh and their own texture -- 10.8 x 8.05 m at 5.3 mm per texel against the
ground's 26 mm -- because a 26 mm step is invisible in gravel noise and a
staircase in the edge of a painted line, and because every edge on the pad is a
smoothstep over a signed distance rather than a boolean mask. Resolution alone
just makes smaller stairs. The bin's placement is arithmetic too: it stands
uprange, which is the only corridor that is 1.2-1.5 m from home, 0.35 m clear of
every pedestal and outside every arm's swept envelope, and clear of everywhere
the sheet goes -- which is all of +x. check_props() prints those four clearances
and the angle the film camera looks into the bin at, because the first bin was
standing inside an arm and rendered perfectly well doing it.
Firing is a flash, a puff of 60 smoke particles and 6 cm of recoil, and all
three are things the SENSORS SEE.
The flash was the expensive one: a point light throws a pool of changing
brightness metres wide across the floor, the detector locked onto it, and the
first crossing call came out 0.99 m wrong. Two things fix that, and neither is
a special case for the ball. The flash is over in five sensor ticks, before the
tracker's first accepted observation. And a disc around the cannon's own
projected position is excluded from the difference for the first 0.35 s: the rig
knows where its own gun is, the disc never moves and never follows the target,
and at 0.75 m the ball is out of it 15 ms after leaving the barrel. Lighting the
range for dusk also cost the detector two thirds of its observations at the old
threshold of 16 luma, so the threshold is 9 now: same bearing medians, 248
observations instead of 59.
The sensors are bolted to tripods you can see, and the view camera is now free:
orbit it during flight, fire from anywhere, it changes nothing the tracker sees.
Secondary views also run no overlay pass, so the aim arc, the reticles and ImGui
are structurally incapable of leaking into a sensor frame.
In the window the cannon carries its aim visibly and draws the arc the shot will
actually take, with a ring where it crosses the catch plane. That preview is
ground truth and is the PLAYER's aid -- it is hidden the instant the shot leaves,
so it can never be confused with, or leak into, what the tracker has to work out
for itself. In a headless run it does not come back at all once the shot is
away: it exists to aim the NEXT one, and a clip has no next one, so all it did
there was paint an amber arc over the closing seconds of the film. Watching the tracker's cyan reticle converge onto that amber ring is
the whole perception story in one picture.
python warp_cloth_catch.py # window; A/D aim, W/S elevate,
# Q/E power, SPACE fire, R reset
python warp_cloth_catch.py --frames 900 --autofire # bounded window run, for testing
python warp_cloth_catch.py --tune # headless, numbers only
python warp_cloth_catch.py --clip catch.mp4 # headless mp4
python warp_cloth_catch.py --clip c.mp4 --split # with both sensor feeds inset
python warp_cloth_catch.py --az -12 --el 58 --speed 7.0
python warp_cloth_catch.py --oracle # ground truth instead of the sensors,
# to separate tracking error from control
python warp_cloth_catch.py --tilt 0 # flat sheet, to A/B the scoop
python warp_cloth_catch.py --no-drones # throw at the basket instead,
# no second catch
python warp_cloth_catch.py --no-toss # catch only, no throw
python warp_cloth_catch.py --no-effects # no flash, smoke or recoil, so the
# sensor pollution can be A/B'd
python warp_cloth_catch.py --physics-arms # arms executed by PhysX drives
# instead of posed by IK
In the window the amber crossing ring turns RED when the shot is outside the
arms' measured envelope, so an impossible shot is visible before it is fired
rather than explained on the last line of the run.
Prints the predicted intercept against where the ball actually crossed, the
throw's commanded speed against what the ball actually left with, and how far
the ball settled from the middle of the net, so perception, catch, throw and the
second catch can each be judged separately.
"""
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import numpy as np
import warp as wp
import threepp as tp
from warp_common import (ball_predict, cli_arg, cloth_integrate, compute_normals,
encode_png_sequence, find_ffmpeg, parse_size,
set_anchors, spring, standard_material)
# ---- flags -------------------------------------------------------------------
TUNE = "--tune" in sys.argv
CLIP = cli_arg("--clip", "", str)
SEQ = cli_arg("--seq", "", str)
SPLIT = "--split" in sys.argv # show both sensor feeds inset in the frame
# Film pass: full render scale, a bigger frame, a proper key/fill/rim set, the
# dead air before the shot trimmed, and the catch itself in slow motion. The
# sheet still has to settle for the tracker to bootstrap -- it just does not
# have to be watched doing it.
FILM = "--film" in sys.argv
ORACLE = "--oracle" in sys.argv # bypass the sensors; use true ball state
TRACE = "--trace" in sys.argv
# Bounded interactive run: drives the real window loop -- ImGui, HUD, orbit
# controls and all -- for N frames and exits, so the interactive path can be
# exercised rather than assumed. --autofire shoots as soon as it reads READY.
FRAMES = int(cli_arg("--frames", 0, float))
AUTOFIRE = "--autofire" in sys.argv
HEADLESS = bool(TUNE or CLIP or SEQ) and not FRAMES
# ---- the cannon --------------------------------------------------------------
CANNON = np.array([float(x) for x in cli_arg("--cannon", "-2.40,0.55,0", str).split(",")])
AZ = cli_arg("--az", 0.0, float) # degrees; 0 fires along +x
EL = cli_arg("--el", 62.0, float) # degrees above horizontal
MUZZLE = cli_arg("--speed", 6.5, float) # m/s
aim = {"az": AZ, "el": EL, "speed": MUZZLE}
# The sheet must be STILL before the ball flies. The detector reports change, so
# a ringing sheet fills both frames with changed pixels and the tracker locks
# onto cloth instead of ball -- measured centre 300 px from the true one. Once it
# has settled the ball is the only mover and the same tracker is sub-pixel.
FIRE_AT = cli_arg("--fire-at", 1.2, float) # headless: seconds of settle first
# ---- the rig -----------------------------------------------------------------
HOME = np.array([float(x) for x in cli_arg("--home", "0.50,0", str).split(",")]) # x, z
ARM_SPEED = cli_arg("--arm-speed", 4.5, float) # m/s cap on the rig centre
ARM_REACH = cli_arg("--arm-reach", 2.0, float) # m from home the rig may travel
ABSORB = cli_arg("--absorb", 0.35, float) # s to bleed the ball's motion off
GIVE = cli_arg("--give", 0.30, float) # m the sheet may drop while absorbing
RECOVER = cli_arg("--recover", 0.55, float) # s to lift back to the ready height
PRESENT = cli_arg("--present", 0.75, float) # how taut to pull on recovery, in
# units of the slack (1 = dead flat)
# The catch ends wherever the ball was, which on an off-axis shot is up to a
# metre from home -- and a throw planned from the rig's own centre is then
# throwing something that is NOT at that centre. So the rig walks the ball home
# while it re-tensions, slowly enough to keep it in the bowl, and only plans the
# stroke once it is back at the pose it knows. Peak carry speed, not duration:
# the carry stretches to whatever the distance needs at this speed.
CARRY_SPEED = cli_arg("--carry-speed", 1.60, float) # m/s while cradling
SETTLE_DWELL = cli_arg("--settle-dwell", 0.35, float) # s of stillness before the throw
# The sheet does not have to stay flat. Tilting the square so the DOWNRANGE side
# rides high turns it into a scoop: the ball's horizontal energy runs uphill into
# the pocket instead of skating out over the far edge, which is the exact way the
# marginal catches were being lost (ball at rest 0.766 m out against a 0.75 m
# half-width). The axis is horizontal and perpendicular to the incoming
# horizontal velocity AS THE TRACKER FITTED IT -- no truth is read.
TILT_DEG = cli_arg("--tilt", 8.0, float) # degrees of scoop at arrival
TILT_LEAD = cli_arg("--tilt-lead", 0.45, float) # s before arrival the tilt ramps in
TILT_EASE = cli_arg("--tilt-ease", 0.30, float) # s to flatten again during recovery
# ---- the throw ----------------------------------------------------------------
# The basket stands UPRANGE of the rig, back toward the cannon, and every part
# of that is forced rather than chosen. Three things want the same floor:
#
# * the catch. Every shot in the aim envelope crosses DOWNRANGE of home, from
# x +0.56 out to x +2.30, and the sheet sweeps 0.75 m further still -- so a
# bin anywhere on the +x side is something the cloth flies through. The
# first placement, x +0.85 z -1.35, was inside the swept envelope of the
# rear-left arm as well (0.56 m from its pedestal axis against a 0.42 m
# bin), which is what check_props() below now refuses.
# * the four pedestals, which occupy a 1.30 x 2.30 m rectangle around home.
# Only two corridors leave 1.2-1.5 m of throw AND 0.35 m of clearance: past
# +x, which the catch owns, and back past -x, which is empty because the
# ball is 2.5 m up when it passes over.
# * the film camera, which stands off the +x +z shoulder. Upranging the bin
# puts the throw broadside to it and out from behind the sheet.
#
# The bin is also WIDER AND SHALLOWER than it was (0.42 x 0.28 against 0.40 x
# 0.42): a camera 4.6 m away and 3.3 m up looks into it at 29 degrees, and a bin
# needs the sight line to clear its own near rim -- 23 degrees at this aspect,
# 39 at the old one, which is why the ball in the bin used to be a rumour.
BASKET = np.array([float(x) for x in cli_arg("--basket", "-0.85,0.0", str).split(",")])
BASKET_R = cli_arg("--basket-r", 0.42, float) # inner radius of the bin
BASKET_RIM = cli_arg("--basket-rim", 0.90, float)
BASKET_DEPTH = cli_arg("--basket-depth", 0.28, float)
BASKET_FLOOR = BASKET_RIM - BASKET_DEPTH
TOSS = "--no-toss" not in sys.argv
# The stroke is bounded by the same arm that does the catching: ARM_SPEED is a
# hard cap on the rig centre, so anything the solve asks for above this is a
# throw the rig cannot make and is reported as such rather than faked.
TOSS_VMAX = cli_arg("--toss-vmax", 0.93 * ARM_SPEED, float)
TOSS_DIP = cli_arg("--toss-dip", 0.15, float) # m of windup along -v0
TOSS_WINDUP = cli_arg("--toss-windup", 0.26, float)
TOSS_STROKE = cli_arg("--toss-stroke", 0.17, float) # s of acceleration
TOSS_BRAKE = cli_arg("--toss-brake", 0.10, float) # s to stop after release
TOSS_SPREAD = cli_arg("--toss-spread", 0.92, float) # taut, but not dead flat:
# a flat sheet has no bowl
# to hold the ball during
# the windup
# What the ball leaves with, as a multiple of the rig's own commanded speed --
# a calibration of the rig's actuator, measured open loop over the stroke rather
# than read off the ball in flight. It is not a constant, because a trampoline
# is not a hand: measured 1.05x at a 2.9 m/s stroke, 1.14x at 3.7 m/s and 1.54x
# at 4.15 m/s, since the long-range attachment snaps taut at the top of a hard
# stroke and slings the ball out faster than the anchors ever move. 0.95 is the
# working point that puts the default shot in the bin; the first throw the rig
# ever made assumed 0.80, was commanded at 4.15 and went 6.6 m past the basket.
TOSS_GAIN = cli_arg("--toss-gain", 0.85, float)
# The flattest throw the planner may choose. Left free, the solve takes the
# cheapest T it can find and asks for 2.70 m/s, which is below the speed at
# which the sheet slings at all: measured, the ball then left with 0.80 of the
# rig's own speed and fell 0.72 m short. Above 0.60 s the plan lands in the
# range the gain above was calibrated over, and it also looks like a throw
# rather than a shove.
TOSS_T_MIN = cli_arg("--toss-t-min", 0.60, float)
# How far the ball rises off its rest point before it is free of the sheet,
# measured at separation rather than assumed from the stroke.
TOSS_RISE = cli_arg("--toss-rise", 0.03, float)
# ---- the sheet ---------------------------------------------------------------
N = int(cli_arg("--res", 48, float))
CLOTH = cli_arg("--cloth", 1.50, float)
SPAN = cli_arg("--span", 1.30, float)
CATCH_Y = cli_arg("--catch-y", 1.10, float)
CLOTH_KG = cli_arg("--cloth-kg", 0.45, float)
BALL_R = cli_arg("--ball-r", 0.10, float)
BALL_KG = cli_arg("--ball-kg", 0.40, float)
MU = cli_arg("--mu", 0.55, float) # cloth-on-ball Coulomb friction
# ---- the net and the drones ---------------------------------------------------
# The second catch. With drones the rig throws the ball UP instead of at the bin,
# and four quadrotors carrying a net between them take it out of the air on the
# way down. Almost nothing here is new machinery:
#
# * the NET is a second INSTANCE of the sheet. Every cloth kernel above already
# takes its positions, its inverse masses, its anchors and its long-range
# table as arguments, so a second set of launches over a second set of arrays
# is a second cloth. Its four anchors are the four drones.
# * the DRONES fly the same arrive-velocity-matched Hermite the rig flies, in
# full 3-D, with a thrust cap in place of a joint cap.
# * the ball is found by the SAME two cameras, by a fresh tracker that has to
# re-acquire it from nothing.
DRONES = "--no-drones" not in sys.argv and "--no-toss" not in sys.argv
NET_RES = int(cli_arg("--net-res", 24, float))
NET_CLOTH = cli_arg("--net", 1.02, float) # m of material in the net
NET_SPAN = cli_arg("--net-span", 0.90, float) # m between the drones
NET_ITERS = int(cli_arg("--net-iters", 24, float))
NET_KG = cli_arg("--net-kg", 0.18, float)
NET_HOOK = cli_arg("--net-hook", 0.10, float) # m the net hangs under the hulls
NET_Y = cli_arg("--net-y", 1.90, float) # altitude of the mid-air catch
NET_NEAR = cli_arg("--net-near", 0.90, float) # m of ball-to-net that counts as
net_near = [False] # close enough to interleave
# A quadrotor is a thrust vector and a lean, so that is the whole flight model.
# The commanded acceleration is turned into a thrust direction, the thrust is
# capped and its lean is capped, and what comes back out is the acceleration
# those two limits allow -- then the body is DRAWN along that same vector, so the
# tilt in the picture is not decoration, it is the constraint. The two caps are
# not independent: holding altitude at lean t costs g / cos t of thrust and buys
# g tan t of horizontal acceleration, which at 40 degrees is 8.2 m/s^2. Leaning
# harder while climbing buys more (up to THRUST sin t = 14.1), which is exactly
# what the drop onto the ball uses.
DRONE_THRUST = cli_arg("--drone-thrust", 22.0, float) # m/s^2 of thrust/mass
DRONE_TILT = cli_arg("--drone-tilt", 40.0, float) # degrees of lean allowed
DRONE_SPEED = cli_arg("--drone-speed", 5.0, float) # m/s cap on the formation
DRONE_TAU = cli_arg("--drone-tau", 0.09, float) # s, the velocity loop
DRONE_ATT_TAU = cli_arg("--drone-att-tau", 0.05, float) # s, the airframe's lag
# Where the drones wait, and when they stop waiting there.
#
# There is nowhere in the air to hover. The cannonball's arc sweeps the whole
# x-y plane the net would have to stand in -- at az 0, el 66, 7 m/s it is still
# at x +1.10 when it passes y 2.10 -- so any station close enough to reach the
# lob's intercept in time is also a station the first shot flies through. The
# first attempt at this was rejected by check_drones() at 0.10 m of overlap.
#
# So the drones sit ON THE PAD until the rig has the ball, and take station while
# it carries it home. That is the rig's own catch, not the ball's position: the
# state machine knows it is in `recover`, and it knows the lob it is going to
# throw because the lob is planned from its own centre at a fixed elevation. The
# staging pose comes out of that plan; where the ball actually goes still comes
# only from the fresh fit, 1.5 s later.
DRONE_GROUND = np.array([float(x) for x in
cli_arg("--drone-ground", "3.10,0.19,1.85", str).split(",")])
DRONE_PARK_OUT = cli_arg("--park-out", 0.45, float)
DRONE_PARK_UP = cli_arg("--park-up", 0.35, float)
DRONE_ABSORB = cli_arg("--drone-absorb", 0.35, float) # s of give after arrival
DRONE_HOLD = cli_arg("--drone-hold", 0.55, float) # s of stillness, then the exit
DRONE_EXIT = cli_arg("--drone-exit", 2.40, float) # s of the carry off frame
DRONE_EXIT_TO = np.array([float(x) for x in
cli_arg("--drone-exit-to", "1.9,1.4,0.8", str).split(",")])
# How much of the ball's own descent the net matches on arrival. All of it and
# the net never closes on the ball; none of it and the ball hits a net that is
# standing still, which is the same 4.5 J/kg problem the sheet has.
DRONE_MATCH = cli_arg("--drone-match", 0.70, float)
# The fresh tracker is armed by the rig's own launch clock: the stroke takes
# TOSS_STROKE, the brake takes TOSS_BRAKE, and after that the sheet is out from
# under the ball. NET_SEED_R is how wide a window the throw PLAN opens before the
# tracker has a fit of its own, in units of the detector's own search radius.
# How long after the ball is gone the fresh tracker starts. Not zero: at 0.10 s
# the tracker has the ball but only 0.12 s of baseline when it first publishes,
# and a ballistic fit over 0.12 s is confident and wrong -- it called the
# crossing at x +3.19 against a truth near +1.6, the formation committed to it,
# and the catch got worse rather than better (0.33 m off the middle against
# 0.09). At 0.245 s the sheet is also well clear of the ball in both frames.
NET_OBS_LAG = cli_arg("--net-obs-lag", 0.245, float) # s after the ball is gone
NET_SEED_R = cli_arg("--net-seed-r", 1.6, float)
# One exclusion disc per DRONE, in metres of world radius around each hull. A
# drone is 0.42 m across its booms; this covers it and a little of its downwash
# without reaching the ball, which is the whole point of masking them one at a
# time instead of masking the formation as a box.
DRONE_MASK_R = cli_arg("--drone-mask-r", 0.30, float)
# ---- rates -------------------------------------------------------------------
# The sensor rate IS the loop rate: the detector samples once per render, so a
# fast tracker means a fast loop. The substep rate is held constant across it,
# because that -- not the frame rate -- is what the contact needs.
SENSOR_HZ = cli_arg("--sensor-hz", 240.0, float)
SUBSTEP_HZ = cli_arg("--substep-hz", 2880.0, float)
ITERS = int(cli_arg("--iters", 32, float))
SUBSTEPS = max(1, int(round(SUBSTEP_HZ / SENSOR_HZ)))
DT = 1.0 / (SENSOR_HZ * SUBSTEPS)
CLIP_FPS = 60.0
# Long enough for the whole beat: settle, flight, absorb, recover, the window
# that decides CAUGHT against MISSED, and then the throw -- windup, stroke,
# ~0.6 s of second flight and the ball coming to rest in the basket. At 3.0 s
# the run stopped mid-lift and reported "still lifting", which is not a verdict;
# the extra seconds cost 0.06 ms/tick of the average, measured.
# The carry home added roughly a second: an off-axis catch ends up to a metre
# out and walks back at a cradling speed.
# With the drones there is a second flight, a second catch, and the carry off
# frame after it, all of which happen after the point the basket run stops.
SECONDS = cli_arg("--seconds", 4.2 if "--no-toss" in sys.argv
else (9.6 if DRONES else 8.0), float)
DAMPING = cli_arg("--damping", 0.010, float)
# Each sensor is a secondary view rendered at exactly this size, so unlike the
# old DVS there is no resampling between render and detector: one focal length
# describes both axes and the pixels are square by construction. 320x240 is a
# deliberate choice, not a leftover -- see SENSORS below for the measured trade
# against 480x360, and note that picture-in-picture is 1:1 only, so the sensor
# size IS the inset size in the window.
SENSOR_W, SENSOR_H = parse_size(cli_arg("--sensor", "320x240", str))
SENSOR_FOV = cli_arg("--sensor-fov", 45.0, float)
# 4:3, to match the sensors. It is also the better aspect for a social clip.
VIEW_W, VIEW_H = parse_size(cli_arg("--size", "1280x960" if FILM else "960x720", str))
# Frame differencing threshold, in 0-255 luma. The ball moves ~0.5 px per tick
# at 240 Hz, so what clears this is not the disc but the disc's TEXTURE sliding
# across the pixels under it -- which means the threshold is really a statement
# about local contrast, and local contrast is set by the lighting.
#
# It used to be 16, against a near-black floor and a single hard sun. Lighting
# the range for dusk (a warm sun, a cool fill, four lamp poles) lifted the mid
# tones and cost the detector two thirds of its observations at 16: 59 tracked
# frames against 146. At 9 the same scene gives 248, with the bearing medians
# and the stereo gap unchanged or better, so this is a threshold that was tuned
# for a different scene rather than a floor imposed by noise.
DIFF_THRESHOLD = cli_arg("--diff-threshold", 9.0, float)
# Render is ~65% of the loop, so this is the lever that matters. The worry was
# that it would cost accuracy -- the detector samples the post-TAA frame, so a
# lower scale softens the edges it fires on -- but measured across 0.5..1.0 the
# bearing stays at 2.4-2.6 px and every scale still catches. Interleaved A/B
# (the run-to-run spread is ~15%, so singles are not worth quoting): 25.8 ms at
# 1.0 against 18.7 ms at 0.6.
RENDER_SCALE = cli_arg("--render-scale", 1.0 if FILM else 0.8, float)
PROFILE = "--profile" in sys.argv
# The GI machinery is built for many-light and emissive-geometry scenes. This
# one has two lights and no emitters, so it was paying for convergence it does
# not need: measured, probe GI + deferred AO + ReSTIR DI cost 1.6 ms/tick of an
# 11.2 ms render here and change the look of a matte floor and four white arms
# very little. So they are OFF by default and opt-back-in per feature; the film
# pass keeps them, because there the look is the point.
GI = "--gi" in sys.argv or FILM
AO = "--ao" in sys.argv or FILM
RESTIR = "--restir" in sys.argv or FILM
NO_DENOISE = "--no-denoise" in sys.argv
prof = {}
MIN_PIXELS = int(cli_arg("--min-pixels", 24, float))
V = (N + 1) * (N + 1)
REST = CLOTH / N
M_PARTICLE = CLOTH_KG / V
GRAVITY = wp.vec3(0.0, -9.81, 0.0)
G_NP = np.array([0.0, -9.81, 0.0])
# The lob. It is planned exactly like the basket throw -- from the rig's own
# centre, at the fastest ball the stroke has been calibrated to deliver -- but
# aimed at an ANGLE instead of at a point, because there is no point to aim at:
# the drones go where the ball is, not the other way round. Near vertical, with
# just enough downrange lean to take the ball out of the rig's own airspace.
LOB_EL = cli_arg("--lob-el", 78.0, float) # degrees above horizontal
# The stroke is commanded at ARM_SPEED and this is what comes off it, measured at
# separation like everything else about this throw -- and it is NOT the number
# the basket throw uses. A near-vertical stroke slings far harder than a flat
# one: 1.41 x the anchors' own speed against 1.02, because the long-range
# attachment snaps taut at the top of the stroke and by then the ball is sitting
# straight up the line of it. Planning the lob at the flat throw's calibration
# put the plan 1.9 m/s slow, which is 1.2 m of apex, and the fresh tracker spent
# 0.7 s looking for a ball that had already gone past its search window.
LOB_SPEED = cli_arg("--lob-speed", 5.70, float) # m/s off the sheet, measured
LOB_RISE = cli_arg("--lob-rise", 0.15, float) # m above the rig centre and
LOB_LEAD = cli_arg("--lob-lead", 0.16, float) # m downrange of it at release
# WHEN it leaves, as seconds into the stroke, and it is not the end of it: the
# ball is gone 45 ms into a 170 ms vertical stroke, because the sheet reaches the
# speed that slings it long before the anchors stop. Assuming the end of the
# stroke put the seed window 0.7 m ahead of the ball and cost the fresh tracker
# half a second of looking in the wrong place.
LOB_T_REL = cli_arg("--lob-t-rel", 0.045, float)
LOB_DIR = np.array([math.cos(math.radians(LOB_EL)), math.sin(math.radians(LOB_EL)),
0.0])
def lob_landmarks(p_rest=None):
"""Launch point, apex and the descending crossing of NET_Y for the planned
lob. Everything the drones' park pose and the coverage check are placed
against, and all of it arithmetic on the rig's own numbers."""
if p_rest is None:
p_rest = np.array([HOME[0], CATCH_Y, HOME[1]])
p = (np.asarray(p_rest, float) + np.array([0.0, LOB_RISE, 0.0])
+ LOB_LEAD * np.array([LOB_DIR[0], 0.0, LOB_DIR[2]])
/ max(math.hypot(LOB_DIR[0], LOB_DIR[2]), 1e-9))
v = LOB_DIR * LOB_SPEED
t_apex = v[1] / 9.81
apex = p + v * t_apex + 0.5 * G_NP * t_apex ** 2
a, b, c = 0.5 * G_NP[1], v[1], p[1] - NET_Y
disc = b * b - 4 * a * c
t_net = (-b + math.sqrt(disc)) / (2 * a) if disc > 0 else t_apex
t_net = max(t_net, (-b - math.sqrt(disc)) / (2 * a) if disc > 0 else t_apex)
return p, v, apex, p + v * t_net + 0.5 * G_NP * t_net ** 2, t_net
# ---- warp: the sheet ----------------------------------------------------------
@wp.kernel
def solve(p_in: wp.array(dtype=wp.vec3), p_out: wp.array(dtype=wp.vec3),
im: wp.array(dtype=float), nx: int, ny: int, rest: float,
ball: wp.array(dtype=wp.vec3), ball_prev: wp.array(dtype=wp.vec3),
ball_r: float, mu: float,
prev_pos: wp.array(dtype=wp.vec3),
anchors: wp.array(dtype=wp.vec3), lra: wp.array(dtype=float),
impulse: wp.array(dtype=wp.vec3), anchor_f: wp.array(dtype=wp.vec3),
m_particle: float, relax: float):
i = wp.tid()
p = p_in[i]
w = im[i]
if w == 0.0:
p_out[i] = p
return
ix = i % (nx + 1)
iy = i // (nx + 1)
rd = rest * wp.sqrt(2.0)
c = wp.vec3(0.0, 0.0, 0.0)
c += spring(p, p_in, im, w, ix - 1, iy, nx, ny, rest, 1.0)
c += spring(p, p_in, im, w, ix + 1, iy, nx, ny, rest, 1.0)
c += spring(p, p_in, im, w, ix, iy - 1, nx, ny, rest, 1.0)
c += spring(p, p_in, im, w, ix, iy + 1, nx, ny, rest, 1.0)
c += spring(p, p_in, im, w, ix - 1, iy - 1, nx, ny, rd, 0.85)
c += spring(p, p_in, im, w, ix + 1, iy - 1, nx, ny, rd, 0.85)
c += spring(p, p_in, im, w, ix - 1, iy + 1, nx, ny, rd, 0.85)
c += spring(p, p_in, im, w, ix + 1, iy + 1, nx, ny, rd, 0.85)
c += spring(p, p_in, im, w, ix - 2, iy, nx, ny, 2.0 * rest, 0.35)
c += spring(p, p_in, im, w, ix + 2, iy, nx, ny, 2.0 * rest, 0.35)
c += spring(p, p_in, im, w, ix, iy - 2, nx, ny, 2.0 * rest, 0.35)
c += spring(p, p_in, im, w, ix, iy + 2, nx, ny, 2.0 * rest, 0.35)
p = p + c * relax
# Long-range attachment: no particle may be further from an anchor than the
# sheet's own material distance to it. Jacobi propagates tension one edge per
# iteration, far too slow to stop a 49-wide grid stretching like a trampoline
# under an impact; this says it in one step. Unilateral, so it adds no energy.
for k in range(4):
ak = anchors[k]
dk = p - ak
lk = wp.length(dk)
lmax = lra[i * 4 + k]
if lk > lmax:
# The projection pulls the particle toward the anchor, so by
# Newton's third law the ANCHOR is dragged toward the particle by
# m * (p - q). Accumulated per corner this says WHICH corner is
# carrying the sheet, and it is used for nothing else -- see
# ARM_LOAD. It is a share and not a force: at the fixed point of a
# Jacobi solve the attachment and the springs it fights both keep
# correcting, by equal and opposite amounts, so the sum over
# iterations grows with ITERS instead of converging. Measured, the
# first attempt at reading it as a force hit a 200 N-a-corner guard
# 43 times through the throw, on a 0.45 kg sheet.
# The projected position is written exactly the way it always was,
# arithmetic and all, so a run with the feedback off is bit-for-bit
# the run before any of this existed.
q = ak + dk * (lmax / lk)
wp.atomic_add(anchor_f, k, (p - q) * m_particle)
p = q
# Ball contact. The pushout delta is the only thing the ball did to this
# particle, so m * delta summed over the sheet IS the momentum transfer --
# the spring corrections above cancel pairwise and cannot leak into it.
bc = ball[0]
d = p - bc
l = wp.length(d)
if l < ball_r:
nrm_c = d / wp.max(l, 1.0e-6)
push = nrm_c * ball_r - d
p = p + push
# Coulomb friction, position-based. Without it the pushout is purely
# radial, so nothing can slow the ball ALONG the sheet -- it skates
# across and out the far side no matter where the rig is. The sheet
# holds a ball with friction, not with geometry.
# Tangential slip is this particle's motion relative to the ball's over
# the substep, projected out of the normal, and it is limited by the
# penetration depth exactly as Macklin's PBD friction is.
slip = (p - prev_pos[i]) - (bc - ball_prev[0])
slip = slip - nrm_c * wp.dot(slip, nrm_c)
lt = wp.length(slip)
fric = wp.vec3(0.0, 0.0, 0.0)
if lt > 1.0e-9:
fric = slip * (-wp.min(1.0, mu * (ball_r - l) / lt))
p = p + fric
wp.atomic_add(impulse, 0, (push + fric) * m_particle)
p_out[i] = wp.vec3(p[0], wp.max(p[1], 0.005), p[2])
@wp.kernel
def ball_finish(bp: wp.array(dtype=wp.vec3), bv: wp.array(dtype=wp.vec3),
impulse: wp.array(dtype=wp.vec3), h: float, inv_bm: float,
radius: float,
bk_x: float, bk_z: float, bk_floor: float, bk_rim: float,
bk_r: float):
v = bv[0] + GRAVITY * h - impulse[0] * (inv_bm / h)
p = bp[0] + v * h
if p[1] < radius:
p = wp.vec3(p[0], radius, p[2])
v = wp.vec3(v[0] * 0.6, wp.abs(v[1]) * 0.3, v[2] * 0.6)
# The basket is a real collider, not a scoring region: an open cylinder
# (wall, floor, rim ring) the ball can enter, rattle in, or bounce off the
# lip of. A throw that clips the rim has to be allowed to fail visibly,
# which a "was it inside the radius" test would quietly hide.
dx = p[0] - bk_x
dz = p[2] - bk_z
d = wp.sqrt(dx * dx + dz * dz)
ux = float(1.0)
uz = float(0.0)
if d > 1.0e-6:
ux = dx / d
uz = dz / d
if p[1] < bk_rim:
if d < bk_r: # inside: wall and floor
if d > bk_r - radius:
p = wp.vec3(bk_x + ux * (bk_r - radius), p[1], bk_z + uz * (bk_r - radius))
vr = v[0] * ux + v[2] * uz
if vr > 0.0:
v = wp.vec3(v[0] - 1.4 * vr * ux, v[1], v[2] - 1.4 * vr * uz)
if p[1] < bk_floor + radius:
p = wp.vec3(p[0], bk_floor + radius, p[2])
v = wp.vec3(v[0] * 0.55, wp.abs(v[1]) * 0.22, v[2] * 0.55)
elif d < bk_r + radius: # outside the wall
p = wp.vec3(bk_x + ux * (bk_r + radius), p[1], bk_z + uz * (bk_r + radius))
vr = v[0] * ux + v[2] * uz
if vr < 0.0:
v = wp.vec3(v[0] - 1.4 * vr * ux, v[1], v[2] - 1.4 * vr * uz)
else:
# The lip itself, as a circle: this is what makes a rim-out look like
# a rim-out instead of a ball passing through a hole.
cx = bk_x + ux * bk_r
cz = bk_z + uz * bk_r
ex = p[0] - cx
ey = p[1] - bk_rim
ez = p[2] - cz
el = wp.sqrt(ex * ex + ey * ey + ez * ez)
if el < radius and el > 1.0e-6:
nx = ex / el
ny = ey / el
nz = ez / el
p = wp.vec3(cx + nx * radius, bk_rim + ny * radius, cz + nz * radius)
vn = v[0] * nx + v[1] * ny + v[2] * nz
if vn < 0.0:
v = wp.vec3(v[0] - 1.5 * vn * nx, v[1] - 1.5 * vn * ny, v[2] - 1.5 * vn * nz)
bv[0] = v
bp[0] = p
# ---- sheet state --------------------------------------------------------------
wp.init()
device = wp.get_preferred_device()
xs = np.linspace(-SPAN / 2, SPAN / 2, N + 1, dtype=np.float32)
gx, gz = np.meshgrid(xs, xs)
r2 = (gx / (SPAN / 2)) ** 2 + (gz / (SPAN / 2)) ** 2
gy = CATCH_Y - 0.5 * (CLOTH - SPAN) * np.clip(1.0 - r2, 0.0, 1.0)
p0 = np.stack([gx, gy, gz], axis=-1).reshape(-1, 3).astype(np.float32)
CORNERS = [(0, 0), (N, 0), (0, N), (N, N)]
anchor_idx_np = np.array([iy * (N + 1) + ix for ix, iy in CORNERS], dtype=np.int32)
anchor_local = np.array([[float(xs[ix]), 0.0, float(xs[iy])] for ix, iy in CORNERS],
dtype=np.float32)
p0[anchor_idx_np] = anchor_local + np.array([0.0, CATCH_Y, 0.0], np.float32)
p0[:, 0] += HOME[0]
p0[:, 2] += HOME[1]
fx = np.linspace(-CLOTH / 2, CLOTH / 2, N + 1, dtype=np.float32)
mx, mz = np.meshgrid(fx, fx)
flat = np.stack([mx, np.zeros_like(mx), mz], axis=-1).reshape(-1, 3)
lra_np = np.linalg.norm(flat[:, None, :] - flat[anchor_idx_np][None, :, :],
axis=2).astype(np.float32).reshape(-1)
im_np = np.full(V, 1.0, dtype=np.float32)
im_np[anchor_idx_np] = 0.0
pos = wp.array(p0, dtype=wp.vec3, device=device)
prev = wp.array(p0, dtype=wp.vec3, device=device)
pred = wp.zeros(V, dtype=wp.vec3, device=device)
scratch = wp.zeros(V, dtype=wp.vec3, device=device)
nrm = wp.zeros(V, dtype=wp.vec3, device=device)
im = wp.array(im_np, dtype=float, device=device)
lra = wp.array(lra_np, dtype=float, device=device)
anchor_idx = wp.array(anchor_idx_np, dtype=int, device=device)
anchor_tgt = wp.array(p0[anchor_idx_np].copy(), dtype=wp.vec3, device=device)
bp = wp.array(np.array([CANNON], np.float32), dtype=wp.vec3, device=device)
bv = wp.zeros(1, dtype=wp.vec3, device=device)
bpred = wp.zeros(1, dtype=wp.vec3, device=device)
impulse = wp.zeros(1, dtype=wp.vec3, device=device)
# What each CORNER is carrying, accumulated over a whole tick (see the long-range
# attachment block in solve()). The two cloths get their own: the sheet's corners
# are the arms and the net's are the drones, and feeding one rig the other's load
# is exactly the bug a shared accumulator would invite.
anchor_f = wp.zeros(4, dtype=wp.vec3, device=device)
solve_graph = None
if device.is_cuda and ITERS % 2 == 0:
try:
with wp.ScopedCapture(device) as _cap:
_a, _b = pred, scratch
for _ in range(ITERS):
wp.launch(solve, dim=V, device=device,
inputs=[_a, _b, im, N, N, REST, bpred, bp, BALL_R + 0.012, MU,
prev, anchor_tgt, lra, impulse, anchor_f,
M_PARTICLE, 0.35])
_a, _b = _b, _a
solve_graph = _cap.graph
except Exception as exc: # noqa: BLE001 - capture is optional
print(f" (no graph capture: {exc})")
# ---- the net ------------------------------------------------------------------
# The same cloth a second time: its own arrays, the same kernels, its own graph.
# Both cloths accumulate into the SAME impulse buffer, which is what the ball
# reads -- the ball can only be inside one of them at a time, so whichever is far
# away contributes exactly zero and nothing has to decide which is which. That is
# also what lets the sheet keep running after the throw, so it hangs and swings
# under the arms while the drones work, instead of freezing.
netp = None
net_graph = None
if DRONES:
NR = NET_RES
NVV = (NR + 1) * (NR + 1)
NET_REST = NET_CLOTH / NR
NET_M = NET_KG / NVV
_nxs = np.linspace(-NET_SPAN / 2, NET_SPAN / 2, NR + 1, dtype=np.float32)
_ngx, _ngz = np.meshgrid(_nxs, _nxs)
_nr2 = (_ngx / (NET_SPAN / 2)) ** 2 + (_ngz / (NET_SPAN / 2)) ** 2
_ngy = -0.5 * (NET_CLOTH - NET_SPAN) * np.clip(1.0 - _nr2, 0.0, 1.0)
net_p0 = np.stack([_ngx, _ngy, _ngz], axis=-1).reshape(-1, 3).astype(np.float32)
NET_CORNERS = [(0, 0), (NR, 0), (0, NR), (NR, NR)]
net_anchor_idx_np = np.array([iy * (NR + 1) + ix for ix, iy in NET_CORNERS],
dtype=np.int32)
# The drones sit at the corners of this square; the net's own anchors hang
# NET_HOOK below them, so the cloth is slung under four hulls rather than
# growing out of their centres.
drone_local = np.array([[float(_nxs[ix]), 0.0, float(_nxs[iy])]
for ix, iy in NET_CORNERS], dtype=np.float32)
net_p0[net_anchor_idx_np] = drone_local
# A provisional station: the settle below measures how far the net actually
# hangs and the whole thing is then moved to where that measurement says the
# drones have to take station.
_, _, _, _lob_net_p, _ = lob_landmarks()
STAGE = np.array([_lob_net_p[0] + DRONE_PARK_OUT,
NET_Y + 0.5 * (NET_CLOTH - NET_SPAN) + NET_HOOK + DRONE_PARK_UP,
_lob_net_p[2]])
net_p0 += (STAGE + np.array([0.0, -NET_HOOK, 0.0])).astype(np.float32)
_nfx = np.linspace(-NET_CLOTH / 2, NET_CLOTH / 2, NR + 1, dtype=np.float32)
_nmx, _nmz = np.meshgrid(_nfx, _nfx)
_nflat = np.stack([_nmx, np.zeros_like(_nmx), _nmz], axis=-1).reshape(-1, 3)
net_lra_np = np.linalg.norm(_nflat[:, None, :] - _nflat[net_anchor_idx_np][None, :, :],
axis=2).astype(np.float32).reshape(-1)
net_im_np = np.full(NVV, 1.0, dtype=np.float32)
net_im_np[net_anchor_idx_np] = 0.0
netp = wp.array(net_p0, dtype=wp.vec3, device=device)
netprev = wp.array(net_p0, dtype=wp.vec3, device=device)
netpred = wp.zeros(NVV, dtype=wp.vec3, device=device)
netscratch = wp.zeros(NVV, dtype=wp.vec3, device=device)
netnrm = wp.zeros(NVV, dtype=wp.vec3, device=device)
net_im = wp.array(net_im_np, dtype=float, device=device)
net_lra = wp.array(net_lra_np, dtype=float, device=device)
net_anchor_idx = wp.array(net_anchor_idx_np, dtype=int, device=device)
net_anchor_tgt = wp.array(net_p0[net_anchor_idx_np].copy(), dtype=wp.vec3,
device=device)
net_anchor_f = wp.zeros(4, dtype=wp.vec3, device=device)
net_from = net_p0[net_anchor_idx_np].astype(np.float64)
net_to = net_from.copy()
NET_CENTRE_I = (NR // 2) * (NR + 1) + NR // 2
def _net_kernels():
wp.launch(cloth_integrate, dim=NVV, device=device,
inputs=[netp, netprev, netpred, net_im, DT, DAMPING])
wp.launch(set_anchors, dim=4, device=device,
inputs=[netpred, net_anchor_idx, net_anchor_tgt])
a, b = netpred, netscratch
for _ in range(NET_ITERS):
wp.launch(solve, dim=NVV, device=device,
inputs=[a, b, net_im, NR, NR, NET_REST, bpred, bp,
BALL_R + 0.012, MU, netprev, net_anchor_tgt,
net_lra, impulse, net_anchor_f, NET_M, 0.35])
a, b = b, a
wp.copy(netp, a)
# The WHOLE substep is captured, not just the solve loop -- integrate, set
# the anchors, iterate, copy back. That matters more than anything about the
# solver here: measured, the second cloth cost +3.0 ms/tick and NONE of it
# was arithmetic (dropping the iterations from 24 to 8 changed the loop by
# 0.05 ms, and halving the resolution made it slower). It was 12 substeps of
# Python launch overhead, and a graph is the way to stop paying it. The
# anchors are the one host input, and they are uploaded once per tick rather
# than once per substep: the formation moves 2 cm in a tick, which the cloth
# does not need walked in for it the way a 4.5 m/s arm does.
#
# And there is a second graph holding the WHOLE TICK of them. The net's
# substeps only have to be interleaved with the sheet's while the ball can
# touch the net, because that is the only thing the two cloths share: one
# impulse accumulator, which the far cloth writes nothing to. So while the
# ball is more than NET_NEAR from the net the twelve substeps go in one
# launch instead of twelve, and the interleaved path is paid for only over
# the tenth of a second the catch actually takes.
net_bulk_graph = None
if device.is_cuda and NET_ITERS % 2 == 0:
try:
with wp.ScopedCapture(device) as _cap:
_net_kernels()
net_graph = _cap.graph
with wp.ScopedCapture(device) as _cap:
for _ in range(SUBSTEPS):
_net_kernels()
net_bulk_graph = _cap.graph
except Exception as exc: # noqa: BLE001 - capture is optional
print(f" (no net graph capture: {exc})")
def net_sim():
"""One substep of the net. The same cloth step as the sheet's, on the
other arrays, in one launch."""
if net_graph is not None:
wp.capture_launch(net_graph)
else:
_net_kernels()
def net_bulk():
"""A whole tick of the net at once, for when the ball is nowhere near."""
if net_bulk_graph is not None:
wp.capture_launch(net_bulk_graph)
else:
for _ in range(SUBSTEPS):
_net_kernels()
def net_anchors_to(target):
net_anchor_tgt.assign(np.asarray(target, np.float32))
# Let it hang before anything else happens, and MEASURE the hang. Everything
# downstream needs that number -- where the drones have to wait so the bowl
# is at the catch altitude, and where the bowl is when the ball arrives --
# and it is a property of the cloth, not something to assert about it.
net_anchors_to(net_to)
for _ in range(int(0.60 / DT)):
net_sim()
NET_HANG = float(STAGE[1] - netp.numpy()[NET_CENTRE_I][1])
# And now the station that measurement implies: the bowl DRONE_PARK_UP above
# the catch altitude and DRONE_PARK_OUT downrange of where the lob will cross
# it. Then put the whole thing back down on the pad, where it starts.
STAGE = STAGE + np.array([0.0, (NET_Y + NET_HANG + DRONE_PARK_UP) - STAGE[1], 0.0])
NET_HALF = 0.5 * NET_SPAN
_shift = (DRONE_GROUND - STAGE).astype(np.float32)
netp.assign(netp.numpy() + _shift)
netprev.assign(netprev.numpy() + _shift)
net_from = net_from + _shift
net_to = net_from.copy()
net_anchors_to(net_to)
for _ in range(int(0.50 / DT)): # and let it pool on the concrete
net_sim()
NET_SETTLED = netp.numpy().copy() # the resting pose, for R in the window
# ---- the rig ------------------------------------------------------------------
class Rig:
"""Four anchors on a square that translates. Position and velocity of the
centre are the state; the corners ride along rigidly."""
def __init__(self):
self.p = np.array([HOME[0], CATCH_Y, HOME[1]])
self.v = np.zeros(3)
self.travel = 0.0
self.peak_speed = 0.0
self.starved = 0 # frames the speed cap bit
self.reach_capped = 0 # frames the arms' own envelope bit
self.p_pending = self.p.copy()
self.spread = 0.0
self.tilt = 0.0 # rad; + raises the downrange side
self.tilt_dir = np.array([1.0, 0.0, 0.0]) # horizontal, unit, downrange
self.peak_tilt = 0.0
def targets(self):
# Spreading the anchors re-tensions the sheet. Left slack it simply
# crumples over the ball and hides it, which is the same lesson the
# throw taught in reverse: a slack sheet has no shape to speak of.
scale = 1.0 + self.spread * (CLOTH - SPAN) / SPAN
c = anchor_local * scale
if self.tilt != 0.0:
# Per-corner height offsets on the UNSCALED square, so re-tensioning
# for the throw cannot also deepen the scoop. Corners downrange of
# the centre go up, corners uprange go down, and the centre -- which
# is what the whole approach was planned to -- stays on CATCH_Y.
s = (anchor_local[:, 0] * self.tilt_dir[0]
+ anchor_local[:, 2] * self.tilt_dir[2])
c = c.copy()
c[:, 1] += math.tan(self.tilt) * s
return (c + self.p).astype(np.float32)
def set_tilt(self, angle, direction=None):
if direction is not None:
d = np.array([direction[0], 0.0, direction[2]], float)
n = float(np.linalg.norm(d))
if n > 1e-6:
self.tilt_dir = d / n
self.tilt = float(angle)
self.peak_tilt = max(self.peak_tilt, abs(self.tilt))
def goto(self, dt, p_cmd, v_cmd):
"""Move toward the commanded pose, capped at ARM_SPEED."""
step = p_cmd - self.p
d = float(np.linalg.norm(step))
if d > ARM_SPEED * dt:
step *= ARM_SPEED * dt / d
self.starved += 1
self.p_pending = self.p + step
self.v = step / dt if dt > 0 else v_cmd
self._commit()
def _commit(self):
home3 = np.array([HOME[0], CATCH_Y, HOME[1]])
nxt = self.p_pending
off = nxt - home3