bevy_pf 0.2.8

A XAML / WPF-like UI framework for Bevy: XAML in macros or files, styling with resources, and the common WPF control set.
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
//! GPU shape backend: WPF shapes rendered by `bevy_pf_vector` instead of
//! CPU-rasterized with tiny-skia.
//!
//! # Why an atlas rather than a texture per shape
//!
//! The engine's whole thesis is tessellate-once-and-instance: geometry is
//! tessellated on first sight, keyed by content hash, and every later frame
//! costs one instance write. A render target per shape would mean a render
//! pass per shape and would throw that away — it would be *slower* than the
//! CPU path it replaces.
//!
//! So every shape draws into ONE shared atlas texture, in a single instanced
//! pass, through an offscreen camera on a dedicated render layer. Each UI node
//! keeps an `ImageNode`, but pointed at its slot in that atlas via
//! [`TextureAtlas`]. bevy_ui therefore keeps owning layout, compositing,
//! `Overflow::Clip` and z-order — the parts that are not worth reimplementing
//! and that a "draw the UI ourselves" approach would break.
//!
//! # What this actually buys
//!
//! Static shapes were already cheap: the CPU path caches by pixel size, so a
//! shape that never resizes never re-rasterized. The win is *dynamic* shapes.
//! A `Fill`/`Stroke` bound to a view model previously re-rasterized on the CPU
//! and allocated a fresh `Image` asset — a full texture re-upload — on every
//! change. Here colour is per-instance: a colour change re-tessellates
//! nothing, uploads no texture, and costs one 56-byte instance write.
//!
//! Shapes that cannot get an atlas slot (atlas full) fall back to the CPU
//! rasterizer, so this is never worse than not having it.

use bevy::camera::visibility::RenderLayers;
use bevy::camera::{ClearColorConfig, RenderTarget};
use bevy::image::{TextureAtlas, TextureAtlasLayout};
use bevy::prelude::*;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages};
use bevy::ui::ComputedNode;
use bevy::ui::widget::{ImageNode, NodeImageMode};
use bevy_pf_vector::{
    Brush, DashPattern, FillRule as VFillRule, GradientStop, HudTransform, LineCap, LineJoin,
    PathCommand, PathStyle, StrokeStyle, VectorPrimitive, VectorShape,
};
use bevy_pf_xaml::geometry::{FillRule, PathData, PathSegment};
use bevy_pf_xaml::value as v;

use crate::shapes::{PfShape, PfShapeClaim, PfShapeRendered, ShapeGeometry, arc_to_cubics};

/// Render layer the shape atlas camera draws, kept clear of app content.
const SHAPE_LAYER: usize = 24;

/// Atlas edge length. 2048² holds a lot of HUD chrome; overflow falls back to
/// the CPU rasterizer rather than failing to draw.
const ATLAS_SIZE: u32 = 2048;

/// Gap between packed slots so a neighbour's antialiased fringe can never
/// bleed into this slot when bevy_ui samples it with filtering.
/// Transparent gutter between reservations.
///
/// Four, not two. The gutter is the margin of error for a sample that lands
/// slightly outside its slot; with nearest filtering one texel would do,
/// but a stretched node can miss by more than a texel and the cost is two
/// pixels per slot on a 2048² atlas — far cheaper than a neighbour's colour
/// appearing along an edge.
const SLOT_PADDING: u32 = 4;

/// Shared atlas, its layout asset, and the shelf allocator that packs it.
#[derive(Resource)]
pub struct PfShapeAtlas {
    pub image: Handle<Image>,
    pub layout: Handle<TextureAtlasLayout>,
    /// Guillotine allocator: subdivides recursively and MERGES on
    /// deallocation, which is what keeps fragmentation from being permanent.
    packer: guillotiere::AtlasAllocator,
    /// Additional pages, opened when the ones before them are full.
    ///
    /// PAGES, NOT ONE TEXTURE, and this is what removes the blink. With a
    /// single page a shape that does not fit has only bad options: demote it
    /// to the CPU rasterizer, or reclaim space by rebuilding -- which drops
    /// EVERY reservation for a frame, so the whole UI disappears and comes
    /// back. Rate-limiting that only turned a constant failure into a
    /// periodic one.
    ///
    /// Bevy's own font atlas answers this by never reclaiming at all: on
    /// overflow `FontAtlasSet::add_glyph_to_atlas` tries every existing page
    /// and then pushes a new one. Nothing is evicted, nothing is rebuilt, so
    /// no glyph vanishes because the atlas was busy -- and UI text is
    /// composited from exactly these multi-page atlases, so per-node distinct
    /// page images are a case bevy_ui already supports.
    ///
    /// The cost is real: our pages are RENDER TARGETS, not CPU-filled images,
    /// so each carries its own camera and clears its own 2048x2048 target
    /// every frame. Bevy's pages cost nothing per frame; ours cost a pass.
    /// Hence [`MAX_PAGES`], and hence the native bevy_ui backend -- which
    /// needs no page at all -- claiming first.
    extra: Vec<AtlasPage>,
    /// Bumped by [`PfShapeAtlas::reset`]. Every reservation is stamped with
    /// the generation it was made in, and a release carrying an older stamp
    /// is dropped.
    ///
    /// A rebuild resets the packer IMMEDIATELY, but the component removals
    /// that accompany it are queued commands — so the `on_remove` hook fires
    /// AFTER the free list has been cleared and pushes every dead origin
    /// into the fresh one. The next frame then hands those regions out while
    /// the cursor, also back at zero, is independently handing out the same
    /// pixels: two shapes own one region and each samples the other's paint.
    ///
    /// That is what a console button showing another control's colour was,
    /// and what put mixed colours down the length of a scrollbar. Ordering
    /// cannot fix it — the hook has to run after the reset for despawns to
    /// work at all — so the stamp makes the stale release inert instead.
    generation: u32,
}

/// One additional atlas texture: its image, its slot layout, its allocator.
pub struct AtlasPage {
    pub image: Handle<Image>,
    pub layout: Handle<TextureAtlasLayout>,
    packer: guillotiere::AtlasAllocator,
}

/// How many atlas pages may exist in total (page 0 plus [`PfShapeAtlas::extra`]).
///
/// Each is 2048x2048xRGBA8 -- ~16 MB of VRAM and, because ours are render
/// targets, one camera and one clear per frame. A deliberate ceiling rather
/// than a limit anyone should reach: with solid rectangles, circles and now
/// gradients all claimed by the native bevy_ui backend, what reaches the atlas
/// is genuinely irregular geometry.
const MAX_PAGES: usize = 4;

impl PfShapeAtlas {
    /// Every page, page 0 first.
    fn packers(&mut self) -> impl Iterator<Item = &mut guillotiere::AtlasAllocator> {
        std::iter::once(&mut self.packer).chain(self.extra.iter_mut().map(|p| &mut p.packer))
    }

    /// The image and layout for a page index.
    pub fn page(&self, index: usize) -> (&Handle<Image>, &Handle<TextureAtlasLayout>) {
        match index.checked_sub(1) {
            None => (&self.image, &self.layout),
            Some(extra) => {
                let page = &self.extra[extra];
                (&page.image, &page.layout)
            }
        }
    }

