File size: 77,440 Bytes
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859a75d
 
29c82ca
859a75d
29c82ca
 
 
 
 
 
 
 
859a75d
 
29c82ca
 
 
 
 
 
859a75d
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859a75d
 
29c82ca
 
 
859a75d
 
 
 
 
 
 
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d202b8
b8e790e
29c82ca
 
 
 
 
4d202b8
 
b8e790e
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
b8e790e
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d202b8
 
 
 
29c82ca
 
 
 
 
 
 
4d202b8
 
29c82ca
4d202b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67c2359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d202b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8478b27
 
 
 
 
 
 
4d202b8
29c82ca
 
 
 
 
 
 
 
 
4d202b8
 
67c2359
29c82ca
 
 
 
 
 
4d202b8
 
 
67c2359
 
 
29c82ca
 
 
 
 
 
 
 
 
 
4d202b8
29c82ca
 
 
4d202b8
 
 
 
 
 
8478b27
 
 
 
 
 
 
 
 
29c82ca
 
 
67c2359
 
 
8478b27
 
29c82ca
 
 
 
 
67c2359
4d202b8
 
 
8478b27
4d202b8
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d202b8
 
67c2359
29c82ca
 
4d202b8
67c2359
 
 
 
 
 
 
4d202b8
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
4d202b8
 
67c2359
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859a75d
 
 
 
 
 
 
 
 
 
67c2359
859a75d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67c2359
859a75d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b11c98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49307ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b11c98
 
 
 
 
 
 
49307ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7f0b44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29c82ca
 
c7f0b44
29c82ca
 
c7f0b44
b8e790e
 
c7f0b44
 
b8e790e
29c82ca
b8e790e
c7f0b44
 
 
b8e790e
 
 
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d202b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859a75d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d202b8
29c82ca
 
 
 
4d202b8
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
4d202b8
29c82ca
 
 
 
 
4d202b8
 
 
 
 
 
29c82ca
 
 
 
4d202b8
 
 
 
29c82ca
 
 
 
4d202b8
 
 
 
29c82ca
4d202b8
29c82ca
 
 
 
 
859a75d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b8e790e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
 
 
b8e790e
29c82ca
b8e790e
 
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
 
 
b8e790e
29c82ca
 
b8e790e
 
 
29c82ca
b8e790e
 
 
29c82ca
b8e790e
 
 
 
 
 
 
 
 
29c82ca
b8e790e
 
29c82ca
b8e790e
 
29c82ca
 
b8e790e
 
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
4d202b8
859a75d
4d202b8
49307ad
 
67c2359
4d202b8
 
 
859a75d
 
 
4d202b8
 
67c2359
 
 
 
 
 
 
 
4d202b8
 
 
 
 
29c82ca
4d202b8
67c2359
 
 
 
 
 
859a75d
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
c7f0b44
 
 
 
 
 
49307ad
 
 
 
 
 
 
 
 
 
c7f0b44
49307ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67c2359
 
 
 
 
 
 
 
 
 
c7f0b44
 
 
 
 
 
 
 
 
b8e790e
 
 
 
c7f0b44
49307ad
b8e790e
 
c7f0b44
 
 
b8e790e
 
 
4b11c98
 
 
 
 
 
29c82ca
49307ad
 
 
 
 
 
 
 
29c82ca
49307ad
29c82ca
 
 
4d202b8
 
49307ad
 
 
4d202b8
 
 
 
 
 
 
 
 
859a75d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67c2359
859a75d
29c82ca
 
859a75d
 
 
29c82ca
 
 
 
 
 
 
 
4d202b8
 
67c2359
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d202b8
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d202b8
29c82ca
 
 
 
 
 
 
 
 
 
4d202b8
29c82ca
 
 
 
 
 
 
c7f0b44
 
 
 
 
 
 
 
 
 
 
4d202b8
 
 
 
 
 
 
 
 
 
29c82ca
4d202b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7f0b44
 
 
 
 
4d202b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7f0b44
49307ad
 
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d202b8
 
 
 
c7f0b44
29c82ca
 
c7f0b44
 
 
 
 
 
 
 
 
4d202b8
 
 
 
 
 
29c82ca
 
 
 
 
 
c7f0b44
4d202b8
 
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
4d202b8
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d202b8
 
29c82ca
 
 
 
 
 
4d202b8
 
 
29c82ca
 
 
4d202b8
29c82ca
 
 
4d202b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29c82ca
 
 
4d202b8
 
29c82ca
4d202b8
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7f0b44
 
 
 
 
859a75d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7f0b44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
4d202b8
c7f0b44
29c82ca
 
 
 
 
 
4d202b8
 
 
c7f0b44
 
 
 
 
29c82ca
 
 
 
c7f0b44
 
29c82ca
 
 
 
4d202b8
 
29c82ca
4d202b8
29c82ca
 
 
4d202b8
29c82ca
 
 
4d202b8
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
859a75d
29c82ca
 
 
859a75d
29c82ca
 
 
 
 
 
 
 
 
 
 
859a75d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
4d202b8
859a75d
29c82ca
 
 
 
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
"""Vector toolpath generation: shapely layer polygons -> printable move lists.

Replaces the pixel-raster core of the old TIFF pipeline. All geometry lives in
world-XY millimetres; every motion segment is a 5-tuple
``(x0, y0, x1, y1, color)`` where color 255 means the dispensing valve is open
and 0 means travel. `fil_width` is both the raster line spacing and the
filament width.
"""

from __future__ import annotations

import math
from dataclasses import dataclass

from shapely import prepare
from shapely.affinity import rotate, translate
from shapely.geometry import LineString, MultiLineString, MultiPolygon, Point, box
from shapely.geometry.polygon import orient
from shapely.ops import linemerge, unary_union

from stl_slicer import LayerStack, _as_multipolygon


EPS = 1e-6

RASTER_PATTERN_SAME_DIRECTION = "X-direction raster"
RASTER_PATTERN_Y_DIRECTION = "Y-direction raster"
RASTER_PATTERN_WOODPILE = "90° Woodpile raster"
RASTER_PATTERN_DIAGONAL_WOODPILE = "45° Woodpile raster"
RASTER_PATTERN_RECTANGULAR_SPIRAL = "Rectangular Spiral raster"
RASTER_PATTERN_CIRCLE_SPIRAL = "Circle Spiral raster"
RASTER_PATTERN_CHOICES = (
    RASTER_PATTERN_SAME_DIRECTION,
    RASTER_PATTERN_Y_DIRECTION,
    RASTER_PATTERN_WOODPILE,
    RASTER_PATTERN_DIAGONAL_WOODPILE,
    RASTER_PATTERN_RECTANGULAR_SPIRAL,
    RASTER_PATTERN_CIRCLE_SPIRAL,
)

Seg = tuple[float, float, float, float, int]


@dataclass(slots=True)
class ContourSource:
    owner_idx: int
    stack: LayerStack


def _normalize_raster_pattern(pattern: str | None) -> str:
    if pattern in RASTER_PATTERN_CHOICES:
        return pattern
    return RASTER_PATTERN_SAME_DIRECTION


def _raster_axis_for_pattern(pattern: str, layer_number: int) -> str:
    if pattern == RASTER_PATTERN_Y_DIRECTION:
        return "Y"
    if pattern == RASTER_PATTERN_WOODPILE and layer_number % 2 == 1:
        return "Y"
    if pattern == RASTER_PATTERN_DIAGONAL_WOODPILE and layer_number % 4 == 2:
        return "Y"
    return "X"


def _diagonal_layer_angle(layer_number: int) -> float | None:
    """Diagonal-woodpile raster angle for a layer; None for the axis layers.

    The pattern cycles 0, 45, 90, 135 degrees: axis layers (0/90) go through
    the regular X/Y raster; only the odd layers need the rotated raster.
    """
    phase = layer_number % 4
    if phase == 1:
        return 45.0
    if phase == 3:
        return 135.0
    return None


def _iter_linestrings(geometry: object):
    if geometry is None or getattr(geometry, "is_empty", True):
        return
    geom_type = geometry.geom_type
    if geom_type == "LineString":
        yield geometry
    elif geom_type in ("MultiLineString", "GeometryCollection"):
        for part in geometry.geoms:
            yield from _iter_linestrings(part)


def _iter_coords(geometry: object):
    if geometry is None or getattr(geometry, "is_empty", True):
        return
    geom_type = geometry.geom_type
    if geom_type == "Point":
        yield geometry.x, geometry.y
    elif geom_type == "LineString":
        for coord in geometry.coords:
            yield coord[0], coord[1]
    elif geom_type in ("MultiPoint", "MultiLineString", "GeometryCollection"):
        for part in geometry.geoms:
            yield from _iter_coords(part)


def _point_distance_sq(ax: float, ay: float, bx: float, by: float) -> float:
    return (ax - bx) ** 2 + (ay - by) ** 2


def _closest_point_on_segment(
    px: float,
    py: float,
    ax: float,
    ay: float,
    bx: float,
    by: float,
) -> tuple[float, float, float]:
    dx = bx - ax
    dy = by - ay
    length_sq = dx * dx + dy * dy
    if length_sq == 0:
        return ax, ay, 0.0
    t = ((px - ax) * dx + (py - ay) * dy) / length_sq
    t = max(0.0, min(1.0, t))
    return ax + t * dx, ay + t * dy, t