    fn page_count(&self) -> usize {
        1 + self.extra.len()
    }
    /// Reserve a region, returning where it starts and the id that owns it.
    ///
    /// GUILLOTINE ALLOCATION, not shelf packing. The shelf packer this
    /// replaced could only hand a freed region back at its EXACT original
    /// size (or, later, best-fit to something at least that big), and it
    /// never merged a freed region with its neighbours. A shape sweeping
    /// through sizes therefore left a trail of regions at capacities nobody
    /// asked for again, and the only way to turn that trail back into space
    /// was to rebuild the entire atlas -- which drops every reservation for
    /// a frame and reads to a player as the whole UI blinking.
    ///
    /// Guillotine allocation subdivides recursively and, on deallocation,
    /// merges the freed rectangle with its siblings and collapses it into its
    /// parent. Space returns to the pool in usable shapes, so fragmentation
    /// stops being something that can only be escaped by starting over.
    ///
    /// The padding is kept: neighbouring slots that touch exactly will bleed
    /// into one another under linear filtering, and the sampler is only
    /// nearest today because of that.
    /// Returns the origin, the capacity ACTUALLY reserved, and the id that
    /// owns it. The capacity matters: guillotine allocation may hand back a
    /// region larger than asked for, and storing the REQUEST instead of the
    /// grant makes a shape think it has outgrown a slot it still fits, so it
    /// re-reserves and appends a layout entry for nothing.
    fn allocate(&mut self, size: UVec2) -> Option<(usize, UVec2, UVec2, guillotiere::AllocId)> {
        let padded = size + UVec2::splat(SLOT_PADDING);
        if padded.x > ATLAS_SIZE || padded.y > ATLAS_SIZE {
            return None;
        }
        let wanted = guillotiere::size2(padded.x as i32, padded.y as i32);
        let mut alloc = None;
        let mut page = 0;
        for (index, packer) in self.packers().enumerate() {
            if let Some(a) = packer.allocate(wanted) {
                alloc = Some(a);
                page = index;
                break;
            }
        }
        let alloc = alloc?;
        let min = alloc.rectangle.min;
        let granted = alloc.rectangle.size();
        let capacity = UVec2::new(
            (granted.width as u32).saturating_sub(SLOT_PADDING),
            (granted.height as u32).saturating_sub(SLOT_PADDING),
        );
        Some((
            page,
            UVec2::new(min.x as u32, min.y as u32),
            capacity,
            alloc.id,
        ))
    }

    /// Hand a region back. Guillotine deallocation merges it with adjacent
    /// free space, so this is a real reclaim rather than an entry in a pool of
    /// fixed-size leftovers.
    fn release(&mut self, page: usize, id: guillotiere::AllocId, generation: u32) {
        // A region reserved before the last reset does not exist any more, and
        // the id may since have been reissued to a LIVE shape -- deallocating
        // it would hand those pixels to a second owner. See `generation`.
        if generation != self.generation {
            return;
        }
        match page.checked_sub(1) {
            None => self.packer.deallocate(id),
            Some(extra) => {
                if let Some(page) = self.extra.get_mut(extra) {
                    page.packer.deallocate(id);
                }
            }
        }
    }

    fn reset(&mut self) {
        self.packer.clear();
        self.generation = self.generation.wrapping_add(1);
    }
}

/// The atlas slot a shape currently occupies, plus the draw entity rendering
/// into it.
/// RECLAIMED ON REMOVAL, via the hook below.
///
/// Slots used to come back only through a wholesale atlas rebuild. That is
/// fine while shapes merely resize — the reservation is mutated in place —
/// but a shape that goes AWAY took its region with it: panels open and
/// close, templates re-expand, and every departed shape leaked a slot until
/// the packer ran out and dropped every reservation at once.
///
/// It hid at small scale. Five shapes sweeping their size never filled a
/// 2048² atlas, so the stress harness passed; the game carries ~148 and
/// exhausted it 1,445 times in 35 seconds, which is what erased the console
/// button chrome. The harness now runs the specimen set x20 and reproduces
/// it: 500 rebuilds against a budget of 9, with no shape holding a slot at
/// the end.
#[derive(Component, Debug, Clone)]
#[component(on_remove = release_slot)]
pub struct PfShapeGpu {
    /// Index into the atlas layout. Its rect is mutated in place when the
    /// shape resizes within its slot, so the `ImageNode` never gets rebuilt.
    index: usize,
    /// Top-left of the reserved region, in atlas pixels.
    origin: UVec2,
    /// Reserved region size, >= the drawn size (see [`slot_capacity`]).
    capacity: UVec2,
    /// Owns the reservation in the packer; deallocation goes by id.
    alloc: guillotiere::AllocId,
    /// Which atlas page the reservation lives on. Each page is its own
    /// texture, layout and render layer.
    page: usize,
    /// Pixel size currently drawn.
    size: UVec2,
    /// The entity drawing into the slot.
    draw: Entity,
    /// Second pass for a shape that has BOTH fill and stroke on the SDF
    /// path, where each is its own instance.
    draw_stroke: Option<Entity>,
    /// Atlas generation this reservation was made in; a release stamped with
    /// anything else is ignored. See [`PfShapeAtlas::generation`].
    generation: u32,
}

/// Hand a departing shape's atlas region back to the packer.
///
/// A component hook rather than a `RemovedComponents` system: despawns and
/// removals both land here, in the same command flush that did them, so a
/// slot can never outlive the shape that held it. The draw entities go too
/// — they are the instances rendering into that region, and leaving them
/// would keep painting a slot the packer has already re-let.
fn release_slot(
    mut world: bevy::ecs::world::DeferredWorld,
    ctx: bevy::ecs::lifecycle::HookContext,
) {
    let Some(gpu) = world.get::<PfShapeGpu>(ctx.entity).cloned() else {
        return;
    };
    if let Some(mut atlas) = world.get_resource_mut::<PfShapeAtlas>() {
        atlas.release(gpu.page, gpu.alloc, gpu.generation);
    }
    let mut commands = world.commands();
    commands.entity(gpu.draw).try_despawn();
    if let Some(stroke) = gpu.draw_stroke {
        commands.entity(stroke).try_despawn();
    }
}

/// Round a reservation up so small size changes reuse the same region.
/// Without this a shape whose width is data-bound — a progress bar, a meter —
/// would burn a fresh slot every frame and exhaust the atlas in seconds.
fn slot_capacity(px: UVec2) -> UVec2 {
    const GRAIN: u32 = 16;
    UVec2::new(px.x.div_ceil(GRAIN) * GRAIN, px.y.div_ceil(GRAIN) * GRAIN)
}

/// Set when a reservation fails; drives a wholesale atlas rebuild rather than
/// letting the packer fragment.
#[derive(Resource, Default)]
struct PfAtlasFull(bool);

/// Frames to wait before another wholesale rebuild is allowed.
///
/// A rebuild is only worth doing to DEFRAGMENT. It is actively harmful as a
/// response to a working set that simply does not fit: every shape loses its
/// slot, all of them re-register, the ones that overflowed overflow again,
/// and the atlas rebuilds once more -- measured at better than one rebuild
/// per frame, with NO shape holding a slot at the end. That is what erased
/// the console-button chrome while the labels stayed.
///
/// Overflow already has a correct answer that costs nothing extra: hand the
/// shape to the CPU rasterizer. Rate-limiting the rebuild lets that answer
/// stand, so an over-committed atlas degrades to "the big shapes go through
/// tiny-skia" instead of "nothing is drawn by anybody".
#[derive(Resource, Default)]
struct PfAtlasRebuildCooldown(u32);

/// Frames between permitted wholesale rebuilds.
const REBUILD_COOLDOWN: u32 = 300;

/// How many wholesale atlas rebuilds have happened.
///
/// A rebuild drops every reservation, so each one costs a frame in which
/// shapes have no slot -- which is what "blinking" looked like in the game.
/// One or two during startup is normal as the working set settles; a counter
/// that keeps climbing means the atlas is thrashing and the content does not
/// fit. Exposed because frame-time percentiles CANNOT see this: a thrashing
/// atlas can measure faster than a healthy one while drawing less.
#[derive(Resource, Default)]
pub struct PfAtlasRebuilds(pub u32);

/// Marker for an atlas page's camera, carrying which page it draws.
///
/// PER PAGE, not one flag covering all of them. Each page is its own
/// 2048x2048 render target, so an active camera costs a full clear and a pass
/// every frame whether or not anything lives on that page. Opening pages to
/// stop shapes blinking would otherwise buy the blink back as steady GPU cost.
#[derive(Component)]
struct PfShapeAtlasCamera(usize);

/// Frames the atlas camera stays active after the last shape edit.
///
/// CURRENTLY UNUSED — see `gate_atlas_camera`. Kept because the idea is
/// right and only the mechanism was wrong.
#[derive(Resource, Default)]
struct PfAtlasDirty(u8);

pub struct PfShapeGpuPlugin;

impl Plugin for PfShapeGpuPlugin {
    fn build(&self, app: &mut App) {
        if !app.is_plugin_added::<bevy_pf_vector::PfVectorPlugin>() {
            app.add_plugins(bevy_pf_vector::PfVectorPlugin);
        }
        // Claim what the atlas can render (see shapes.rs module docs for the
        // backend contract); unclaimed shapes fall through to the CPU
        // rasterizer. When bevy_ui native styling is also compiled in, it
        // claims first — a free bevy_ui node beats an atlas slot.
        let claim = (sync_gpu_shapes, rebuild_atlas_if_full, gate_atlas_camera)
            .chain()
            .in_set(crate::shapes::PfShapeSystems::Claim);
        #[cfg(feature = "native_shapes")]
        let claim = claim.after(crate::shapes::style_native_shapes);
        app.init_resource::<PfAtlasFull>()
            .init_resource::<PfAtlasRebuildCooldown>()
            .init_resource::<PfAtlasRebuilds>()
            .init_resource::<PfAtlasDirty>()
            .add_systems(Startup, setup_atlas)
            .add_systems(PostUpdate, claim);
    }
}

/// Open another atlas page: its texture, its slot layout, and the offscreen
/// camera that draws into it, on its own render layer.
fn open_page(
    index: usize,
    commands: &mut Commands,
    images: &mut Assets<Image>,
    layouts: &mut Assets<TextureAtlasLayout>,
) -> AtlasPage {
    let mut image = Image::new_fill(
        Extent3d {
            width: ATLAS_SIZE,
            height: ATLAS_SIZE,
            depth_or_array_layers: 1,
        },
        TextureDimension::D2,
        &[0, 0, 0, 0],
        TextureFormat::Rgba8UnormSrgb,
        bevy::asset::RenderAssetUsages::RENDER_WORLD,
    );
    image.texture_descriptor.usage =
        TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT;
    image.sampler = bevy::image::ImageSampler::nearest();
    let image = images.add(image);
    let layout = layouts.add(TextureAtlasLayout::new_empty(UVec2::splat(ATLAS_SIZE)));
    let mut projection = OrthographicProjection::default_2d();
    projection.scaling_mode = bevy::camera::ScalingMode::Fixed {
        width: ATLAS_SIZE as f32,
        height: ATLAS_SIZE as f32,
    };
    commands.spawn((
        Camera2d,
        Camera {
            clear_color: ClearColorConfig::Custom(Color::NONE),
            // Pages may share an order because each has a DIFFERENT target,
            // which is what bevy keys its camera-ambiguity warning on.
            order: -100,
            ..default()
        },
        RenderTarget::Image(image.clone().into()),
        Projection::Orthographic(projection),
        bevy::render::view::Msaa::Off,
        RenderLayers::layer(SHAPE_LAYER + index),
        PfShapeAtlasCamera(index),
        Name::new(format!("PfShapeAtlasCamera{index}")),
    ));
    AtlasPage {
        image,
        layout,
        packer: guillotiere::AtlasAllocator::new(guillotiere::size2(
            ATLAS_SIZE as i32,
            ATLAS_SIZE as i32,
        )),
    }
}

fn setup_atlas(
    mut commands: Commands,
    images: Option<ResMut<Assets<Image>>>,
    layouts: Option<ResMut<Assets<TextureAtlasLayout>>>,
) {
    // Headless apps have no image/atlas asset collections. Without this the
    // system fails parameter validation and takes the process down, so simply
    // ADDING this plugin broke any test that did not have a renderer.
    let (Some(mut images), Some(mut layouts)) = (images, layouts) else {
        return;
    };
    let mut image = Image::new_fill(
        Extent3d {
            width: ATLAS_SIZE,
            height: ATLAS_SIZE,
            depth_or_array_layers: 1,
        },
        TextureDimension::D2,
        &[0, 0, 0, 0],
        TextureFormat::Rgba8UnormSrgb,
        bevy::asset::RenderAssetUsages::RENDER_WORLD,
    );
    image.texture_descriptor.usage =
        TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT;
    // NEAREST, NOT THE DEFAULT LINEAR.
    //
    // Every shape shares one texture, so a filtered sample taken at a slot
    // edge reaches into whatever is packed next door. The camera below is
    // set up on the assumption that the atlas is sampled 1:1 — and it very
    // nearly is — but "nearly" is the whole problem: node rects come out of
    // layout in logical points and are scaled by the display factor, so a
    // slot lands on fractional texels often enough. Linear filtering then
    // blends the gutter (a dark fringe along an edge) or, worse, a
    // neighbouring shape's pixels, which is what "lines and odd colours"
    // looks like from the outside.
    //
    // Nearest costs nothing here. The geometry is antialiased analytically
    // by the engine before it ever reaches the atlas, so there is no
    // smoothing to preserve — filtering was only ever blurring a signal
    // that was already correct.
    image.sampler = bevy::image::ImageSampler::nearest();
    let image = images.add(image);
    let layout = layouts.add(TextureAtlasLayout::new_empty(UVec2::splat(ATLAS_SIZE)));

    // Offscreen camera: renders only the shape layer, into the atlas, with a
    // transparent clear. Msaa off — the engine antialiases analytically, and
    // the atlas is sampled 1:1 by bevy_ui anyway.
    let mut projection = OrthographicProjection::default_2d();
    projection.scaling_mode = bevy::camera::ScalingMode::Fixed {
        width: ATLAS_SIZE as f32,
        height: ATLAS_SIZE as f32,
    };
    commands.spawn((
        Camera2d,
        Camera {
            clear_color: ClearColorConfig::Custom(Color::NONE),
            // Behind every on-screen camera: this only fills a texture.
            order: -100,
            ..default()
        },
        // In bevy 0.19 the render target is its own component.
        RenderTarget::Image(image.clone().into()),
        Projection::Orthographic(projection),
        bevy::render::view::Msaa::Off,
        // Page 0's camera. Each page draws on its OWN layer so a page's
        // camera renders only the shapes that live on it -- one shared layer
        // would have every camera redraw every shape into every page.
        RenderLayers::layer(SHAPE_LAYER),
        PfShapeAtlasCamera(0),
        Name::new("PfShapeAtlasCamera0"),
    ));

    commands.insert_resource(PfShapeAtlas {
        image,
        layout,
        packer: guillotiere::AtlasAllocator::new(guillotiere::size2(
            ATLAS_SIZE as i32,
            ATLAS_SIZE as i32,
        )),
        generation: 0,
        extra: Vec::new(),
    });
}

/// Atlas pixel rect -> world position for the atlas camera, whose world
/// origin is the atlas centre with +Y up.
fn slot_center_world(origin: UVec2, size: UVec2) -> Vec2 {
    let half = ATLAS_SIZE as f32 * 0.5;
    Vec2::new(
        origin.x as f32 + size.x as f32 * 0.5 - half,
        half - (origin.y as f32 + size.y as f32 * 0.5),
    )
}

/// Layout space (+Y down, origin top-left of the node) -> engine local space
/// (+Y up, origin at the shape's centre).
fn to_local(x: f32, y: f32, size: Vec2) -> Vec2 {
    Vec2::new(x - size.x * 0.5, size.y * 0.5 - y)
}

fn to_color(c: v::PfColor) -> LinearRgba {
    Color::srgba_u8(c.r, c.g, c.b, c.a).to_linear()
}

fn to_brush(brush: &v::PfBrush, size: Vec2) -> Brush {
    let stops = |stops: &Vec<v::GradientStop>| -> Vec<GradientStop> {
        stops
            .iter()
            .map(|s| GradientStop {
                offset: s.offset.clamp(0.0, 1.0),
                color: to_color(s.color),
            })
            .collect()
    };
    match brush {
        v::PfBrush::Solid(c) => Brush::Solid(to_color(*c)),
        // WPF gradient coordinates are fractions of the shape's box.
        v::PfBrush::LinearGradient {
            start,
            end,
            stops: s,
        } => Brush::Linear {
            start: to_local(start.x * size.x, start.y * size.y, size),
            end: to_local(end.x * size.x, end.y * size.y, size),
            stops: stops(s),
        },
        v::PfBrush::RadialGradient {
            center,
            radius_x,
            radius_y,
            stops: s,
        } => Brush::Radial {
            center: to_local(center.x * size.x, center.y * size.y, size),
            // The engine's radial is circular; match tiny-skia's collapse of
            // the WPF ellipse to its larger axis.
            radius: (radius_x * size.x).max(radius_y * size.y).max(1.0),
            stops: stops(s),
        },
    }
}