def _append_colored_segment(
    segments: list[Seg],
    start_x: float,
    start_y: float,
    end_x: float,
    end_y: float,
    color: int,
) -> None:
    if start_x == end_x and start_y == end_y:
        return

    if segments:
        prev_start_x, prev_start_y, prev_end_x, prev_end_y, prev_color = segments[-1]
        if (
            prev_color == color
            and prev_end_x == start_x
            and prev_end_y == start_y
        ):
            prev_dx = prev_end_x - prev_start_x
            prev_dy = prev_end_y - prev_start_y
            next_dx = end_x - start_x
            next_dy = end_y - start_y
            if abs((prev_dx * next_dy) - (prev_dy * next_dx)) < 1e-9:
                segments[-1] = (
                    prev_start_x,
                    prev_start_y,
                    end_x,
                    end_y,
                    color,
                )
                return

    segments.append((start_x, start_y, end_x, end_y, color))


def _append_relative_move(
    output_list: list[dict],
    current_x: float,
    current_y: float,
    target_x: float,
    target_y: float,
    color: int,
    z_step: float | None = None,
) -> tuple[float, float]:
    # Round away GEOS float noise (sub-micron residues, -0.0) so the emitted
    # G-code stays clean; position tracking keeps the exact targets. Six
    # decimals (1 nm) is far below any printable resolution, and rounding here
    # also guarantees the writer can format every delta without scientific
    # notation (which G-code parsers misread: "X-5.1e-08" parses as X-5.1).
    dx = round(target_x - current_x, 6) + 0.0
    dy = round(target_y - current_y, 6) + 0.0
    if dx == 0 and dy == 0 and z_step is None:
        return current_x, current_y
    move = {"X": dx, "Y": dy, "Color": color}
    if z_step is not None:
        move["Z"] = z_step
    output_list.append(move)
    return target_x, target_y


def _chord_runs(
    region: MultiPolygon,
    axis: str,
    coord: float,
    eps: float = EPS,
) -> list[tuple[float, float]]:
    """Sorted, merged (lo, hi) intervals where an axis-aligned scanline is inside `region`.

    Axis "X": horizontal scanline at y=coord, runs measured along X.
    Axis "Y": vertical scanline at x=coord, runs measured along Y.
    """
    if region is None or region.is_empty:
        return []

    min_x, min_y, max_x, max_y = region.bounds
    if axis == "Y":
        if coord < min_x - eps or coord > max_x + eps:
            return []
        line = LineString([(coord, min_y - 1.0), (coord, max_y + 1.0)])
        index = 1
    else:
        if coord < min_y - eps or coord > max_y + eps:
            return []
        line = LineString([(min_x - 1.0, coord), (max_x + 1.0, coord)])
        index = 0

    runs: list[tuple[float, float]] = []
    for piece in _iter_linestrings(region.intersection(line)):
        if piece.length <= eps:
            continue
        values = [coords[index] for coords in piece.coords]
        runs.append((min(values), max(values)))

    runs.sort()
    merged: list[tuple[float, float]] = []
    for lo, hi in runs:
        if merged and lo <= merged[-1][1] + eps:
            merged[-1] = (merged[-1][0], max(merged[-1][1], hi))
        else:
            merged.append((lo, hi))
    return merged


def _point_along(
    t: float,
    x0: float,
    y0: float,
    x1: float,
    y1: float,
) -> tuple[float, float]:
    if t <= 0.0:
        return x0, y0
    if t >= 1.0:
        return x1, y1
    return x0 + (x1 - x0) * t, y0 + (y1 - y0) * t


def _classify_polyline(
    points: list[tuple[float, float]],
    valve: MultiPolygon,
    eps: float = EPS,
    keep_point=None,
    keep_segment=None,
) -> list[Seg]:
    """Split a motion polyline at valve-boundary crossings and color the pieces.

    Preserves path order (a whole-polyline shapely intersection would not).
    Pieces on the boundary count as printing (`covers`), matching the raster
    convention that boundary-grazing sweeps dispense. `keep_point(x, y)` can
    additionally veto dispensing for a piece (evaluated at its midpoint) —
    used for partial infill. `keep_segment(x0, y0, x1, y1)` vetoes a whole
    source segment BEFORE it is split, so a vetoed transition move stays
    valve-off even where the material boundary cuts through it. The motion
    is unaffected by either gate.
    """
    segments: list[Seg] = []
    has_valve = valve is not None and not valve.is_empty
    if has_valve:
        prepare(valve)
        boundary = valve.boundary

    for (x0, y0), (x1, y1) in zip(points, points[1:]):
        dx = x1 - x0
        dy = y1 - y0
        seg_len = math.hypot(dx, dy)
        if seg_len <= eps:
            continue

        if not has_valve or (
            keep_segment is not None and not keep_segment(x0, y0, x1, y1)
        ):
            _append_colored_segment(segments, x0, y0, x1, y1, 0)
            continue

        ts = {0.0, 1.0}
        crossings = LineString([(x0, y0), (x1, y1)]).intersection(boundary)
        for cx, cy in _iter_coords(crossings):
            t = ((cx - x0) * dx + (cy - y0) * dy) / (seg_len * seg_len)
            ts.add(max(0.0, min(1.0, t)))

        t_eps = eps / seg_len
        ordered = sorted(ts)
        deduped = [ordered[0]]
        for t in ordered[1:]:
            if t - deduped[-1] > t_eps:
                deduped.append(t)
            else:
                deduped[-1] = max(deduped[-1], t)

        for t0, t1 in zip(deduped, deduped[1:]):
            if (t1 - t0) * seg_len <= eps:
                continue
            mid_x, mid_y = _point_along((t0 + t1) / 2.0, x0, y0, x1, y1)
            dispensing = valve.covers(Point(mid_x, mid_y))
            if dispensing and keep_point is not None:
                dispensing = keep_point(mid_x, mid_y)
            color = 255 if dispensing else 0
            start = _point_along(t0, x0, y0, x1, y1)
            end = _point_along(t1, x0, y0, x1, y1)
            _append_colored_segment(segments, *start, *end, color)

    return segments


def _infill_line_keep(fraction: float):
    """Line-selection gate for partial infill; None means print every line.

    Uses a Bresenham-style distribution so kept lines spread evenly: line k
    dispenses iff floor((k+1)*f) > floor(k*f). At f=0.5 every other line
    prints; at f=1 all do; at f=0 none. `k` is a global grid index, so the
    same lines are selected across layers, across split pieces sharing a
    parent frame, and across shapes sharing reference motion — the motion
    path itself is never affected.
    """
    if fraction >= 1.0:
        return None
    fraction = max(0.0, float(fraction))

    def keep(line_index: int) -> bool:
        return math.floor((line_index + 1) * fraction) - math.floor(line_index * fraction) >= 1

    return keep


def _combined_infill_keep(fractions):
    """Union infill gate over every shape sharing the motion; None = keep all.

    A grid line is traversed iff ANY shape's infill pattern dispenses on it —
    lines nobody prints are dropped from the MOTION entirely instead of being
    swept valve-off (e.g. every shape at 50% infill halves the path). Every
    shape must be given the same fraction list so the shared motion stays
    identical across heads.
    """
    keeps = []
    for fraction in fractions or []:
        keep = _infill_line_keep(fraction)
        if keep is None:
            return None  # someone prints every line: no motion line can drop
        keeps.append(keep)
    if not keeps:
        return None

    def keep_any(line_index: int) -> bool:
        return any(keep(line_index) for keep in keeps)

    return keep_any


def _scan_anchor(lo: float, hi: float, fil_width: float) -> float:
    """First scanline position of the centred grid spanning [lo, hi].

    The grid is centred: when the extent is not an exact multiple of
    fil_width, the leftover slack is split evenly between both edges.
    """
    extent = hi - lo
    if extent < fil_width:
        return (lo + hi) / 2.0
    count = int(math.floor(extent / fil_width + 1e-9))
    slack = max(0.0, extent - count * fil_width)
    return lo + slack / 2.0 + fil_width / 2.0


def _scan_coords(
    lo: float,
    hi: float,
    fil_width: float,
    anchor: float | None = None,
) -> list[float]:
    """Scanline positions in [lo, hi], fil_width apart; at least one line.

    Without `anchor` the set is centred in [lo, hi] (always at least one
    line). With `anchor` the lines lie on the global grid
    {anchor + k*fil_width}: grid-split pieces anchored to their parent's
    frame then raster on one continuous line grid, so the assembled seams
    keep an exact one-fil-width pitch instead of drifting by each piece's own
    quantization slack. The interval is half-open at `hi` so a line exactly
    on a cut belongs to exactly one piece, and a sliver too thin to contain a
    grid line gets none — the neighbouring grid line (at most one fil away)
    covers it with filament width.
    """
    if anchor is None:
        anchor = _scan_anchor(lo, hi, fil_width)
        if hi - lo < fil_width:
            return [anchor]
        count = int(math.floor((hi - lo) / fil_width + 1e-9))
        return [anchor + index * fil_width for index in range(count)]

    # The epsilon is in grid-cell units and must swamp float noise from the
    # anchor/edge arithmetic: a piece whose material starts EXACTLY on a grid
    # line (a split cut on the line) can compute (lo-anchor)/fil as
    # -1.0000000000000009, and a 1e-9 epsilon then ceils the boundary line
    # away — nobody prints it and every assembled seam gets a one-fil gap.
    k_lo = math.ceil((lo - anchor) / fil_width - 1e-6)
    k_hi = math.floor((hi - anchor) / fil_width - 1e-6)
    return [anchor + k * fil_width for k in range(int(k_lo), int(k_hi) + 1)]