/// A rounded rectangle as a closed path, matching the CPU backend's arc
/// approximation exactly so the two look identical.
fn rounded_rect(l: f32, t: f32, r: f32, b: f32, rx: f32, ry: f32, size: Vec2) -> Vec<PathCommand> {
    let k = 0.5522848;
    let p = |x: f32, y: f32| to_local(x, y, size);
    vec![
        PathCommand::MoveTo(p(l + rx, t)),
        PathCommand::LineTo(p(r - rx, t)),
        PathCommand::CubicTo {
            ctrl1: p(r - rx + k * rx, t),
            ctrl2: p(r, t + ry - k * ry),
            to: p(r, t + ry),
        },
        PathCommand::LineTo(p(r, b - ry)),
        PathCommand::CubicTo {
            ctrl1: p(r, b - ry + k * ry),
            ctrl2: p(r - rx + k * rx, b),
            to: p(r - rx, b),
        },
        PathCommand::LineTo(p(l + rx, b)),
        PathCommand::CubicTo {
            ctrl1: p(l + rx - k * rx, b),
            ctrl2: p(l, b - ry + k * ry),
            to: p(l, b - ry),
        },
        PathCommand::LineTo(p(l, t + ry)),
        PathCommand::CubicTo {
            ctrl1: p(l, t + ry - k * ry),
            ctrl2: p(l + rx - k * rx, t),
            to: p(l + rx, t),
        },
        PathCommand::Close,
    ]
}

fn path_data_commands(data: &PathData, size: Vec2) -> Vec<PathCommand> {
    let p = |pt: v::Point| to_local(pt.x, pt.y, size);
    let mut out = Vec::new();
    for figure in &data.figures {
        out.push(PathCommand::MoveTo(p(figure.start)));
        let mut cursor = figure.start;
        for segment in &figure.segments {
            match segment {
                PathSegment::Line(to) => {
                    out.push(PathCommand::LineTo(p(*to)));
                    cursor = *to;
                }
                PathSegment::Cubic(c1, c2, to) => {
                    out.push(PathCommand::CubicTo {
                        ctrl1: p(*c1),
                        ctrl2: p(*c2),
                        to: p(*to),
                    });
                    cursor = *to;
                }
                PathSegment::Quadratic(c, to) => {
                    out.push(PathCommand::QuadTo {
                        ctrl: p(*c),
                        to: p(*to),
                    });
                    cursor = *to;
                }
                PathSegment::Arc {
                    radii,
                    rotation,
                    large_arc,
                    sweep,
                    to,
                } => {
                    // Reuse the CPU backend's endpoint->centre arc conversion
                    // so both backends draw the same curve.
                    for (c1, c2, end) in
                        arc_to_cubics(cursor, *radii, *rotation, *large_arc, *sweep, *to)
                    {
                        out.push(PathCommand::CubicTo {
                            ctrl1: p(c1),
                            ctrl2: p(c2),
                            to: p(end),
                        });
                    }
                    cursor = *to;
                }
            }
        }
        if figure.closed {
            out.push(PathCommand::Close);
        }
    }
    out
}

/// Axis-aligned bounds of a command list's control points — the same
/// conservative box the CPU backend stretches by.
fn commands_bounds(commands: &[PathCommand]) -> Option<(Vec2, Vec2)> {
    let mut min = Vec2::splat(f32::INFINITY);
    let mut max = Vec2::splat(f32::NEG_INFINITY);
    let mut any = false;
    let mut visit = |p: Vec2| {
        min = min.min(p);
        max = max.max(p);
        any = true;
    };
    for command in commands {
        match command {
            PathCommand::MoveTo(p) | PathCommand::LineTo(p) => visit(*p),
            PathCommand::QuadTo { ctrl, to } => {
                visit(*ctrl);
                visit(*to);
            }
            PathCommand::CubicTo { ctrl1, ctrl2, to } => {
                visit(*ctrl1);
                visit(*ctrl2);
                visit(*to);
            }
            PathCommand::Close => {}
        }
    }
    any.then_some((min, max))
}

fn map_commands(commands: &mut [PathCommand], f: impl Fn(Vec2) -> Vec2) {
    for command in commands {
        match command {
            PathCommand::MoveTo(p) | PathCommand::LineTo(p) => *p = f(*p),
            PathCommand::QuadTo { ctrl, to } => {
                *ctrl = f(*ctrl);
                *to = f(*to);
            }
            PathCommand::CubicTo { ctrl1, ctrl2, to } => {
                *ctrl1 = f(*ctrl1);
                *ctrl2 = f(*ctrl2);
                *to = f(*to);
            }
            PathCommand::Close => {}
        }
    }
}

/// Build the engine geometry + style for a shape laid out at `px` pixels.
/// Mirrors `shapes::rasterize_shape` step for step so the backends agree.
pub fn shape_to_vector(shape: &PfShape, px: UVec2) -> Option<(Vec<PathCommand>, PathStyle)> {
    let size = Vec2::new(px.x as f32, px.y as f32);
    let (w, h) = (size.x, size.y);
    let st = shape.stroke_thickness;
    let inset = if shape.stroke.is_some() {
        st * 0.5
    } else {
        0.0
    };

    let (mut commands, rule) = match &shape.geometry {
        ShapeGeometry::Rectangle { radius_x, radius_y } => {
            let (l, t) = (inset, inset);
            let (r, b) = ((w - inset).max(inset + 0.1), (h - inset).max(inset + 0.1));
            let commands = if *radius_x > 0.0 || *radius_y > 0.0 {
                let rx = radius_x.min((r - l) / 2.0);
                let ry = radius_y.max(0.0).min((b - t) / 2.0);
                rounded_rect(l, t, r, b, rx, ry, size)
            } else {
                let p = |x: f32, y: f32| to_local(x, y, size);
                vec![
                    PathCommand::MoveTo(p(l, t)),
                    PathCommand::LineTo(p(r, t)),
                    PathCommand::LineTo(p(r, b)),
                    PathCommand::LineTo(p(l, b)),
                    PathCommand::Close,
                ]
            };
            (commands, VFillRule::NonZero)
        }
        ShapeGeometry::Ellipse => {
            let (l, t) = (inset, inset);
            let (r, b) = ((w - inset).max(inset + 0.1), (h - inset).max(inset + 0.1));
            // An oval is the rounded rect whose radii are half its extents.
            let (rx, ry) = ((r - l) * 0.5, (b - t) * 0.5);
            (rounded_rect(l, t, r, b, rx, ry, size), VFillRule::NonZero)
        }
        ShapeGeometry::Line { x1, y1, x2, y2 } => (
            vec![
                PathCommand::MoveTo(to_local(*x1, *y1, size)),
                PathCommand::LineTo(to_local(*x2, *y2, size)),
            ],
            VFillRule::NonZero,
        ),
        ShapeGeometry::Polyline { points, closed } => {
            let mut iter = points.iter();
            let first = iter.next()?;
            let mut commands = vec![PathCommand::MoveTo(to_local(first.x, first.y, size))];
            for p in iter {
                commands.push(PathCommand::LineTo(to_local(p.x, p.y, size)));
            }
            if *closed {
                commands.push(PathCommand::Close);
            }
            let rule = match shape.fill_rule {
                Some(FillRule::NonZero) => VFillRule::NonZero,
                _ => VFillRule::EvenOdd, // WPF default
            };
            (commands, rule)
        }
        ShapeGeometry::Path(data) => {
            let rule = match data.fill_rule {
                FillRule::EvenOdd => VFillRule::EvenOdd,
                FillRule::NonZero => VFillRule::NonZero,
            };
            (path_data_commands(data, size), rule)
        }
    };

    // Stretch, for coordinate geometries only (rect/ellipse already fill).
    let stretchable = !matches!(
        shape.geometry,
        ShapeGeometry::Rectangle { .. } | ShapeGeometry::Ellipse
    );
    if stretchable
        && shape.stretch != v::Stretch::None
        && let Some((min, max)) = commands_bounds(&commands)
    {
        let extent = (max - min).max(Vec2::splat(1e-3));
        let avail = Vec2::new((w - st).max(1.0), (h - st).max(1.0));
        let mut scale = avail / extent;
        match shape.stretch {
            v::Stretch::Uniform => scale = Vec2::splat(scale.x.min(scale.y)),
            v::Stretch::UniformToFill => scale = Vec2::splat(scale.x.max(scale.y)),
            _ => {}
        }
        // The CPU path works in layout space (+Y down) and lands the box at
        // (st/2, st/2). Here the geometry is already centred, so scale about
        // the box centre and re-centre — the same result, one step fewer.
        let centre = (min + max) * 0.5;
        let target = Vec2::new(0.0, 0.0);
        map_commands(&mut commands, |p| (p - centre) * scale + target);
    }

    let fill = shape.fill.as_ref().map(|b| to_brush(b, size));
    let stroke = shape.stroke.as_ref().map(|b| {
        let width = st.max(0.01);
        StrokeStyle {
            brush: to_brush(b, size),
            width,
            join: match shape.stroke_join {
                v::PenLineJoin::Miter => LineJoin::Miter,
                v::PenLineJoin::Bevel => LineJoin::Bevel,
                v::PenLineJoin::Round => LineJoin::Round,
            },
            cap: match shape.stroke_cap {
                v::PenLineCap::Flat => LineCap::Butt,
                v::PenLineCap::Square | v::PenLineCap::Triangle => LineCap::Square,
                v::PenLineCap::Round => LineCap::Round,
            },
            miter_limit: shape.stroke_miter_limit.max(1.0),
            dash: (!shape.stroke_dash_array.is_empty()).then(|| {
                // WPF dash units are multiples of the stroke thickness.
                let mut pattern: Vec<f32> = shape
                    .stroke_dash_array
                    .iter()
                    .map(|d| (d * width).max(0.01))
                    .collect();
                if pattern.len() % 2 != 0 {
                    let copy = pattern.clone();
                    pattern.extend(copy); // odd counts repeat, like WPF
                }
                DashPattern {
                    pattern,
                    offset: shape.stroke_dash_offset * width,
                }
            }),
        }
    });

    if fill.is_none() && stroke.is_none() {
        return None;
    }
    Some((
        commands,
        PathStyle {
            fill,
            stroke,
            fill_rule: rule,
        },
    ))
}

/// A rect/rounded-rect/ellipse reduces to a rounded box, which the engine
/// draws as an SDF primitive: one quad, and `size` lives in the instance.
///
/// This matters because it is what MOST UI chrome is. Sending those through
/// the tessellated path means every resize mints new geometry and
/// re-tessellates; as an SDF primitive a resize is one instance write. Only
/// genuinely arbitrary path data still needs tessellation, and that geometry
/// is fixed per screen — the case the tessellate-once cache is good at.
///
/// Returns (size, corner_radius) in the node's pixel space.
fn as_rounded_box(shape: &PfShape, px: UVec2) -> Option<(Vec2, f32)> {
    let size = Vec2::new(px.x as f32, px.y as f32);
    match &shape.geometry {
        ShapeGeometry::Rectangle { radius_x, radius_y } => {
            // The engine's SDF takes ONE radius; an elliptical corner would
            // have to stay on the tessellated path.
            if (radius_x - radius_y).abs() > 0.01 {
                return None;
            }
            Some((size, *radius_x))
        }
        // An ellipse inscribed in the box is the rounded box whose radius is
        // half the shortest side — exact only when the box is square, so a
        // non-square ellipse stays tessellated.
        ShapeGeometry::Ellipse => {
            if (size.x - size.y).abs() > 0.01 {
                return None;
            }
            Some((size, size.x * 0.5))
        }
        _ => None,
    }
}

/// Solid colour of a brush, if it is one. Gradients keep the tessellated
/// path, which already carries them per-instance.
fn solid(brush: &v::PfBrush) -> Option<Color> {
    match brush {
        v::PfBrush::Solid(c) => Some(Color::srgba_u8(c.r, c.g, c.b, c.a)),
        _ => None,
    }
}

/// The SDF instances a rounded-box shape wants, in draw order: the first is
/// the fill (or the stroke, when there is no fill), the second is the stroke
/// laid over a fill. `None` means the shape is not SDF-eligible and belongs on
/// the tessellated path.
///
/// Factored out because BOTH `spawn_draws` and the in-place fast path have to
/// agree on it exactly. When the fast path built its own instances, the two
/// could disagree about how many entities a shape wants and which one carries
/// the stroke, and disagreement there is a shape drawn twice or not at all.
///
/// THE STROKE INSTANCE TAKES THE FULL SIZE, and that is the whole subtlety.
/// The CPU backend insets the filled path by half the stroke and then strokes
/// it CENTRED, so the visible border occupies [0, st] measured inward from the
/// node edge and the fill starts at st/2. The SDF stroke is inward-only from
/// whatever size it is handed (`coverage * smoothstep(-aa, aa, dist +
/// thickness)` keeps `-thickness < dist < 0`), so handing it the inset size
/// applies that half-inset a SECOND time and floats the border at
/// [st/2, 3st/2] — half a stroke width adrift, with the outermost half-stroke
/// of the reserved slot left empty. The fill instance does want the inset
/// size, which is why the two are computed separately.
fn sdf_instances(shape: &PfShape, px: UVec2) -> Option<(VectorPrimitive, Option<VectorPrimitive>)> {
    let (size, radius) = as_rounded_box(shape, px)?;
    let fill = shape.fill.as_ref().and_then(solid);
    let stroke = shape.stroke.as_ref().and_then(solid);
    if fill.is_none() && stroke.is_none() {
        return None;
    }
    let st = shape.stroke_thickness;
    let stroke_prim = |color: Color| VectorPrimitive::Rect {
        size,
        radius,
        thickness: st.max(0.01),
        color: color.to_linear(),
    };
    let Some(fill) = fill else {
        // Stroke only: one instance, drawn at the full size.
        return Some((stroke_prim(stroke.expect("fill or stroke")), None));
    };
    // Filled: the fill is inset by half the stroke, matching the CPU path.
    let inset = if stroke.is_some() { st * 0.5 } else { 0.0 };
    let fill_prim = VectorPrimitive::Rect {
        size: (size - Vec2::splat(inset * 2.0)).max(Vec2::splat(0.1)),
        radius: (radius - inset).max(0.0),
        thickness: 0.0,
        color: fill.to_linear(),
    };
    Some((fill_prim, stroke.map(stroke_prim)))
}

/// Spawn the draw entities for a shape into its atlas slot: either SDF
/// primitives (fast path) or one tessellated `VectorShape`.
fn spawn_draws(
    commands: &mut Commands,
    shape: &PfShape,
    px: UVec2,
    origin: UVec2,
    page: usize,
) -> (Entity, Option<Entity>) {
    let centre = slot_center_world(origin, px).extend(0.0);
    let transform = || HudTransform {
        translation: centre,
        ..default()
    };

    if let Some((first_prim, second_prim)) = sdf_instances(shape, px) {
        let first = commands
            .spawn((
                first_prim,
                transform(),
                RenderLayers::layer(SHAPE_LAYER + page),
                Name::new("PfShapeSdf"),
            ))
            .id();
        // Both fill and stroke: the stroke is a second instance over it.
        let second = second_prim.map(|prim| {
            commands
                .spawn((
                    prim,
                    HudTransform {
                        // Above the fill in the slot's local depth.
                        translation: centre + Vec3::new(0.0, 0.0, 1.0e-4),
                        ..default()
                    },
                    RenderLayers::layer(SHAPE_LAYER + page),
                    Name::new("PfShapeSdfStroke"),
                ))
                .id()
        });
        return (first, second);
    }

    let (path, style) = shape_to_vector(shape, px)
        .unwrap_or_else(|| (Vec::new(), PathStyle::fill(LinearRgba::NONE)));
    let draw = commands
        .spawn((
            VectorShape {
                commands: path,
                style,
            },
            transform(),
            RenderLayers::layer(SHAPE_LAYER + page),
            Name::new("PfShapeDraw"),
        ))
        .id();
    (draw, None)
}