def _axis_raster_segments(
    motion: MultiPolygon,
    valve: MultiPolygon,
    fil_width: float,
    axis: str,
    reverse_order: bool = False,
    start_forward: bool = True,
    scan_anchor: float | None = None,
    infill_keep=None,
    motion_keep=None,
) -> list[Seg]:
    """Snake raster of `motion`, dispensing only inside `valve`.

    Axis "X" sweeps along X with rows stacked in Y; axis "Y" sweeps along Y
    with columns stacked in X. Each sweep spans from the first to the last
    motion chord (crossing interior gaps valve-off) and gets a fil_width
    valve-settle travel buffer before and after. `scan_anchor` pins the
    scanlines to a global grid (see `_scan_coords`). `infill_keep(k)` gates
    dispensing per grid line for partial infill: skipped lines are still
    swept with the valve closed. `motion_keep(k)` drops a grid line from the
    MOTION entirely — used when NO shape sharing the motion dispenses there
    (the union of every head's infill pattern), so nobody sweeps dead lines.
    """
    if motion is None or motion.is_empty:
        return []

    min_x, min_y, max_x, max_y = motion.bounds
    if axis == "Y":
        scan_lo, scan_hi = min_x, max_x
    else:
        scan_lo, scan_hi = min_y, max_y

    coords = _scan_coords(scan_lo, scan_hi, fil_width, anchor=scan_anchor)
    if reverse_order:
        coords = coords[::-1]

    # Grid index base: orientation-independent, and shared across pieces when
    # anchored to a common frame, so infill line selection lines up at seams.
    index_base = scan_anchor if scan_anchor is not None else _scan_anchor(
        scan_lo, scan_hi, fil_width
    )

    # A grid line can graze the material boundary from OUTSIDE by float ulps
    # (split cuts sit exactly on grid lines, and the piece's material edge IS
    # the cut): probe the chords a hair inside the bounds so the boundary
    # sweep is still found, while emitting at the true grid coordinate.
    # A dropped boundary sweep prints a one-fil gap at every assembled seam.
    probe_eps = fil_width * 1e-6
    probe_lo = min(scan_lo + probe_eps, (scan_lo + scan_hi) / 2.0)
    probe_hi = max(scan_hi - probe_eps, (scan_lo + scan_hi) / 2.0)

    segments: list[Seg] = []
    sweep_number = 0
    for coord in coords:
        line_index = int(round((coord - index_base) / fil_width))
        if motion_keep is not None and not motion_keep(line_index):
            continue  # no head prints this line: drop it from the motion
        probe = min(max(coord, probe_lo), probe_hi)
        motion_runs = _chord_runs(motion, axis, probe)
        if not motion_runs:
            continue

        sweep_lo = motion_runs[0][0]
        sweep_hi = motion_runs[-1][1]
        if infill_keep is not None and not infill_keep(line_index):
            valve_runs: list[tuple[float, float]] = []
        else:
            valve_runs = []
            for lo, hi in _chord_runs(valve, axis, probe):
                lo = max(lo, sweep_lo)
                hi = min(hi, sweep_hi)
                if hi - lo > EPS:
                    valve_runs.append((lo, hi))

        def emit(a: float, b: float, color: int) -> None:
            if axis == "Y":
                _append_colored_segment(segments, coord, a, coord, b, color)
            else:
                _append_colored_segment(segments, a, coord, b, coord, color)

        forward = (sweep_number % 2 == 0) == start_forward
        sweep_number += 1

        if forward:
            emit(sweep_lo - fil_width, sweep_lo, 0)
            current = sweep_lo
            for lo, hi in valve_runs:
                if lo - current > EPS:
                    emit(current, lo, 0)
                    current = lo
                start = max(lo, current)
                if hi - start > EPS:
                    emit(start, hi, 255)
                    current = hi
            if sweep_hi - current > EPS:
                emit(current, sweep_hi, 0)
                current = sweep_hi
            emit(current, current + fil_width, 0)
        else:
            emit(sweep_hi + fil_width, sweep_hi, 0)
            current = sweep_hi
            for lo, hi in reversed(valve_runs):
                if current - hi > EPS:
                    emit(current, hi, 0)
                    current = hi
                start = min(hi, current)
                if start - lo > EPS:
                    emit(start, lo, 255)
                    current = lo
            if current - sweep_lo > EPS:
                emit(current, sweep_lo, 0)
                current = sweep_lo
            emit(current, current - fil_width, 0)

    return segments


def _oriented_axis_raster_segments(
    motion: MultiPolygon,
    valve: MultiPolygon,
    fil_width: float,
    axis: str,
    current_x: float,
    current_y: float,
    prefer_default: bool = False,
    scan_anchor: float | None = None,
    infill_keep=None,
    motion_keep=None,
) -> list[Seg]:
    """Pick the raster orientation whose start is nearest the current position."""
    default_segments = _axis_raster_segments(
        motion,
        valve,
        fil_width,
        axis,
        scan_anchor=scan_anchor,
        infill_keep=infill_keep,
        motion_keep=motion_keep,
    )
    if prefer_default or not default_segments:
        return default_segments

    candidates: list[list[Seg]] = [default_segments]
    for reverse_order in (False, True):
        for start_forward in (False, True):
            segments = _axis_raster_segments(
                motion,
                valve,
                fil_width,
                axis,
                reverse_order=reverse_order,
                start_forward=start_forward,
                scan_anchor=scan_anchor,
                infill_keep=infill_keep,
                motion_keep=motion_keep,
            )
            if segments and segments not in candidates:
                candidates.append(segments)

    return min(
        candidates,
        key=lambda segments: _point_distance_sq(
            current_x,
            current_y,
            segments[0][0],
            segments[0][1],
        ),
    )


def _rotated_raster_segments(
    motion: MultiPolygon,
    valve: MultiPolygon,
    fil_width: float,
    angle_degrees: float,
    current_x: float,
    current_y: float,
    prefer_default: bool,
    frame: tuple[float, float, float, float],
    infill_keep=None,
    motion_keep=None,
) -> list[Seg]:
    """Snake raster at an arbitrary angle, reusing the axis raster machinery.

    Rotates motion/valve by -angle about the scan frame's centre, rasters
    with the standard X-axis sweep (snake, buffers, valve gating, scan grid,
    infill selection all identical), then rotates the segments back. The
    pivot and the rotated scan anchor both derive from the shared frame, so
    split pieces and reference-motion shapes keep one continuous diagonal
    line grid across seams.
    """
    if motion is None or motion.is_empty:
        return []

    pivot_x = (frame[0] + frame[2]) / 2.0
    pivot_y = (frame[1] + frame[3]) / 2.0
    pivot = (pivot_x, pivot_y)

    rotated_motion = _as_multipolygon(rotate(motion, -angle_degrees, origin=pivot))
    if valve is motion:
        rotated_valve = rotated_motion
    elif valve is None or valve.is_empty:
        rotated_valve = MultiPolygon()
    else:
        rotated_valve = _as_multipolygon(rotate(valve, -angle_degrees, origin=pivot))

    rotated_frame_bounds = rotate(box(*frame), -angle_degrees, origin=pivot).bounds
    anchor = _scan_anchor(rotated_frame_bounds[1], rotated_frame_bounds[3], fil_width)

    theta = math.radians(-angle_degrees)
    cos_f, sin_f = math.cos(theta), math.sin(theta)
    rotated_current_x = pivot_x + (current_x - pivot_x) * cos_f - (current_y - pivot_y) * sin_f
    rotated_current_y = pivot_y + (current_x - pivot_x) * sin_f + (current_y - pivot_y) * cos_f

    segments = _oriented_axis_raster_segments(
        rotated_motion,
        rotated_valve,
        fil_width,
        "X",
        rotated_current_x,
        rotated_current_y,
        prefer_default=prefer_default,
        scan_anchor=anchor,
        infill_keep=infill_keep,
        motion_keep=motion_keep,
    )

    theta_back = math.radians(angle_degrees)
    cos_b, sin_b = math.cos(theta_back), math.sin(theta_back)

    def back(x: float, y: float) -> tuple[float, float]:
        return (
            pivot_x + (x - pivot_x) * cos_b - (y - pivot_y) * sin_b,
            pivot_y + (x - pivot_x) * sin_b + (y - pivot_y) * cos_b,
        )

    return [
        (*back(x0, y0), *back(x1, y1), color)
        for x0, y0, x1, y1, color in segments
    ]


def _extend_polyline_ends(
    points: list[tuple[float, float]],
    length: float,
) -> list[tuple[float, float]]:
    """Extend both polyline ends by `length` along their local directions."""
    if len(points) < 2:
        return points

    (x0, y0), (x1, y1) = points[0], points[1]
    distance = math.hypot(x1 - x0, y1 - y0)
    if distance > 0:
        points = [
            (x0 - (x1 - x0) / distance * length, y0 - (y1 - y0) / distance * length),
            *points,
        ]

    (xa, ya), (xb, yb) = points[-2], points[-1]
    distance = math.hypot(xb - xa, yb - ya)
    if distance > 0:
        points = [
            *points,
            (xb + (xb - xa) / distance * length, yb + (yb - ya) / distance * length),
        ]
    return points


def _rectangular_spiral_polyline(
    bounds: tuple[float, float, float, float],
    fil_width: float,
    reverse: bool = False,
) -> list[tuple[float, float]]:
    """Corner polyline spiralling from the bounds inward (or outward if reversed)."""
    min_x, min_y, max_x, max_y = bounds
    half = fil_width / 2.0
    left = min_x + half
    right = max_x - half
    bottom = min_y + half
    top = max_y - half
    if right < left:
        left = right = (min_x + max_x) / 2.0
    if top < bottom:
        bottom = top = (min_y + max_y) / 2.0

    eps = 1e-9
    points: list[tuple[float, float]] = []

    def add(x: float, y: float) -> None:
        if not points or _point_distance_sq(points[-1][0], points[-1][1], x, y) > eps:
            points.append((x, y))

    while left <= right + eps and bottom <= top + eps:
        add(left, top)
        add(right, top)
        top -= fil_width

        if bottom <= top + eps:
            add(right, bottom)
        right -= fil_width

        if bottom <= top + eps:
            add(left, bottom)
            bottom += fil_width

        if left <= right + eps:
            if bottom <= top + eps:
                add(left, top)
            left += fil_width

    if len(points) == 1:
        center_x, center_y = points[0]
        points = [(center_x - half, center_y), (center_x + half, center_y)]
        if reverse:
            points.reverse()
        return points

    if reverse:
        points.reverse()
    return _extend_polyline_ends(points, half)