/// Give every laid-out shape an atlas slot and a `VectorShape` drawing into
/// it, and point its `ImageNode` at that slot.
#[allow(clippy::type_complexity)]
fn sync_gpu_shapes(
    mut shapes: Query<(
        Entity,
        Ref<PfShape>,
        &ComputedNode,
        Option<&mut PfShapeGpu>,
        Option<&PfShapeRendered>,
        Option<&PfShapeClaim>,
    )>,
    mut draws: Query<(&mut VectorShape, &mut HudTransform), Without<VectorPrimitive>>,
    // Separate query because SDF draws carry `VectorPrimitive`, not
    // `VectorShape`. `Without` on each side is what makes the two provably
    // disjoint, so both may hold `&mut HudTransform`.
    mut prims: Query<(&mut VectorPrimitive, &mut HudTransform), Without<VectorShape>>,
    atlas: Option<ResMut<PfShapeAtlas>>,
    // Optional for the same reason `atlas` is: `PfUiPlugin` adds this backend
    // whenever the feature is compiled in, including into apps that never
    // registered the atlas-layout asset (anything built on MinimalPlugins
    // rather than DefaultPlugins). A required `ResMut` there is not a missing
    // feature, it is a PANIC on the first frame -- which is what took 30 of
    // this crate's 32 test binaries down the moment `vector_gpu` was on.
    layouts: Option<ResMut<Assets<TextureAtlasLayout>>>,
    images: Option<ResMut<Assets<Image>>>,
    mut full: ResMut<PfAtlasFull>,
    mut dirty: ResMut<PfAtlasDirty>,
    mut commands: Commands,
) {
    let Some(mut atlas) = atlas else { return };
    let Some(mut layouts) = layouts else { return };
    let Some(mut images) = images else { return };
    for (entity, shape, computed, mut gpu, cpu_rendered, claimed) in &mut shapes {
        let size = computed.size();
        let px = UVec2::new(size.x.round() as u32, size.y.round() as u32);
        if px.x == 0 || px.y == 0 {
            continue;
        }
        let resized = gpu.as_ref().is_none_or(|g| g.size != px);
        if !resized && !shape.is_changed() {
            continue;
        }
        let Some((_path, _style)) = shape_to_vector(&shape, px) else {
            continue;
        };
        let previous_draw: Vec<Entity> = gpu
            .as_ref()
            .map(|g| {
                [Some(g.draw), g.draw_stroke]
                    .into_iter()
                    .flatten()
                    .collect()
            })
            .unwrap_or_default();

        // Copied before the fast path moves `gpu`: if this shape ends up
        // re-reserving below, this is the region to hand back.
        let old_slot = gpu.as_ref().map(|g| (g.page, g.alloc, g.generation));
        // Another backend got there first -- bevy_ui native styling claims
        // ahead of this one (see the ordering in `build`) and paints the node
        // itself. Without this test the ordering was decorative: the GPU
        // backend re-claimed the shape anyway, overwrote the claim with its
        // own, and both backends painted the same node.
        if claimed.is_some_and(|c| c.backend != "vector_gpu") {
            continue;
        }
        // Does THIS backend hold the shape? Only then may the exhaustion arm
        // below take its components away.
        let held = gpu.is_some() || claimed.is_some();
        let old_index = gpu.as_ref().map(|g| (g.page, g.index));

        // Fast path: the shape still fits its reservation, so the slot, the
        // layout index and the draw entity all stand. A colour-only edit
        // re-tessellates nothing (the engine hashes path content) and uploads
        // no texture — the case this backend exists for.
        //
        // THE SDF ARM IS FIRST BECAUSE IT IS THE COMMON ONE. Rects, rounded
        // rects and square ellipses — nearly all UI chrome — are drawn as SDF
        // primitives, and their draw entities carry `VectorPrimitive`. The
        // tessellated arm below asks `draws` for a `VectorShape`, which those
        // entities do not have, so for a long time this fast path could not be
        // taken by ANY of the shapes the SDF path was built for. Every colour
        // change fell through to a full re-reservation, and each one appended
        // a fresh `layout.add_texture` entry — a vector that only ever grows,
        // and which the wholesale rebuild used to be the only thing to reset.
        // Rate-limiting rebuilds turned that from churn into an unbounded leak.
        if let Some(gpu) = gpu.as_mut()
            && px.cmple(gpu.capacity).all()
            && let Some((first, second)) = sdf_instances(&shape, px)
            // The instance LAYOUT has to match what is already spawned; a
            // shape that gained or lost its stroke needs a real re-spawn.
            && second.is_some() == gpu.draw_stroke.is_some()
            && prims.contains(gpu.draw)
            && gpu.draw_stroke.is_none_or(|e| prims.contains(e))
        {
            let centre = slot_center_world(gpu.origin, px).extend(0.0);
            if let Ok((mut prim, mut transform)) = prims.get_mut(gpu.draw) {
                *prim = first;
                transform.translation = centre;
            }
            if let Some(stroke_entity) = gpu.draw_stroke
                && let Some(second) = second
                && let Ok((mut prim, mut transform)) = prims.get_mut(stroke_entity)
            {
                *prim = second;
                transform.translation = centre + Vec3::new(0.0, 0.0, 1.0e-4);
            }
            dirty.0 = 2;
            if gpu.size != px {
                gpu.size = px;
                if let Some(mut layout) = layouts.get_mut(atlas.page(gpu.page).1.clone().id())
                    && let Some(rect) = layout.textures.get_mut(gpu.index)
                {
                    *rect = URect::from_corners(gpu.origin, gpu.origin + px);
                }
            }
            continue;
        }

        if let Some(mut gpu) = gpu
            && px.cmple(gpu.capacity).all()
            && gpu.draw_stroke.is_none()
            && let Ok((mut vector, mut transform)) = draws.get_mut(gpu.draw)
        {
            let Some((path, style)) = shape_to_vector(&shape, px) else {
                continue;
            };
            vector.commands = path;
            vector.style = style;
            dirty.0 = 2;
            if gpu.size != px {
                gpu.size = px;
                transform.translation = slot_center_world(gpu.origin, px).extend(0.0);
                if let Some(mut layout) = layouts.get_mut(atlas.page(gpu.page).1.clone().id())
                    && let Some(rect) = layout.textures.get_mut(gpu.index)
                {
                    *rect = URect::from_corners(gpu.origin, gpu.origin + px);
                }
            }
            continue;
        }

        // Falling through to here means the shape outgrew its reservation.
        //
        // The old region is handed back AFTER the reservation below succeeds,
        // never before. Releasing first looks harmless -- the shape is about
        // to take a bigger slot -- but on the FAILURE path the removal of
        // `PfShapeGpu` fires `release_slot`, which releases the very same
        // region a second time, in the same generation, so the stamp waves
        // both through and one origin sits in the free list TWICE. The packer
        // then hands those identical pixels to two live shapes and each
        // samples the other's paint.
        //
        // Nothing is lost by waiting: this arm is only reached because the
        // shape OUTGREW its capacity, so the region being released can never
        // satisfy the reservation being made -- the exact-capacity bucket
        // wants a different key, and best-fit requires `cap >= size` on both
        // axes, which the smaller old region fails by construction.
        let wanted = slot_capacity(px);
        // `capacity` is what was actually reserved, which best-fit reuse may
        // widen; storing `wanted` here would release it to the wrong bucket.
        // OPEN A PAGE RATHER THAN GIVE THE SHAPE UP. Demotion is what makes
        // size the failure axis: a shape that grows past the last free region
        // loses its slot, falls to the CPU rasterizer, gets one back when it
        // shrinks, and that oscillation is what a player sees as chrome
        // blinking. Small shapes never hit it, which is exactly why they
        // "do better". Another page costs VRAM and a pass; a demotion costs a
        // full CPU rasterization and a new texture EVERY time the shape
        // resizes -- measured at 19 GB of texture churn in 540 frames.
        //
        // Allocate ONCE and keep the result: calling `allocate` to test and
        // then again to use it reserves two regions and abandons the first.
        let mut slot = atlas.allocate(wanted);
        if slot.is_none() && atlas.page_count() < MAX_PAGES {
            let index = atlas.page_count();
            let page = open_page(index, &mut commands, &mut images, &mut layouts);
            atlas.extra.push(page);
            slot = atlas.allocate(wanted);
        }
        let Some((page, origin, capacity, alloc)) = slot else {
            // Out of room: rebuild the whole atlas next -- but ONLY if a
            // rebuild could actually help. A shape larger than the atlas
            // itself never fits however empty it is, and letting it latch
            // this flag buys a pointless wholesale rebuild every cooldown,
            // forever.
            if wanted.x + SLOT_PADDING <= ATLAS_SIZE && wanted.y + SLOT_PADDING <= ATLAS_SIZE {
                full.0 = true;
            }
            // AND HAND THE SHAPE BACK. The claim is what tells the CPU
            // rasterizer to keep its hands off (it queries
            // `Without<PfShapeClaim>`), so a shape that is claimed but has
            // no slot is drawn by NOBODY — it does not fall through, it
            // disappears. That is how the game lost every console-button
            // border while the labels stayed: the atlas was exhausted, the
            // claim stood, and the fallback this module's own header
            // promises ("never worse than not having it") never ran.
            //
            // Releasing the claim and the ImageNode puts the shape back in
            // the CPU path's query on the very next frame, so exhaustion
            // costs rasterization time instead of visible chrome.
            //
            // `PfShapeRendered` goes too, and leaving it out is what made the
            // promise above a lie. It is the CPU rasterizer's cache marker and
            // its guard is SIZE-ONLY (`rendered.0 == px` in `rasterize_shapes`):
            // a shape that was CPU-drawn, then claimed by this backend, then
            // demoted still carries a marker matching its current size, so the
            // rasterizer skips it as already-done while the `ImageNode` that
            // marker refers to has just been taken away. Nobody draws it, and
            // nobody ever will.
            //
            // And only strip a shape THIS backend actually holds. Without that
            // test the arm re-runs every frame for an already-demoted shape and
            // removes the `ImageNode` the CPU rasterizer installed one frame
            // earlier, so the fallback flickers once and then stops.
            if held {
                commands
                    .entity(entity)
                    .remove::<(PfShapeGpu, PfShapeClaim, ImageNode, PfShapeRendered)>();
            }
            continue;
        };
        // Safe now: the reservation stands, so this release cannot be paired
        // with the hook-driven one on the failure path above.
        if let Some((page, alloc, generation)) = old_slot {
            atlas.release(page, alloc, generation);
        }
        let Some(mut layout) = layouts.get_mut(atlas.page(page).1.clone().id()) else {
            continue;
        };
        // REUSE THE INDEX THIS SHAPE ALREADY OWNS. `add_texture` only ever
        // pushes, and nothing truncates `layout.textures`, so minting a fresh
        // index on every re-reservation grows that vector for the life of the
        // process -- one entry per resize, per shape, forever. The wholesale
        // rebuild used to be the only thing that reset it, which made
        // rate-limiting rebuilds turn steady churn into an unbounded leak.
        //
        // A shape's index is a stable name for "wherever this shape lives"; it
        // is the RECT behind the name that changes when it moves.
        let rect = URect::from_corners(origin, origin + px);
        // ONLY ON THE SAME PAGE. Each page owns a separate
        // `TextureAtlasLayout`, so an index is a name within ONE page's table.
        // Reusing an index from the old page against the new page's layout
        // would overwrite whatever shape holds that index there -- two shapes
        // pointing at one rect, which is the aliasing this backend has already
        // been bitten by twice.
        let index = match old_index {
            Some((old_page, index)) if old_page == page && index < layout.textures.len() => {
                layout.textures[index] = rect;
                index
            }
            _ => layout.add_texture(rect),
        };

        // A grown shape abandons its old reservation; the rebuild reclaims it.
        for previous in previous_draw {
            commands.entity(previous).despawn();
        }
        let (draw, draw_stroke) = spawn_draws(&mut commands, &shape, px, origin, page);

        dirty.0 = 2;
        commands.entity(entity).insert((
            ImageNode::from_atlas_image(
                atlas.page(page).0.clone(),
                TextureAtlas {
                    layout: atlas.page(page).1.clone(),
                    index,
                },
            )
            .with_mode(NodeImageMode::Stretch),
            PfShapeGpu {
                index,
                origin,
                capacity,
                alloc,
                page,
                size: px,
                draw,
                draw_stroke,
                generation: atlas.generation,
            },
            PfShapeClaim {
                backend: "vector_gpu",
            },
        ));
        // The CPU backend's cache marker is meaningless once this backend owns
        // the node; drop it so the two never fight over the `ImageNode`.
        if cpu_rendered.is_some() {
            commands.entity(entity).remove::<PfShapeRendered>();
        }
    }
}