def _drop_short_print_runs(segments: list[Seg], min_length: float) -> list[Seg]:
    """Close the valve over isolated dispensing runs shorter than min_length.

    Motion is untouched — only the color flips to travel, so shared-motion
    parallel heads stay in sync. Used against boundary-grazing flicker,
    which no valve can deposit cleanly anyway.
    """
    result = list(segments)
    index = 0
    while index < len(result):
        if result[index][4] != 255:
            index += 1
            continue
        end = index
        run_length = 0.0
        while end < len(result) and result[end][4] == 255:
            x0, y0, x1, y1, _color = result[end]
            run_length += math.hypot(x1 - x0, y1 - y0)
            end += 1
        if run_length < min_length:
            for position in range(index, end):
                x0, y0, x1, y1, _color = result[position]
                result[position] = (x0, y0, x1, y1, 0)
        index = end
    return result


def circle_wall_radius(
    layer: MultiPolygon | None,
    center_x: float,
    center_y: float,
    fil_width: float,
) -> float | None:
    """A layer's outermost COMPLETE ring radius about the ring centre.

    Uses the minimum OUTER-boundary distance (holes ignored; the material
    may sit slightly off the ring centre). When a global grid ring already
    lies close under the boundary it IS the outer ring (no extra wall — a
    custom wall a fraction of a bead away would just double-deposit);
    otherwise the wall sits half a bead inside the closest boundary point.
    Only meaningful when the material surrounds the centre.
    """
    if layer is None or layer.is_empty:
        return None
    center = Point(center_x, center_y)
    for polygon in layer.geoms:
        if not polygon.covers(center):
            continue
        d_min = float(polygon.exterior.distance(center))
        grid_j = int(math.floor(d_min / fil_width - 0.5 + 1e-9))
        if grid_j >= 0:
            # ALWAYS the outermost grid ring that fits: an off-grid
            # edge-hugging wall would land within half a bead of the grid
            # ring below it (visibly "too close"), and suppressing that
            # ring instead leaves a visibly skipped line. Staying on the
            # grid keeps ring spacing uniform; the rim sits at most half a
            # bead further in at unlucky dimensions.
            return (grid_j + 0.5) * fil_width
        wall = d_min - fil_width / 2.0
        return wall if wall > fil_width * 0.25 else None
    return None


def _frame_spiral_bounds(
    frame: tuple[float, float, float, float],
    material_bounds: tuple[float, float, float, float],
    fil_width: float,
) -> tuple[float, float, float, float]:
    """Outer bounds for a rectangular spiral, on the FRAME's loop family.

    The spiral's loops live on the family "frame inset by half + k*fil per
    side" so every layer (and every split sibling) walks the same
    rectangles and walls stack. Loops that enclose this layer's material
    with more than half a bead of margin on EVERY side are pure travel:
    skip them by starting k0 loops in. Returns the frame shrunk by k0*fil
    per side (still on the family).
    """
    frame_left, frame_bottom, frame_right, frame_top = frame
    mat_left, mat_bottom, mat_right, mat_top = material_bounds
    half = fil_width / 2.0
    # Margin between the base loop (frame inset by half) and the material.
    min_margin = min(
        mat_left - (frame_left + half),
        mat_bottom - (frame_bottom + half),
        (frame_right - half) - mat_right,
        (frame_top - half) - mat_top,
    )
    skip = max(0, int(math.ceil((min_margin - half) / fil_width - 1e-9)))
    # Never shrink past the material's own footprint.
    max_skip_x = (frame_right - frame_left - (mat_right - mat_left)) / (2.0 * fil_width)
    max_skip_y = (frame_top - frame_bottom - (mat_top - mat_bottom)) / (2.0 * fil_width)
    skip = min(skip, max(0, int(min(max_skip_x, max_skip_y))))
    inset = skip * fil_width
    return (
        frame_left + inset,
        frame_bottom + inset,
        frame_right - inset,
        frame_top - inset,
    )


def _circle_ring_radii(
    motion: MultiPolygon,
    center_x: float,
    center_y: float,
    fil_width: float,
) -> tuple[list[float], tuple[float, ...]]:
    """Ring radii for one layer, outermost first: perimeter walls + grid fill.

    The outermost revolution is a PERIMETER WALL hugging the layer's material
    edge (its farthest boundary distance minus half a bead), so the printed
    silhouette follows the shape smoothly instead of staircasing by whole
    grid steps. If the layer has a central hole, a matching inner wall hugs
    the hole edge. The fill between the walls comes from a global grid —
    ring j at (j + 1/2) * fil_width from the frame centre, the same grid on
    every layer (and every split sibling) so interior rings stack vertically.
    Grid rings that would overlap a wall bead, or whose circle cannot cross
    the layer's material at all, are skipped instead of traveled.

    Returns (radii outermost-first, wall radii). Walls always dispense even
    under partial infill, like contour tracing.
    """
    if motion is None or motion.is_empty:
        return [], ()

    max_dist = 0.0
    for polygon in motion.geoms:
        for ring in (polygon.exterior, *polygon.interiors):
            for x, y in ring.coords:
                max_dist = max(max_dist, math.hypot(x - center_x, y - center_y))
    if max_dist <= 0.0:
        return [], ()
    min_dist = float(motion.distance(Point(center_x, center_y)))

    pitch = max(float(fil_width), 1e-9)
    half = pitch / 2.0

    outer_wall = max_dist - half
    if outer_wall <= EPS:
        # Material thinner than one bead (e.g. the dome cap): one tiny ring
        # through the middle of it so the layer still gets motion.
        radius = max(max_dist / 2.0, EPS)
        return [radius], (radius,)

    walls = [outer_wall]
    inner_wall: float | None = None
    if min_dist > pitch / 4.0:
        candidate = min_dist + half
        if candidate <= outer_wall - half:
            inner_wall = candidate
            walls.append(candidate)

    grid_hi = outer_wall - half
    grid_lo = (inner_wall + half) if inner_wall is not None else max(min_dist - half, 0.0)
    j_hi = int(math.floor(grid_hi / pitch - 0.5 + 1e-9))
    j_lo = max(0, int(math.ceil(grid_lo / pitch - 0.5 - 1e-9)))

    radii = [outer_wall]
    if j_hi >= j_lo:
        radii.extend((j + 0.5) * pitch for j in range(j_hi, j_lo - 1, -1))
    if inner_wall is not None:
        radii.append(inner_wall)
    return radii, tuple(walls)


def _circle_rings_polyline(
    center_x: float,
    center_y: float,
    radii: list[float],
    pitch: float,
) -> list[tuple[float, float]]:
    """Concentric-ring "spiral": one full circle per radius, in list order.

    Each revolution stays at a CONSTANT radius (so the printed walls are true
    smooth circles); consecutive rings are joined by a radial jump at theta 0,
    which the caller classifies as valve-off travel.
    """
    pitch = max(float(pitch), 1e-9)
    points: list[tuple[float, float]] = []
    for radius in radii:
        if radius <= 0.0:
            continue
        # Sample roughly one pitch of arc length per step, at least 20/ring.
        d_theta = min(math.pi / 10.0, pitch / max(radius, pitch))
        steps = max(8, int(math.ceil((2.0 * math.pi) / d_theta)))
        for index in range(steps + 1):
            theta = (2.0 * math.pi) * index / steps
            points.append(
                (
                    center_x + (radius * math.cos(theta)),
                    center_y + (radius * math.sin(theta)),
                )
            )
    return points


def _contour_area2(points: list[tuple[float, float]]) -> float:
    return sum(
        x0 * y1 - x1 * y0
        for (x0, y0), (x1, y1) in zip(points, points[1:])
    )


def _contour_sort_key(points: list[tuple[float, float]]) -> tuple[float, float, float]:
    xs = [point[0] for point in points]
    ys = [point[1] for point in points]
    return (-abs(_contour_area2(points)), min(ys), min(xs))


def _layer_contour_loops(layer: MultiPolygon) -> list[list[tuple[float, float]]]:
    """Closed contour loops of a layer: exterior rings first, holes after."""
    loops: list[list[tuple[float, float]]] = []
    for polygon in layer.geoms:
        oriented = orient(polygon)
        for ring in (oriented.exterior, *oriented.interiors):
            simplified = ring.simplify(0)
            coords = [(float(x), float(y)) for x, y in simplified.coords]
            if len(coords) >= 4:
                loops.append(coords)

    loops.sort(key=_contour_sort_key)
    return loops


def _rotate_closed_contour_to_nearest_border(
    contour: list[tuple[float, float]],
    current_x: float,
    current_y: float,
    approach_dx: float = 0.0,
    approach_dy: float = 0.0,
) -> list[tuple[float, float]]:
    if len(contour) < 3:
        return contour

    ring = contour[:-1] if contour[0] == contour[-1] else contour
    if len(ring) < 2:
        return contour

    best_idx = 0
    best_t = 0.0
    best_point = ring[0]
    best_dist = float("inf")
    for idx, (ax, ay) in enumerate(ring):
        bx, by = ring[(idx + 1) % len(ring)]
        point_x, point_y, t = _closest_point_on_segment(
            current_x,
            current_y,
            ax,
            ay,
            bx,
            by,
        )
        distance = _point_distance_sq(current_x, current_y, point_x, point_y)
        if distance < best_dist:
            best_dist = distance
            best_idx = idx
            best_t = t
            best_point = (point_x, point_y)

    eps = 1e-9

    def choose_direction(
        forward: list[tuple[float, float]],
        reverse: list[tuple[float, float]],
    ) -> list[tuple[float, float]]:
        if (
            len(forward) < 2
            or len(reverse) < 2
            or (approach_dx == 0 and approach_dy == 0)
        ):
            return forward

        def score(candidate: list[tuple[float, float]]) -> float:
            tx = candidate[1][0] - candidate[0][0]
            ty = candidate[1][1] - candidate[0][1]
            # Positive when the shape interior, opposite the approach vector,
            # is to the left of the contour's first move.
            return (ty * approach_dx) - (tx * approach_dy)

        return reverse if score(reverse) > score(forward) else forward

    if best_t <= eps:
        forward = ring[best_idx:] + ring[:best_idx] + [ring[best_idx]]
        reverse_ring = list(reversed(ring))
        reverse_idx = reverse_ring.index(ring[best_idx])
        reverse = (
            reverse_ring[reverse_idx:]
            + reverse_ring[:reverse_idx]
            + [reverse_ring[reverse_idx]]
        )
        return choose_direction(forward, reverse)

    next_idx = (best_idx + 1) % len(ring)
    if best_t >= 1.0 - eps:
        forward = ring[next_idx:] + ring[:next_idx] + [ring[next_idx]]
        reverse_ring = list(reversed(ring))
        reverse_idx = reverse_ring.index(ring[next_idx])
        reverse = (
            reverse_ring[reverse_idx:]
            + reverse_ring[:reverse_idx]
            + [reverse_ring[reverse_idx]]
        )
        return choose_direction(forward, reverse)

    forward = [best_point]
    for step in range(1, len(ring) + 1):
        forward.append(ring[(best_idx + step) % len(ring)])
    forward.append(best_point)

    reverse = [best_point]
    for step in range(0, len(ring)):
        reverse.append(ring[(best_idx - step) % len(ring)])
    reverse.append(best_point)
    return choose_direction(forward, reverse)


def _last_print_reference(output_list: list[dict]) -> tuple[float, float, float, float]:
    x = y = 0.0
    last_x = last_y = 0.0
    last_dx = last_dy = 0.0
    for move in output_list:
        dx = float(move.get("X", 0.0))
        dy = float(move.get("Y", 0.0))
        x += dx
        y += dy
        if move.get("Color") == 255 and "Z" not in move:
            last_x = x
            last_y = y
            last_dx = dx
            last_dy = dy
    return last_x, last_y, last_dx, last_dy


def _last_motion_reference(output_list: list[dict]) -> tuple[float, float, float, float]:
    """Endpoint and direction of the last XY move, regardless of valve state.

    Used instead of `_last_print_reference` when shapes share a reference
    motion path: anchoring contours off the valve state would give every
    shape a different contour tour, breaking the shared path.
    """
    x = y = 0.0
    last_x = last_y = 0.0
    last_dx = last_dy = 0.0
    for move in output_list:
        dx = float(move.get("X", 0.0))
        dy = float(move.get("Y", 0.0))
        x += dx
        y += dy
        if "Z" not in move and (dx != 0.0 or dy != 0.0):
            last_x = x
            last_y = y
            last_dx = dx
            last_dy = dy
    return last_x, last_y, last_dx, last_dy


def _rewind_trailing_travel(
    output_list: list[dict],
    current_x: float,
    current_y: float,
) -> tuple[float, float]:
    if not output_list:
        return current_x, current_y

    last_move = output_list[-1]
    if last_move.get("Color") != 0 or "Z" in last_move:
        return current_x, current_y

    has_layer_print = any(
        move.get("Color") == 255 and "Z" not in move
        for move in reversed(output_list[:-1])
    )
    if not has_layer_print:
        return current_x, current_y

    output_list.pop()
    return (
        current_x - float(last_move.get("X", 0.0)),
        current_y - float(last_move.get("Y", 0.0)),
    )


def build_contour_layers(
    sources: list[ContourSource] | None,
    n_layers: int,
    reference: LayerStack | None = None,
) -> list[list[dict]]:
    """Per-layer contour loops per owning shape.

    When `reference` is given (reference-motion mode) each source stack is
    translated by its centering delta so contours land in the shared frame.
    """
    contour_layers: list[list[dict]] = [[] for _ in range(n_layers)]
    for source in sources or []:
        stack = source.stack
        if stack is None or not stack.layers:
            continue

        if reference is not None:
            delta_x, delta_y = _centering_delta(stack, reference)
        else:
            delta_x = delta_y = 0.0

        if stack.contour_paths is not None:
            # Split pieces carry seam-free contour paths computed from the
            # parent shape's outline; use them directly.
            for layer_number in range(min(n_layers, len(stack.contour_paths))):
                contours = [
                    [(x + delta_x, y + delta_y) for x, y in path]
                    for path in stack.contour_paths[layer_number]
                    if len(path) >= 2
                ]
                if contours:
                    contour_layers[layer_number].append(
                        {
                            "owner_idx": source.owner_idx,
                            "contours": contours,
                        }
                    )
            continue

        if reference is not None:
            layers = align_stack_to(stack, reference, n_layers)
        else:
            layers = stack.layers

        for layer_number in range(min(n_layers, len(layers))):
            layer = layers[layer_number]
            if layer is None or layer.is_empty:
                continue
            contours = _layer_contour_loops(layer)
            if contours:
                contour_layers[layer_number].append(
                    {
                        "owner_idx": source.owner_idx,
                        "contours": contours,
                    }
                )

    return contour_layers


def _append_layer_contours(
    output_list: list[dict],
    current_x: float,
    current_y: float,
    contour_layers: list[list[dict]],
    layer_number: int,
    active_owner_idx: int | None,
    origin_x: float = 0.0,
    origin_y: float = 0.0,
    shared_motion: bool = False,
) -> tuple[float, float]:
    # `origin_x/origin_y` is the world position of the move list's start:
    # _last_print_reference sums relative moves from zero, so its result must
    # be shifted back into the world frame the contours live in.
    #
    # With `shared_motion` (reference-stack printing) every shape must trace
    # EVERY source's contours in the same order so all parallel heads follow
    # one identical path; the valve opens only on the active owner's contours.
    # Anchoring then uses the last motion move instead of the last print, and
    # the trailing-travel rewind is skipped — both depend on how the raster
    # moves were split at valve boundaries, which differs per shape and would
    # desynchronise the shared path.
    if layer_number >= len(contour_layers):
        return current_x, current_y

    active_sources = [
        source
        for source in contour_layers[layer_number]
        if (shared_motion or source.get("owner_idx") == active_owner_idx)
        and any(len(contour) >= 2 for contour in source.get("contours", []))
    ]
    if not active_sources:
        return current_x, current_y

    if not shared_motion:
        current_x, current_y = _rewind_trailing_travel(
            output_list,
            current_x,
            current_y,
        )

    use_infill_reference = True

    for source in active_sources:
        if shared_motion and source.get("owner_idx") != active_owner_idx:
            color = 0
        else:
            color = 255
        for contour in source.get("contours", []):
            if len(contour) < 2:
                continue
            if use_infill_reference:
                reference = (
                    _last_motion_reference(output_list)
                    if shared_motion
                    else _last_print_reference(output_list)
                )
                nearest_x, nearest_y, approach_dx, approach_dy = reference
                nearest_x += origin_x
                nearest_y += origin_y
            else:
                nearest_x, nearest_y = current_x, current_y
                approach_dx, approach_dy = 0.0, 0.0
            is_closed = len(contour) >= 4 and contour[0] == contour[-1]
            if is_closed:
                contour = _rotate_closed_contour_to_nearest_border(
                    contour,
                    nearest_x,
                    nearest_y,
                    approach_dx,
                    approach_dy,
                )
                if contour[0] != contour[-1]:
                    contour = [*contour, contour[0]]
            else:
                # Open arc (a split piece's seam-free outline): approach the
                # nearer end and print to the other. Never close the loop —
                # the closing chord would run along the cut seam this path
                # deliberately excludes.
                if _point_distance_sq(
                    nearest_x, nearest_y, contour[-1][0], contour[-1][1]
                ) < _point_distance_sq(
                    nearest_x, nearest_y, contour[0][0], contour[0][1]
                ):
                    contour = list(reversed(contour))
            start_x, start_y = contour[0]
            use_infill_reference = False
            current_x, current_y = _append_relative_move(
                output_list,
                current_x,
                current_y,
                start_x,
                start_y,
                0,
            )
            for target_x, target_y in contour[1:]:
                current_x, current_y = _append_relative_move(
                    output_list,
                    current_x,
                    current_y,
                    target_x,
                    target_y,
                    color,
                )

    return current_x, current_y