/// Reclaim the atlas when it fills. Slots are never individually freed, so a
/// long session of resizes eventually exhausts the packer; this drops every
/// reservation at once and lets the next frame re-register each live shape.
/// Cheaper and simpler than tracking free lists, and rare enough not to be
/// worth more.
fn rebuild_atlas_if_full(
    mut full: ResMut<PfAtlasFull>,
    atlas: Option<ResMut<PfShapeAtlas>>,
    layouts: Option<ResMut<Assets<TextureAtlasLayout>>>,
    slots: Query<(Entity, &PfShapeGpu)>,
    mut rebuilds: ResMut<PfAtlasRebuilds>,
    mut cooldown: ResMut<PfAtlasRebuildCooldown>,
    mut commands: Commands,
) {
    if cooldown.0 > 0 {
        cooldown.0 -= 1;
        // Still claimed-but-slotless shapes are already back on the CPU path
        // (see the exhaustion arm of `sync_gpu_shapes`), so dropping the flag
        // here loses nothing.
        full.0 = false;
        return;
    }
    if !full.0 {
        return;
    }
    full.0 = false;
    cooldown.0 = REBUILD_COOLDOWN;
    // A rebuild is USER-VISIBLE: every shape loses its slot for a frame, which
    // reads as the whole UI blinking. It had no log at all, so the only way to
    // notice one was to catch the flicker by eye.
    let losing = slots.iter().count();
    warn!("pf: shape atlas rebuild -- {losing} shapes lose their slot for one frame");
    rebuilds.0 = rebuilds.0.saturating_add(1);
    let Some(mut atlas) = atlas else { return };
    let Some(mut layouts) = layouts else { return };
    atlas.reset();
    if let Some(mut layout) = layouts.get_mut(&atlas.layout) {
        layout.textures.clear();
    }
    for (entity, gpu) in &slots {
        commands.entity(gpu.draw).despawn();
        if let Some(stroke) = gpu.draw_stroke {
            commands.entity(stroke).despawn();
        }
        commands
            .entity(entity)
            .remove::<(PfShapeGpu, PfShapeClaim, ImageNode)>();
    }
}