LEAD_IN_DIRECTION_LEFT = "Left"
LEAD_IN_DIRECTION_RIGHT = "Right"
LEAD_IN_DIRECTION_UP = "Up"
LEAD_IN_DIRECTION_DOWN = "Down"
LEAD_IN_DIRECTION_CHOICES = (
    LEAD_IN_DIRECTION_LEFT,
    LEAD_IN_DIRECTION_RIGHT,
    LEAD_IN_DIRECTION_UP,
    LEAD_IN_DIRECTION_DOWN,
)
# Away-from-the-part axis and the lateral line-stepping axis per direction.
_LEAD_IN_AXES = {
    LEAD_IN_DIRECTION_LEFT: ((-1.0, 0.0), (0.0, 1.0)),
    LEAD_IN_DIRECTION_RIGHT: ((1.0, 0.0), (0.0, 1.0)),
    LEAD_IN_DIRECTION_UP: ((0.0, 1.0), (1.0, 0.0)),
    LEAD_IN_DIRECTION_DOWN: ((0.0, -1.0), (1.0, 0.0)),
}


def _normalize_lead_in_direction(direction: str | None) -> str:
    if direction in _LEAD_IN_AXES:
        return direction
    return LEAD_IN_DIRECTION_LEFT


def _lead_in_moves(
    enabled: bool,
    length: float,
    clearance: float,
    line_count: int,
    line_spacing: float,
    print_color: int,
    off_color: int,
    direction: str | None = LEAD_IN_DIRECTION_LEFT,
) -> list[dict]:
    """Purge patch printed before layer 1, in the chosen direction.

    The patch sits `clearance` away from the toolpath start along the purge
    direction and snakes `line_count` strokes of `length`, stepping one
    `line_spacing` laterally between strokes. The return route exits the
    patch one lateral step to the OUTSIDE and comes home through virgin
    ground, so the freshly primed nozzle never drags back across the wet
    purge lines.
    """
    if not enabled:
        return []
    lead_length = max(0.0, float(length))
    if lead_length <= 0.0:
        return []
    lead_clearance = max(0.0, float(clearance))
    pass_count = max(1, int(line_count))
    spacing = max(0.0, float(line_spacing))
    away, lateral = _LEAD_IN_AXES[_normalize_lead_in_direction(direction)]

    moves: list[dict] = []
    # Patch-local frame: `a` runs along the away axis, `v` along the lateral.
    current_a = 0.0
    current_v = 0.0

    def append_move(delta_a: float, delta_v: float, color: int) -> None:
        nonlocal current_a, current_v
        if delta_a == 0.0 and delta_v == 0.0:
            return
        moves.append(
            {
                "X": delta_a * away[0] + delta_v * lateral[0],
                "Y": delta_a * away[1] + delta_v * lateral[1],
                "Color": color,
            }
        )
        current_a += delta_a
        current_v += delta_v

    append_move(lead_clearance + lead_length, 0.0, off_color)
    stroke = -1.0  # first stroke prints back toward the part
    for pass_index in range(pass_count):
        append_move(stroke * lead_length, 0.0, print_color)
        stroke *= -1.0
        if pass_index < pass_count - 1:
            append_move(0.0, spacing, off_color)

    # Return: step one spacing outside the patch laterally, travel home
    # through the clearance lane, then step back onto the start point.
    if spacing > 0.0:
        append_move(0.0, -(current_v + spacing), off_color)
        append_move(-current_a, 0.0, off_color)
        append_move(0.0, -current_v, off_color)
    else:
        append_move(-current_a, -current_v, off_color)
    return moves


def plan_layer_moves(
    motion_layers: list[MultiPolygon],
    valve_layers: list[MultiPolygon],
    fil_width: float,
    layer_height: float,
    raster_pattern: str,
    contour_layers: list[list[dict]] | None = None,
    active_contour_owner: int | None = None,
    shared_motion: bool = False,
    scan_frame: tuple[float, float, float, float] | None = None,
    infill_fraction: float = 1.0,
    extra_wall_radii: list[list[float]] | None = None,
    ring_center: tuple[float, float] | None = None,
    motion_infill_fractions: list[float] | None = None,
) -> tuple[list[dict], tuple[float, float]]:
    """Assemble per-layer segments into a relative move list for all patterns.

    `scan_frame` (an XY box) pins the rasters' scanlines to a global grid so
    lines stack across layers and across split pieces, and provides the
    rotation pivot for diagonal layers.

    `infill_fraction` < 1 skips dispensing on evenly-distributed lines (grid
    lines for axis rasters, rings for the circle spiral). Lines that NO head
    prints are dropped from the motion entirely: `motion_infill_fractions`
    lists every shape sharing the motion (pass the SAME list to every shape,
    or the shared paths diverge). When omitted, a SOLO shape's own fraction
    bounds its motion, while shared motion is never restricted (the other
    heads' infill is unknown). The rectangular spiral keeps its full
    continuous walk (a skipped loop would break the spiral into
    disconnected rectangles).

    Returns the move list and the toolpath origin — the world position the
    relative moves start from (the first segment start of the first non-empty
    layer). Callers need it to map relative G-code back into world space.
    """
    raster_pattern = _normalize_raster_pattern(raster_pattern)
    infill_keep = _infill_line_keep(infill_fraction)
    if motion_infill_fractions is not None:
        motion_keep = _combined_infill_keep(motion_infill_fractions)
    elif shared_motion:
        motion_keep = None
    else:
        motion_keep = infill_keep
    if scan_frame is not None:
        anchor_x = _scan_anchor(scan_frame[0], scan_frame[2], fil_width)
        anchor_y = _scan_anchor(scan_frame[1], scan_frame[3], fil_width)
    else:
        anchor_x = anchor_y = None
    gcode_list: list[dict] = []
    current_x = 0.0
    current_y = 0.0
    origin_x = 0.0
    origin_y = 0.0
    raster_origin_initialized = False
    contour_layers = contour_layers or []

    for layer_number, (motion, valve) in enumerate(zip(motion_layers, valve_layers)):
        if motion is None or motion.is_empty:
            segments: list[Seg] = []
        elif raster_pattern == RASTER_PATTERN_CIRCLE_SPIRAL:
            # Rings live on one global radii grid anchored at the frame
            # centre (ring j at (j + 1/2) * fil): every layer draws from the
            # same radii, so walls stack across layers instead of aliasing,
            # and rings that never touch this layer's material are skipped
            # instead of swept as full travel circles.
            frame = scan_frame if scan_frame is not None else motion.bounds
            if ring_center is not None:
                # Shared-motion whole shapes: rings centred where every
                # shape's centre was ALIGNED to (the reference align
                # centre), so each shape is concentric with the ring set —
                # a bbox-union centre can sit off it when shapes differ in
                # size, making rings graze boundaries.
                center_x, center_y = ring_center
            else:
                center_x = (frame[0] + frame[2]) / 2.0
                center_y = (frame[1] + frame[3]) / 2.0
            radii, wall_radii = _circle_ring_radii(motion, center_x, center_y, fil_width)

            # Shared-motion parallel printing: every shape's own wall radius
            # joins the ONE ring set (all heads travel all walls; each
            # dispenses only on its own), so every shape keeps a smooth,
            # complete outer circle instead of whatever grid ring happens to
            # graze its boundary.
            extra_walls = (
                extra_wall_radii[layer_number]
                if extra_wall_radii is not None and layer_number < len(extra_wall_radii)
                else []
            )
            if extra_walls:
                merged = sorted(set(radii) | set(extra_walls), reverse=True)
                radii = []
                for radius in merged:
                    if radius <= EPS:
                        continue
                    if radii and radii[-1] - radius < fil_width * 0.05:
                        continue  # near-duplicate wall/ring
                    radii.append(radius)
                wall_radii = tuple(sorted(set(wall_radii) | set(extra_walls), reverse=True))

            if motion_keep is not None:
                # Rings NO head dispenses on drop out of the motion (walls
                # always print, so they always stay).
                radii = [
                    radius
                    for radius in radii
                    if any(abs(radius - wall) <= fil_width * 0.25 for wall in wall_radii)
                    or motion_keep(max(0, int(round(radius / fil_width - 0.5))))
                ]

            points = _circle_rings_polyline(center_x, center_y, radii, fil_width)
            if layer_number % 2 == 1:
                points.reverse()

            # Dispense only ON a ring: the radial jump between revolutions
            # travels with the valve shut — otherwise every jump would
            # extrude a radial seam, worst on the outer wall. Vetoing whole
            # source segments by their radial change also kills the step
            # pieces the material boundary would otherwise split off.
            def keep_segment(x0: float, y0: float, x1: float, y1: float) -> bool:
                radius_0 = math.hypot(x0 - center_x, y0 - center_y)
                radius_1 = math.hypot(x1 - center_x, y1 - center_y)
                if abs(radius_1 - radius_0) > fil_width * 0.25:
                    return False  # radial jump between rings: travel only
                radius_mid = (radius_0 + radius_1) / 2.0
                if infill_keep is None:
                    return True
                if any(abs(radius_mid - wall) <= fil_width * 0.25 for wall in wall_radii):
                    return True  # perimeter walls always print, like contours
                ring = round(radius_mid / fil_width - 0.5)
                return infill_keep(max(0, int(ring)))

            segments = _classify_polyline(points, valve, keep_segment=keep_segment)
            # A ring grazing a faceted boundary flickers in/out of material,
            # printing sub-bead dashes (the spotty outer ring); real fill
            # arcs — a square's corner fill, a triangle's lobes — are far
            # longer and must stay. Close the valve over runs too short to
            # deposit cleanly; the motion is untouched.
            segments = _drop_short_print_runs(segments, fil_width * 1.5)
        elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
            # Anchor the loop family to the FRAME (constant across layers,
            # shapes, and split pieces), not each layer's own material
            # bounds: layers with smaller footprints would otherwise spiral
            # at their own offsets and the walls would not stack. Outer
            # loops that enclose this layer's material with more than half a
            # bead to spare are skipped instead of traveled.
            frame = scan_frame if scan_frame is not None else motion.bounds
            spiral_bounds = _frame_spiral_bounds(frame, motion.bounds, fil_width)
            points = _rectangular_spiral_polyline(
                spiral_bounds,
                fil_width,
                reverse=layer_number % 2 == 1,
            )
            keep_point = None
            if infill_keep is not None:
                # A spiral "line" is one ring; index rings from the FRAME's
                # base loop so the selection lines up across layers.
                min_x, min_y, max_x, max_y = frame
                half = fil_width / 2.0
                left, right = min_x + half, max_x - half
                bottom, top = min_y + half, max_y - half

                def keep_point(x: float, y: float) -> bool:
                    inset = min(x - left, right - x, y - bottom, top - y)
                    return infill_keep(max(0, int(round(inset / fil_width))))

            segments = _classify_polyline(points, valve, keep_point=keep_point)
        elif (
            raster_pattern == RASTER_PATTERN_DIAGONAL_WOODPILE
            and _diagonal_layer_angle(layer_number) is not None
        ):
            angle = _diagonal_layer_angle(layer_number)
            frame = scan_frame if scan_frame is not None else motion.bounds
            segments = _rotated_raster_segments(
                motion,
                valve,
                fil_width,
                angle,
                current_x,
                current_y,
                prefer_default=not raster_origin_initialized,
                frame=frame,
                infill_keep=infill_keep,
                motion_keep=motion_keep,
            )
        else:
            raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
            # Axis "X" sweeps along X with rows stacked in Y, so its
            # scanlines use the Y anchor (and vice versa).
            axis_anchor = anchor_x if raster_axis == "Y" else anchor_y
            segments = _oriented_axis_raster_segments(
                motion,
                valve,
                fil_width,
                raster_axis,
                current_x,
                current_y,
                prefer_default=not raster_origin_initialized,
                scan_anchor=axis_anchor,
                infill_keep=infill_keep,
                motion_keep=motion_keep,
            )

        if not segments:
            if layer_number > 0:
                gcode_list.append({"X": 0.0, "Y": 0.0, "Z": layer_height, "Color": 0})
            layer_end_x, layer_end_y = current_x, current_y
            current_x, current_y = _append_layer_contours(
                gcode_list,
                current_x,
                current_y,
                contour_layers,
                layer_number,
                active_contour_owner,
                origin_x,
                origin_y,
                shared_motion,
            )
            current_x, current_y = _append_relative_move(
                gcode_list,
                current_x,
                current_y,
                layer_end_x,
                layer_end_y,
                0,
            )
            continue

        first_x, first_y = segments[0][0], segments[0][1]
        if not raster_origin_initialized:
            if layer_number > 0:
                current_x, current_y = _append_relative_move(
                    gcode_list,
                    current_x,
                    current_y,
                    current_x,
                    current_y,
                    0,
                    z_step=layer_height,
                )
            current_x, current_y = first_x, first_y
            origin_x, origin_y = first_x, first_y
            raster_origin_initialized = True
        elif layer_number > 0:
            current_x, current_y = _append_relative_move(
                gcode_list,
                current_x,
                current_y,
                first_x,
                first_y,
                0,
                z_step=layer_height,
            )
        else:
            current_x, current_y = _append_relative_move(
                gcode_list,
                current_x,
                current_y,
                first_x,
                first_y,
                0,
            )

        for start_x, start_y, end_x, end_y, color in segments:
            current_x, current_y = _append_relative_move(
                gcode_list,
                current_x,
                current_y,
                start_x,
                start_y,
                0,
            )
            current_x, current_y = _append_relative_move(
                gcode_list,
                current_x,
                current_y,
                end_x,
                end_y,
                color,
            )

        layer_end_x, layer_end_y = current_x, current_y
        current_x, current_y = _append_layer_contours(
            gcode_list,
            current_x,
            current_y,
            contour_layers,
            layer_number,
            active_contour_owner,
            origin_x,
            origin_y,
            shared_motion,
        )
        current_x, current_y = _append_relative_move(
            gcode_list,
            current_x,
            current_y,
            layer_end_x,
            layer_end_y,
            0,
        )

    return gcode_list, (origin_x, origin_y)


def _stack_center(stack: LayerStack) -> tuple[float, float]:
    (x_min, y_min, _z_min), (x_max, y_max, _z_max) = stack.bounds
    return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0)


def _alignment_center(stack: LayerStack) -> tuple[float, float]:
    """The point a stack is centred by: its multi-material group frame's
    centre when it belongs to a group, else its own bbox centre. Group
    members share one frame, so they all get the same delta and keep their
    modelled positions relative to each other."""
    if stack.align_frame is not None:
        x_min, y_min, x_max, y_max = stack.align_frame
        return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0)
    return _stack_center(stack)


def _snap_to_grid(value: float, grid: float | None) -> float:
    if not grid or grid <= 0.0:
        return value
    # Half-up, not Python's banker's rounding: values that differ by exact
    # grid multiples must snap to results that differ by the same multiples
    # (banker's rounds exact halves toward even, breaking that translation
    # invariance and with it uniform split-piece spacing).
    return math.floor(value / grid + 0.5 + 1e-9) * grid


def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, float]:
    """Translation that aligns `stack` into `reference`'s shared frame.

    Uses the alignment target and snap grid stamped on the reference by
    `build_reference_stack`, so re-deriving the delta later reproduces the
    exact translation the reference was built with. Snapping the delta to the
    fil grid keeps every shape's world scan-grid phase intact, so split
    pieces printed with shared reference motion still tile at an exact
    one-fil-width line pitch.

    Split siblings (stacks sharing the reference's scan frame) are aligned by
    their cell corner within that frame instead of their centre: with cells
    sized in whole grid multiples the deltas — and with them the required
    nozzle spacing — come out uniform across all pieces, whereas snapping the
    centres would wobble by up to one fil where the last cell's width (and so
    its centre phase) differs.

    Multi-material group members (stacks carrying a shared `align_frame`)
    are aligned by the group frame's centre instead of their own bbox
    centre: every member gets the same delta, so the group moves as one
    rigid unit and parts keep their modelled relative positions.
    """
    grid = reference.align_grid
    if (
        grid
        and stack.scan_frame is not None
        and stack.scan_frame == reference.scan_frame
    ):
        (stack_min_x, stack_min_y, _sz), _stack_max = (
            stack.bounds[0],
            stack.bounds[1],
        )
        frame_min_x, frame_min_y = stack.scan_frame[0], stack.scan_frame[1]
        return (
            _snap_to_grid(frame_min_x - stack_min_x, grid),
            _snap_to_grid(frame_min_y - stack_min_y, grid),
        )

    if reference.align_center is not None:
        reference_x, reference_y = reference.align_center
    else:
        reference_x, reference_y = _stack_center(reference)
    center_x, center_y = _alignment_center(stack)
    # No grid snap here: whole shapes centre EXACTLY on the align centre
    # (circle-spiral rings are centred there, and a snapped residue of up
    # to half a fil would make rings graze the shape's boundary). The snap
    # only ever mattered for split pieces, which take the scan-frame corner
    # rule above.
    return (reference_x - center_x, reference_y - center_y)


def align_stack_to(
    stack: LayerStack,
    reference: LayerStack,
    n_layers: int,
) -> list[MultiPolygon]:
    """Translate a stack's layers into the reference frame, padded with empties."""
    delta_x, delta_y = _centering_delta(stack, reference)
    layers: list[MultiPolygon] = []
    for index in range(n_layers):
        if index < len(stack.layers) and not stack.layers[index].is_empty:
            layers.append(
                _as_multipolygon(
                    translate(stack.layers[index], xoff=delta_x, yoff=delta_y)
                )
            )
        else:
            layers.append(MultiPolygon())
    return layers


def build_reference_stack(
    stacks: list[LayerStack | None],
    grid: float | None = None,
) -> LayerStack | None:
    """Union all shapes into one shared motion stack, alignment-centres aligned.

    Vector analog of the old centered "black wins" TIFF merge: every stack is
    translated so its alignment centre (its own XY bbox centre, or its
    multi-material group frame's centre — see `_alignment_center`) lands on
    the first stack's alignment centre, then each layer is the union of the
    translated layers. Multi-material group members share one frame, so the
    group translates as a rigid unit and its parts keep their modelled
    relative positions; the group holding the first stack does not move at
    all. Group members should be sliced on one common Z grid so layer indices
    line up; a part that starts higher simply contributes nothing to the
    lower layers.

    `grid` (the fil width) snaps each translation to grid multiples so every
    shape's world scan-grid phase survives the alignment — required for split
    pieces to keep an exact one-fil line pitch under shared reference motion.
    The alignment target and grid are stamped on the result so later
    `_centering_delta` calls reproduce the exact same translations.
    """
    valid = [stack for stack in stacks if stack is not None and stack.layers]
    if not valid:
        return None

    n_layers = max(len(stack.layers) for stack in valid)
    reference_x, reference_y = _alignment_center(valid[0])
    target = LayerStack(
        layers=[],
        z_values=[],
        bounds=((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)),
        layer_height=valid[0].layer_height,
        scan_frame=valid[0].scan_frame,
        align_center=(reference_x, reference_y),
        align_grid=grid,
    )

    layer_parts: list[list[MultiPolygon]] = [[] for _ in range(n_layers)]
    x_min = y_min = z_min = math.inf
    x_max = y_max = z_max = -math.inf

    for stack in valid:
        delta_x, delta_y = _centering_delta(stack, target)

        (sx_min, sy_min, sz_min), (sx_max, sy_max, sz_max) = stack.bounds
        x_min = min(x_min, sx_min + delta_x)
        x_max = max(x_max, sx_max + delta_x)
        y_min = min(y_min, sy_min + delta_y)
        y_max = max(y_max, sy_max + delta_y)
        z_min = min(z_min, sz_min)
        z_max = max(z_max, sz_max)

        for index, layer in enumerate(stack.layers):
            if layer is None or layer.is_empty:
                continue
            layer_parts[index].append(translate(layer, xoff=delta_x, yoff=delta_y))

    layers = [
        _as_multipolygon(unary_union(parts)) if parts else MultiPolygon()
        for parts in layer_parts
    ]

    z_values: list[float] = []
    for index in range(n_layers):
        z_value = 0.0
        for stack in valid:
            if index < len(stack.z_values):
                z_value = stack.z_values[index]
                break
        z_values.append(z_value)

    # The first stack's delta is zero (snap(0) == 0), so its scan frame is
    # already in the shared frame and anchors the reference's scan grid.
    return LayerStack(
        layers=layers,
        z_values=z_values,
        bounds=((x_min, y_min, z_min), (x_max, y_max, z_max)),
        layer_height=valid[0].layer_height,
        name="reference",
        scan_frame=valid[0].scan_frame,
        align_center=(reference_x, reference_y),
        align_grid=grid,
    )