/// The atlas camera runs continuously while at least one shape samples it.
///
/// It used to be gated on a dirty counter — skip the pass and the 2048x2048
/// clear when no shape changed — which measured ~0.13 ms better on static UI.
/// That was WRONG: an inactive camera's render target does not reliably
/// retain its contents, so every shape vanished a few frames after it was
/// drawn. Caught in the game as "the borders around the access code fields
/// disappeared", reproduced in `shapes_gpu_check` as every specimen blank.
///
/// Turning it off when there are NO slots is different: no `ImageNode` can be
/// sampling the target, so its contents are irrelevant. Native bevy_ui shapes
/// commonly leave this backend empty; skipping the empty pass removes its
/// fixed cost without putting retained pixels at risk.
fn gate_atlas_camera(
    mut dirty: ResMut<PfAtlasDirty>,
    slots: Query<&PfShapeGpu>,
    mut cameras: Query<(&mut Camera, &PfShapeAtlasCamera)>,
) {
    dirty.0 = 0;
    // Occupancy per page, so an empty page costs nothing. A page that HOLDS a
    // slot must keep rendering: an inactive camera's target does not reliably
    // retain its contents, which is how "the borders around the access code
    // fields disappeared" -- so this gates on emptiness, never on idleness.
    let mut occupied = [false; MAX_PAGES];
    for gpu in &slots {
        if let Some(flag) = occupied.get_mut(gpu.page) {
            *flag = true;
        }
    }
    for (mut camera, page) in &mut cameras {
        let active = occupied.get(page.0).copied().unwrap_or(false);
        if camera.is_active != active {
            camera.is_active = active;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn atlas() -> PfShapeAtlas {
        PfShapeAtlas {
            image: Handle::default(),
            layout: Handle::default(),
            packer: guillotiere::AtlasAllocator::new(guillotiere::size2(
                ATLAS_SIZE as i32,
                ATLAS_SIZE as i32,
            )),
            extra: Vec::new(),
            generation: 0,
        }
    }

    #[test]
    fn atlas_camera_runs_only_while_a_slot_is_sampled() {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .init_resource::<PfAtlasDirty>()
            .add_systems(Update, gate_atlas_camera);
        let camera = app
            .world_mut()
            .spawn((Camera::default(), PfShapeAtlasCamera(0)))
            .id();

        app.update();
        assert!(
            !app.world().get::<Camera>(camera).unwrap().is_active,
            "an empty atlas has no consumers and should not clear or render"
        );

        let mut atlas = atlas();
        let (page, origin, capacity, alloc) = atlas.allocate(UVec2::splat(16)).unwrap();
        let draw = app.world_mut().spawn_empty().id();
        let slot = app
            .world_mut()
            .spawn(PfShapeGpu {
                index: 0,
                origin,
                capacity,
                alloc,
                page,
                size: UVec2::splat(16),
                draw,
                draw_stroke: None,
                generation: 0,
            })
            .id();

        app.update();
        assert!(
            app.world().get::<Camera>(camera).unwrap().is_active,
            "a live slot samples retained atlas pixels, so the camera must stay active"
        );

        app.world_mut().despawn(slot);
        app.update();
        assert!(
            !app.world().get::<Camera>(camera).unwrap().is_active,
            "once the last slot is gone, target persistence no longer matters"
        );
    }

    /// A rebuild resets the packer immediately, but the component removals it
    /// queues fire `release_slot` afterwards -- so the deallocations arrive
    /// against an allocator that has already been cleared. Guillotine ids are
    /// REISSUED after a clear, so a stale one does not merely refer to nothing:
    /// it names whichever LIVE allocation happens to hold that id now, and
    /// freeing it hands those pixels to a second owner.
    ///
    /// Measured in the game before the stamp existed: a console button's 4x4
    /// teal dot rendered as a hard-edged CYAN bar, sampling a slot that
    /// belonged to a different control, and scrollbars carried a different
    /// colour along each stretch of their length.
    #[test]
    fn a_release_from_before_a_reset_cannot_free_a_live_allocation() {
        let mut atlas = atlas();
        let stale: Vec<guillotiere::AllocId> = (0..8)
            .map(|_| atlas.allocate(UVec2::splat(16)).expect("fresh atlas").3)
            .collect();

        // The wholesale rebuild: packer cleared now, removals queued.
        atlas.reset();

        // Live shapes re-register against the rebuilt atlas FIRST...
        let live: Vec<(UVec2, guillotiere::AllocId)> = (0..8)
            .map(|_| {
                let (_, o, _, id) = atlas.allocate(UVec2::splat(16)).expect("rebuilt atlas");
                (o, id)
            })
            .collect();

        // ...and only then do the queued hooks land, each carrying an id from
        // the atlas that no longer exists.
        for id in &stale {
            atlas.release(0, *id, 0);
        }

        // Every live reservation must still be exclusively its owner's: ask for
        // as many regions again and none may land on a live origin.
        let live_origins: std::collections::HashSet<(u32, u32)> =
            live.iter().map(|(o, _)| (o.x, o.y)).collect();
        for _ in 0..8 {
            let (_, origin, _, _) = atlas.allocate(UVec2::splat(16)).expect("space remains");
            assert!(
                !live_origins.contains(&(origin.x, origin.y)),
                "origin {origin:?} was handed out while a live shape still holds it -- \
                 a stale release freed an allocation belonging to somebody else"
            );
        }
    }

    /// The stamp must not break ordinary reuse: a shape that outgrows its slot
    /// inside one generation still hands the region back, and guillotine
    /// merging must make that space genuinely available again.
    #[test]
    fn a_release_within_the_same_generation_returns_usable_space() {
        let mut atlas = atlas();
        // Fill with a size that divides the atlas evenly, then free it all and
        // confirm the whole area comes back -- which only happens if freed
        // rectangles MERGE. A shelf packer keeping fixed-size leftovers would
        // fail the final large allocation.
        let mut ids = Vec::new();
        while let Some((_, _, _, id)) = atlas.allocate(UVec2::splat(256)) {
            ids.push(id);
            if ids.len() > 128 {
                break;
            }
        }
        assert!(ids.len() > 8, "expected many 256px slots in a 2048px atlas");
        let generation = atlas.generation;
        for id in ids {
            atlas.release(0, id, generation);
        }
        assert!(
            atlas.allocate(UVec2::splat(1024)).is_some(),
            "after freeing every small slot, a large one must fit -- freed \
             regions have to merge, not sit in per-size pools"
        );
    }

    /// The SDF stroke is inward-only from the size it is handed, so it must be
    /// handed the FULL node size. Passing the fill's inset size applies the
    /// half-stroke inset twice and floats the border half a stroke width
    /// inward, leaving the outermost half-stroke of the slot empty -- visible
    /// as a thin gap between a control's edge and its own border.
    #[test]
    fn the_sdf_stroke_covers_the_node_edge_not_a_half_stroke_inside_it() {
        let mut shape = PfShape::new(crate::shapes::ShapeGeometry::Rectangle {
            radius_x: 0.0,
            radius_y: 0.0,
        });
        shape.fill = Some(v::PfBrush::Solid(v::PfColor {
            r: 1,
            g: 2,
            b: 3,
            a: 255,
        }));
        shape.stroke = Some(v::PfBrush::Solid(v::PfColor {
            r: 4,
            g: 5,
            b: 6,
            a: 255,
        }));
        shape.stroke_thickness = 4.0;

        let (fill, stroke) = sdf_instances(&shape, UVec2::new(100, 60)).expect("SDF eligible");
        let VectorPrimitive::Rect {
            size: stroke_size,
            thickness,
            ..
        } = stroke.expect("fill + stroke means two instances")
        else {
            panic!("stroke instance should be a Rect")
        };
        assert_eq!(
            stroke_size,
            Vec2::new(100.0, 60.0),
            "the stroke instance must take the FULL size; handing it the inset \
             size stroked inward from an already-inset edge"
        );
        assert_eq!(thickness, 4.0);

        // The fill IS inset by half the stroke, matching the CPU backend.
        let VectorPrimitive::Rect {
            size: fill_size,
            thickness: fill_thickness,
            ..
        } = fill
        else {
            panic!("fill instance should be a Rect")
        };
        assert_eq!(fill_size, Vec2::new(96.0, 56.0));
        assert_eq!(fill_thickness, 0.0, "a fill is not a stroke");
    }

    /// A stroke with no fill is one instance, and it too takes the full size.
    #[test]
    fn a_stroke_only_shape_is_a_single_full_size_instance() {
        let mut shape = PfShape::new(crate::shapes::ShapeGeometry::Rectangle {
            radius_x: 0.0,
            radius_y: 0.0,
        });
        shape.stroke = Some(v::PfBrush::Solid(v::PfColor {
            r: 4,
            g: 5,
            b: 6,
            a: 255,
        }));
        shape.stroke_thickness = 2.0;
        let (first, second) = sdf_instances(&shape, UVec2::new(40, 40)).expect("SDF eligible");
        assert!(second.is_none(), "no fill means no second instance");
        let VectorPrimitive::Rect {
            size, thickness, ..
        } = first
        else {
            panic!("expected a Rect")
        };
        assert_eq!(size, Vec2::new(40.0, 40.0));
        assert_eq!(thickness, 2.0);
    }
}