def _base_split_edges(
    lo: float,
    hi: float,
    count: int,
    grid: float | None = None,
) -> list[float]:
    """Cell edges for one axis: equal cells, padded to whole grid multiples.

    With `grid` (the fil width), every cell gets the SAME width — the extent
    divided by `count`, rounded UP to a whole number of grid units — and the
    leftover becomes blank margin split evenly outside the shape's outer
    edges. This is the vector analog of the old pixel splitter's padded
    canvas: identical piece sizes, so the required nozzle spacing is one cell
    everywhere, including under shared reference motion.
    """
    extent = hi - lo
    if grid and grid > 0.0 and count > 1 and extent > 0.0:
        units = max(1, math.ceil(extent / (count * grid) - 1e-9))
        cell = units * grid
        pad = cell * count - extent
        start = lo - pad / 2.0
        return [start + index * cell for index in range(count + 1)]
    return [lo + index * extent / count for index in range(count + 1)]


def _shifted_split_edges(
    edges: list[float],
    layer_index: int,
    overlap: float,
) -> list[float]:
    """Per-layer cell edges; interior ones alternate by ±overlap per layer."""
    count = len(edges) - 1
    if overlap <= 0.0 or count <= 1:
        return list(edges)

    adjusted = list(edges)
    for boundary_index in range(1, count):
        direction = 1 if (layer_index + boundary_index) % 2 == 1 else -1
        lower = adjusted[boundary_index - 1] + overlap
        upper = edges[boundary_index + 1] - overlap
        shifted = edges[boundary_index] + direction * overlap
        if lower <= upper:
            shifted = max(lower, min(upper, shifted))
        else:
            shifted = edges[boundary_index]
        adjusted[boundary_index] = shifted
    return adjusted


def _linework_to_paths(geometry: object) -> list[list[tuple[float, float]]]:
    """Merge linework into maximal polylines, ordered/oriented
    deterministically so shapes sharing reference motion trace them
    identically. Open paths allowed; closed rings keep first == last."""
    pieces = list(_iter_linestrings(geometry))
    if not pieces:
        return []
    merged = linemerge(MultiLineString(pieces)) if len(pieces) > 1 else pieces[0]

    paths: list[list[tuple[float, float]]] = []
    for line in _iter_linestrings(merged):
        coords = [(float(x), float(y)) for x, y in line.coords]
        if len(coords) < 2 or LineString(coords).length <= EPS:
            continue
        # Normalize open-path orientation (closed rings keep first == last).
        if coords[0] != coords[-1] and coords[-1] < coords[0]:
            coords.reverse()
        paths.append(coords)

    paths.sort(
        key=lambda path: (
            min(point[1] for point in path),
            min(point[0] for point in path),
            -len(path),
        )
    )
    return paths


def _clip_contour_paths(
    source_lines: object,
    cell: object,
) -> list[list[tuple[float, float]]]:
    """Contour polylines of a split piece: the parent outline inside the cell.

    Clipping the PARENT's boundary linework (instead of taking the piece
    polygon's own boundary) excludes the cut seams between sibling pieces —
    only the true outer surface remains.
    """
    return _linework_to_paths(source_lines.intersection(cell))


def group_contour_paths(
    member: LayerStack,
    siblings: list[LayerStack],
    tolerance: float,
) -> list[list[list[tuple[float, float]]]]:
    """Per-layer seam-free contour polylines for one multi-material member.

    Parts sharing a nozzle assemble into ONE shape, so where a member's
    boundary meets a sibling material — or comes within `tolerance` of it
    (fit gaps between materials) — that edge is an internal interface, not a
    printable surface. Only the member boundary on the assembly's true
    outside is kept; a member fully embedded in the assembly gets no
    contours at all. Members should share one Z grid so layer indices align.
    """
    tolerance = max(float(tolerance), EPS)
    contour_paths: list[list[list[tuple[float, float]]]] = []
    for layer_number, layer in enumerate(member.layers):
        if layer is None or layer.is_empty:
            contour_paths.append([])
            continue
        sibling_parts = [
            sibling.layers[layer_number]
            for sibling in siblings
            if layer_number < len(sibling.layers)
            and sibling.layers[layer_number] is not None
            and not sibling.layers[layer_number].is_empty
        ]
        boundary = layer.boundary
        if sibling_parts:
            boundary = boundary.difference(unary_union(sibling_parts).buffer(tolerance))
        contour_paths.append(_linework_to_paths(boundary))
    return contour_paths


def split_layer_stack_grid(
    stack: LayerStack,
    columns: int,
    rows: int,
    overlapping_layers: bool = False,
    overlap: float = 0.0,
    grid: float | None = None,
    frame: tuple[float, float, float, float] | None = None,
) -> list[LayerStack]:
    """Split a sliced shape into a rows x columns grid of piece stacks.

    Pieces are returned row-major with row 1 the top strip (max-Y side),
    matching the legacy image-grid ordering. With `overlapping_layers`, the
    interior cut lines alternate by ±overlap between layers so neighbouring
    pieces interlock. `grid` (the fil width) sizes the cells in whole grid
    multiples (see `_base_split_edges`). Piece `bounds` are the nominal
    (un-shifted) cell boxes.

    `frame` overrides the XY box the cell grid is computed over. Splitting
    every member of a multi-material group with the group's combined bounds
    as the frame clips all materials by the SAME cells (and one shared scan
    frame), so cell-mates assemble exactly.
    """
    columns = max(1, int(columns))
    rows = max(1, int(rows))
    (x_min, y_min, z_min), (x_max, y_max, z_max) = stack.bounds
    if frame is not None:
        x_min, y_min, x_max, y_max = (float(value) for value in frame)

    overlap_x = overlap if (overlapping_layers and columns > 1) else 0.0
    overlap_y = overlap if (overlapping_layers and rows > 1) else 0.0

    base_x_edges = _base_split_edges(x_min, x_max, columns, grid)
    base_y_edges = _base_split_edges(y_min, y_max, rows, grid)
    layer_x_edges = [
        _shifted_split_edges(base_x_edges, index, overlap_x)
        for index in range(len(stack.layers))
    ]
    layer_y_edges = [
        _shifted_split_edges(base_y_edges, index, overlap_y)
        for index in range(len(stack.layers))
    ]

    # Pieces inherit the parent's scan frame so they all raster on ONE
    # continuous line grid: the assembled seams then keep an exact
    # one-fil-width pitch instead of each piece re-centring its own lines.
    scan_frame = stack.scan_frame or (x_min, y_min, x_max, y_max)

    base_name = stack.name or "shape"
    pieces: list[LayerStack] = []
    for row_index in range(1, rows + 1):
        # Row 1 is the top strip: count y-cells down from the max edge.
        y_cell = rows - row_index
        for col_index in range(1, columns + 1):
            x_cell = col_index - 1

            layers: list[MultiPolygon] = []
            contour_paths: list[list[list[tuple[float, float]]]] = []
            for layer_number, layer in enumerate(stack.layers):
                if layer is None or layer.is_empty:
                    layers.append(MultiPolygon())
                    contour_paths.append([])
                    continue
                x_edges = layer_x_edges[layer_number]
                y_edges = layer_y_edges[layer_number]
                cell = box(
                    x_edges[x_cell],
                    y_edges[y_cell],
                    x_edges[x_cell + 1],
                    y_edges[y_cell + 1],
                )
                layers.append(_as_multipolygon(layer.intersection(cell)))

                # Contours come from the parent's outline (or, when
                # re-splitting a piece, its already-seam-free paths) so the
                # cut lines between siblings are never traced.
                if stack.contour_paths is not None:
                    parent_paths = (
                        stack.contour_paths[layer_number]
                        if layer_number < len(stack.contour_paths)
                        else []
                    )
                    source_lines = MultiLineString(
                        [path for path in parent_paths if len(path) >= 2]
                    )
                else:
                    source_lines = layer.boundary
                contour_paths.append(_clip_contour_paths(source_lines, cell))

            pieces.append(
                LayerStack(
                    layers=layers,
                    z_values=list(stack.z_values),
                    bounds=(
                        (base_x_edges[x_cell], base_y_edges[y_cell], z_min),
                        (base_x_edges[x_cell + 1], base_y_edges[y_cell + 1], z_max),
                    ),
                    layer_height=stack.layer_height,
                    name=f"{base_name}_r{row_index:02d}_c{col_index:02d}",
                    scan_frame=scan_frame,
                    contour_paths=contour_paths,
                )
            )

    return pieces