bevy_grimoire_core 0.8.0

Engine-free world-building core: constraint IR, solvers, WFC, deterministic RNG.
Documentation
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
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
//! **The asset descriptor** — one schema for what a mesh is, where it may go, and what it offers.
//!
//! Replaces the two parallel schemas the game grew: `placement::manifest::ManifestItem` (open,
//! string-keyed, with `Role`/`tags`/`affordances`) and `site::kit::KitPiece` (closed enum, with
//! `front`/`scale`/`rests_on`). Neither was a superset; they intersected on `glb`, `height`,
//! `footprint`, `surfaces` and `y_offset` and diverged everywhere else. See
//! `docs/2026-08-03-asset-schema-audit.md` §4 for the field-by-field comparison.
//!
//! # Every field is optional, and that is the design
//!
//! A [`Descriptor`] is a **patch**, not an instance: absence means *inherit*, never *zero*. That
//! shape is deliberate rather than lazy — Bevy 0.19's BSN scenes are patches that layer over
//! defaults, and if a first-party `.bsn` asset loader lands, a descriptor that is already a patch
//! ports mechanically instead of becoming a fourth migration. It also gives per-instance overrides in
//! a map for free: the base says what a crate is, the placement says what *this* crate is.
//!
//! The price is that "is this descriptor usable?" becomes a question with an answer —
//! [`Descriptor::resolve`] — instead of being true by construction. That is the right trade: a
//! missing footprint should be one loud error naming the id, not a silent `(0.0, 0.0)` reservation
//! that lets a prop overlap everything.
//!
//! # `mount` is the layering axis
//!
//! It replaces `Role`, `rests_on`, and — importantly — the height heuristic in
//! `site::layout::is_floor_marking`, which inferred "this is a decal" from a mesh being under 15 cm
//! and had already misclassified a 10.9 cm mug. Layering is now something an author *states*.
//!
//! [`Mount::Decal`] is the case that could not be expressed at all before: a decal on a wall. The
//! Site's props carried no Y, no host and no normal, so the only wall-mounted object in the game was
//! `DoorPlaque`, hardcoded in Rust and never authored.

use serde::{Deserialize, Serialize};

/// What an asset is. A patch — see the module docs.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Descriptor {
    /// Stable identity, opaque to everything here. The only required field.
    pub id: String,
    /// Path to the mesh, relative to the consuming project's asset root.
    pub mesh: Option<String>,
    pub align: Align,
    pub extent: Extent,
    pub mount: Option<Mount>,
    /// Space that must stay free around the piece. See [`Clearance`].
    pub clearance: Vec<Clearance>,
    pub offers: Offers,

    // ── The typed tag axes ────────────────────────────────────────────────────────────────────
    //
    // Three lists rather than one, because the alternative was tried and named a bug. The kit split
    // `surfaces` (what a piece OFFERS) from `affordances` (what it is FOR) after conflating them let
    // a mug seat itself on a bed — "exactly the 'prop rests on a bed' bug", as that comment puts it.
    // Appearance, category and effect are three more axes, and folding them into one `tags` list
    // would make a query for "something that recharges stamina" able to match a brown thing.
    /// What it *is*: `"furniture"`, `"table"`, `"light-source"`, `"food"`.
    pub kind: Vec<String>,
    /// What it *does* to whoever uses it or to the world: `"stamina-recharge"`, `"uses-electricity"`.
    pub effects: Vec<String>,
    /// What it *looks like*: `"brown"`, `"rusted"`, `"metal"`. Never matched by gameplay; present so
    /// an author can search the palette the way they think about it.
    pub look: Vec<String>,

    /// Hints for the generator — where this belongs and what it belongs *with*. See [`Placement`].
    pub placement: Placement,

    /// The tile's internal lattice, or `None` for a piece that says nothing about its inside.
    ///
    /// **An `Option` because a patch has to be able to say nothing.** [`Self::patched_with`] used a
    /// bare `Subgrid` and treated `Subgrid::default()` as "unset" — but that is also the commonest
    /// legal value, so a patch deliberately clearing a lattice was indistinguishable from a patch
    /// with no opinion about it, and the second reading silently won. `None` is "no opinion";
    /// `Some(Subgrid::default())` is "this piece has no marked cells".
    pub subgrid: Option<Subgrid>,

    /// What this asset is and why it is set up the way it is, as data.
    ///
    /// Same argument as [`crate::map::Map::note`]: prose a serializer can lose is prose that gets
    /// lost. The kit's own entries carry paragraphs of it — why `wall_low` is the one piece still
    /// scaled, how `front` was derived — and today that survives only because nothing re-serializes
    /// the file.
    pub note: Option<String>,

    /// **Where each of the fields above came from** — a machine's guess, or a person's decision.
    ///
    /// `None` is *no record*, which is what every descriptor written before 2026-08-20 has and what
    /// a generator that fills this struct from a manifest still writes. It is deliberately **not**
    /// read as "a human decided this": absence of evidence is the one thing this field exists to
    /// stop being mistaken for evidence.
    ///
    /// See [`LabelOrigin`] for why it is per-axis.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub labels: Option<LabelOrigin>,
}

/// **How a descriptor's labels arrived, one axis at a time.**
///
/// # Why this exists
///
/// A vision model proposes `kind`, `effects`, `look`, `surfaces`, a `mount` and a description; a
/// person accepts them with one keypress. After that keypress the file held the *values* and nothing
/// else — the model's name, the date and its own stated confidence were dropped on the floor, and a
/// guess and a judgement were byte-identical from then on.
///
/// That is the failure Goddard, Roudsari & Wyatt 2011 (`10.1136/amiajnl-2011-000089`) call
/// **automation bias**: the measured tendency to over-accept an automated recommendation, producing
/// errors of commission that would not happen without the aid. Two of their named mitigations are
/// *emphasising accountability* and *attaching confidence to the output* — which require the record
/// to survive the acceptance, because otherwise there is nothing left to attach it to.
///
/// The shape is PROV-DM's (Belhajjame et al., W3C 2013) at its smallest: an **agent** ([`By`], and
/// [`Self::model`] when that agent is a model), an **activity** collapsed to the moment it happened
/// ([`Self::at`]), and the **entity** being the descriptor this rides on.
///
/// # Why per-axis
///
/// Because the mixed case is the normal case. You keep the model's `kind` and rewrite its
/// description; a single "model or human" stamp would call the whole piece human the moment one word
/// was touched, and the question worth answering — *which of these has nobody actually checked?* —
/// would have no answer again.
///
/// # Why `at` has no default
///
/// This carried a container-level `#[serde(default)]`, so a record with no date deserialised to
/// `at: ""` and said nothing about it. That is the one field the whole struct is *for*: a person
/// deciding what to re-check reads the date, and an empty string reads as *"re-checked at the dawn
/// of time"* — an absence wearing a value's clothes, which is precisely the mistake
/// [`Descriptor::labels`] exists to stop. The default moved down onto the three fields that have an
/// honest absence, and `at` now refuses at parse instead.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LabelOrigin {
    /// Per-axis, and `None` per axis means the same as `None` here: no record.
    #[serde(default)]
    pub by: AxisOrigin,
    /// Which model, when one wrote any of it. Absent for a piece only ever labelled by hand.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// The day the model wrote its axes, `YYYY-MM-DD`. Set by [`LabelOrigin::stamped`] and never
    /// moved by a person's touch, which moves `at`. Absent on a record written before this field
    /// existed; a reader falls back to `at`, which was then the model's date.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model_at: Option<String>,
    /// The day of the most recent write, `YYYY-MM-DD`. A date rather than a timestamp: this is read
    /// by a person deciding what to re-check, not by anything that needs to order two edits.
    ///
    /// **Required**, and the only field here that is. A provenance record is a claim that somebody
    /// or something wrote these values at a moment; a record that cannot say when is not a weaker
    /// record, it is a different kind of statement, and it must be refused rather than filled in
    /// with `""` — see the struct docs.
    pub at: String,
    /// How sure the model said it was, carried through from the proposal. Display only, never a
    /// branch — the same rule it follows while the proposal is still pending.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub confidence: Option<Confidence>,
}

/// One [`By`] per field a labeller can write, so the mixed case has somewhere to live.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct AxisOrigin {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub kind: Option<By>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub effects: Option<By>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub look: Option<By>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub surfaces: Option<By>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mount: Option<By>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub note: Option<By>,
}

/// **Who wrote a value.**
///
/// Three variants: the model, the person and the agent each claim a value, and the record keeps
/// them apart. An `Import` for "the measuring pass put this here" was drafted and cut, because
/// nothing in the importer writes any of these six fields — it measures size, extent and cells — so
/// it would have had no writer. `None` already says *no record*, which is exactly and only what a
/// manifest-generated descriptor means.
///
/// **Old readers.** A file without `Agent` parses everywhere. A `library.ron` that holds `Agent`
/// (written by `label_set` from an agent) does not parse in a core older than the one that added
/// the variant, so a consumer pinned to an older core must move with the editor.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum By {
    /// A model proposed it and a person pressed apply. **Not the same as a person deciding it** —
    /// that distinction is the whole point of this enum.
    Model,
    /// A person typed it, clicked it, or confirmed it in the editor.
    Human,
    /// An agent wrote it through a registered operation (`label_set`). **Not a person's word**, so
    /// it counts as unconfirmed, the same as a model's; and not a model's, so the record does not
    /// claim a VLM said it.
    Agent,
}

/// How sure a labeller said it was — display only, never a branch.
///
/// Lives here rather than in the mapper's `vlm` module because a descriptor now carries one: a type
/// two crates share has to sit in the one they both depend on, and duplicating three variants so
/// each crate could own a copy is how two enums that mean the same thing drift apart.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Confidence {
    High,
    Medium,
    Low,
}

/// **The word an author reads**, lower case: `high`, `medium`, `low`. The one spelling every
/// surface uses, so a proposal line and a labels row cannot say the same confidence two ways.
impl std::fmt::Display for Confidence {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Self::High => "high",
            Self::Medium => "medium",
            Self::Low => "low",
        })
    }
}

/// **The inverse of `Display`**: the three words, exactly as written. The one place a word is read.
impl std::str::FromStr for Confidence {
    type Err = String;
    fn from_str(word: &str) -> Result<Self, String> {
        match word {
            "high" => Ok(Self::High),
            "medium" => Ok(Self::Medium),
            "low" => Ok(Self::Low),
            other => Err(format!("confidence is high, medium or low, not \"{other}\"")),
        }
    }
}

impl LabelOrigin {
    /// **A fresh record naming the model that is about to write**, with no axis claimed yet.
    ///
    /// The caller stamps the axes it actually wrote, one at a time, because a suggestion does not
    /// carry all six: a proposal with no `mount` leaves the author's mount alone, and claiming it
    /// here would be a record of something that did not happen.
    pub fn stamped(model: &str, at: &str, confidence: Option<Confidence>) -> LabelOrigin {
        LabelOrigin {
            by: AxisOrigin::default(),
            model: Some(model.to_owned()),
            at: at.to_owned(),
            model_at: Some(at.to_owned()),
            confidence,
        }
    }

    /// **The model's labelling date**, or `None` when no model wrote the record. A record written
    /// before `model_at` existed has no separate date, and `at` was then the model's, so it stands
    /// in. One rule, shared by the labels row and `grimoire/pieces`, so they cannot show two dates.
    pub fn model_date(&self) -> Option<&str> {
        self.model.as_ref().map(|_| self.model_at.as_deref().unwrap_or(&self.at))
    }
}

impl AxisOrigin {
    /// The one axis a hand edit touched, by the name the editor's tag block uses.
    ///
    /// A method rather than six call sites reaching into fields, so "the author clicked a `look`
    /// chip" is one statement in one place — the same argument [`Descriptor::labels`] makes about
    /// having a single record at all.
    pub fn set(&mut self, axis: Axis, by: By) {
        *match axis {
            Axis::Kind => &mut self.kind,
            Axis::Effects => &mut self.effects,
            Axis::Look => &mut self.look,
            Axis::Surfaces => &mut self.surfaces,
            Axis::Mount => &mut self.mount,
            Axis::Note => &mut self.note,
        } = Some(by);
    }

    /// What wrote this axis, or `None` for no record.
    pub fn get(&self, axis: Axis) -> Option<By> {
        match axis {
            Axis::Kind => self.kind,
            Axis::Effects => self.effects,
            Axis::Look => self.look,
            Axis::Surfaces => self.surfaces,
            Axis::Mount => self.mount,
            Axis::Note => self.note,
        }
    }

    /// **Whether a model or an agent wrote an axis that nobody has since confirmed.** The one
    /// predicate the labels row and `grimoire_list_pieces`' `by_model` both ask, so the two cannot
    /// disagree about which pieces still await a person.
    pub fn any_unconfirmed(&self) -> bool {
        [self.kind, self.effects, self.look, self.surfaces, self.mount, self.note]
            .iter()
            .any(|by| matches!(by, Some(By::Model | By::Agent)))
    }
}

/// **Which labelled field**, so [`AxisOrigin`] can be addressed rather than pattern-matched at every
/// call site.
///
/// Six, not four: `mount` and `note` are written by the same apply and are just as worth doubting as
/// a tag is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Axis {
    Kind,
    Effects,
    Look,
    Surfaces,
    Mount,
    Note,
}

/// **A tile's internal lattice** — the thing that lets two pieces agree on where they meet.
///
/// A descriptor's [`Extent::footprint`] says how much floor a piece takes; `grid::cells` rounds that
/// up to whole cells. That is enough to stop two pieces overlapping and not enough for anything else:
/// it cannot say that an L-shaped desk leaves its inner corner free, that a table's four sides each
/// seat someone, or that this wall segment may only abut another wall segment. Those are three
/// questions about *where inside the tile*, and the tile had no inside.
///
/// One lattice answers all three, because they are facets of the same fact — what is at (x, y, z)
/// within this piece:
///
/// * [`SubCell::solid`] — occupancy. Clearance and flood fill can respect the shape rather than the
///   bounding box.
/// * [`SubCell::edge`] — what the cell presents to the neighbour. WFC matches a tile's face against
///   the facing cells of the tile beside it, which is what makes a corridor meet a corridor.
///
/// # The divisions are not stored here
///
/// **A lattice knows its cells; the project knows how finely a tile divides.** `div` used to be a
/// per-descriptor field defaulting to 3×3×3, which meant a 3 m wall had 1 m cells and a 0.5 m chair
/// had 0.167 m ones — two faces that [`crate::adjacency::seam`] compares cell against cell and
/// that could never mean the same thing. Merrell & Manocha's model synthesis is explicit about why
/// that cannot work: the grid is three sets of parallel planes and *"all planes within each set are
/// parallel and evenly spaced"*, so a spacing that varies per object is not a grid at all
/// (Merrell & Manocha 2009, *Constraint-Based Model Synthesis*, §4.4).
///
/// So the spacing is one project-level number — [`crate::policy::Policy::divisions`] — and a piece's
/// divisions are **derived** from its own size by [`divisions`]. The same paper names this exact
/// remedy for objects that do not land on round multiples: *"the planes could be spaced more closely.
/// If they are spaced twice as close, an object that was 1.5-plane spaces wide would become three
/// planes wide"* (§4.5).
///
/// # Sparse, because most tiles have nothing to say
///
/// The shipped library is 43 mostly-rectangular props. Writing every cell would be thousands of rows
/// of `solid: false` — so only cells that differ from open-and-unlabelled are written, and a piece
/// with no lattice detail costs nothing at all.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Subgrid {
    /// Only the cells that are not plain open space. See the note on sparseness.
    pub cells: Vec<SubCell>,
}

/// **How finely a piece divides**, given the project's divisions-per-tile.
///
/// A subunit is a `grid::SNAP / per_tile` **cube**: each axis is the whole number of authoring cells
/// the piece spans, times `per_tile`. So every piece in a project is measured against the same
/// spacing on every axis, which is what makes an edge token on a 3 m wall mean the same thing as one
/// on a 0.5 m chair.
///
/// # The piece as placed, not the mesh as measured
///
/// The height is `extent.height * align.stretch_y`, because that is how tall the piece **stands**.
/// Reading `extent.height` alone made the same piece id derive a different lattice in two kits: the
/// Site kit's wall is authored at 2.40 m and stretched 1.0, so five layers; `site_greybox`'s is a 1 m
/// module stretched 2.4x, so it derived **two** — a lattice describing a piece a third of the height
/// of the one actually in the world. Found by authoring the first real tokens, which is the only way
/// it could have been found.
///
/// **`align.scale` is not applied**, because `extent` already carries it: the extents are recorded
/// post-scale (see [`placed_footprint`], where the contract and the `site/books` datum that proves it
/// are written out), so the lattice derives from `extent` and `stretch_y` alone and agrees with
/// `stack::covers`, `fill` and the drawn mesh by construction.
///
/// `grid::cells` never returns zero, so a decal with no height gets one layer rather than a
/// degenerate lattice — no special case needed.
///
/// A missing `footprint` is refused by id rather than resolved to `(0, 0)`, the same call
/// [`Descriptor::resolve`] makes and for the same reason: a zero lattice has no cells and every rule
/// reading it would silently report success.
pub fn divisions(d: &Descriptor, per_tile: u32) -> Result<(u32, u32, u32), String> {
    let owner = &d.id;
    if per_tile == 0 {
        return Err(format!(
            "`{owner}`: the project divides each tile 0 ways; an axis with no divisions has no cells"
        ));
    }
    let (w, dep) = placed_footprint(d).ok_or_else(|| {
        format!("`{owner}`: no `extent.footprint`, so its lattice cannot be derived")
    })?;
    // A decal may legitimately omit its height — `Descriptor::resolve` says so — and `grid::cells(0)`
    // is one cell, so the lattice is one layer deep rather than empty.
    let h = placed_height(d).unwrap_or(0.0);
    let axis = |span: f32| crate::grid::cells(span).0 * per_tile;
    Ok((axis(w), axis(h), axis(dep)))
}

/// **The footprint a piece actually occupies**, in metres — which is `extent.footprint`, verbatim.
///
/// # `align.scale` is deliberately NOT applied, and this time that is the contract
///
/// `extent` records the piece **as placed**: `src/site/visuals.rs` states it in as many words —
/// *"the kit's `height`/`footprint` are the post-scale values because every placement rule reads
/// them"* — and the one shipped non-unity scale proves it. `site/books`' raw mesh measures 0.5096 m
/// wide; the file records `footprint (0.306, 0.106)` with `scale: Some(0.6)`, and 0.5096 × 0.6 is
/// 0.306. **`scale` maps the authored mesh onto the recorded extent** — a render instruction, exactly
/// like [`Align::rotate`], whose extents are likewise baked at import so no reader learns it exists.
///
/// A previous version multiplied by `scale` here, on the argument that the vertical axis already did
/// (`stack::drawn_height` was `h × scale × stretch_y`). That was a double-application: it shrank
/// `books`' every space answer to 0.184 m while the mesh drew at 0.306 m — the exact
/// drawn-versus-reserved disagreement it claimed to fix, created rather than closed. The editor's
/// `SIZE (m)` field therefore **bakes**: it rewrites `extent` and composes `scale`, so this function
/// stays a plain read.
///
/// It exists as a function rather than a field access so the question "how much room" has one name —
/// [`divisions`], `stack::covers`, `fill::cell_extents`, `adjacency::faults` and the editor all come
/// through here, and the contract above is written at the one place they share.
///
/// `None` for an unmeasured piece, which is propagated rather than defaulted. A zero footprint
/// overlaps nothing and would let the piece sit inside a wall with every rule reporting success —
/// [`Descriptor::resolve`] makes the same call for the same reason.
pub fn placed_footprint(d: &Descriptor) -> Option<(f32, f32)> {
    d.extent.footprint
}

/// **How tall a piece stands**, in metres — `extent.height × stretch_y`.
///
/// The vertical sibling of [`placed_footprint`], under the same contract: `extent.height` is already
/// the post-scale value, so only [`Align::stretch_y`] — game policy applied on top of the art, never
/// baked — multiplies it. This is the rule [`divisions`] has always used for the lattice.
///
/// It replaced `stack::drawn_height`, which multiplied by `scale` as well. For every shipped piece
/// but one that factor was 1.0 and harmless; for `site/books` it was a latent double-application —
/// never observable only because `books` offers no surfaces, so nothing ever rested on the answer.
///
/// `None` when the descriptor records no height — an **unmeasured** piece, not a flat one. Nothing may
/// rest on it, and saying so is better than treating unknown as zero and stacking a lamp at floor
/// level on top of a bookcase.
pub fn placed_height(d: &Descriptor) -> Option<f32> {
    let h = d.extent.height?;
    Some(h * d.align.stretch_y.unwrap_or(1.0))
}

/// The placed **(width, height, depth)** after a placement's tip — a pure axis permutation, which is
/// the whole reason [`crate::map::Placed::tip`] is quarter turns: any other angle has no
/// axis-aligned box to answer with.
///
/// Order matches the drawn rotation (about X first, then Z): an odd X tip lays height along depth,
/// an odd Z tip then lays what is upright along width. Even turns (180°) change which face is down
/// but not the box. `None` when the piece is unmeasured — same rule as [`placed_footprint`]:
/// unknown is not flat.
pub fn tipped_extents(d: &Descriptor, tip: (u8, u8)) -> Option<(f32, f32, f32)> {
    let (w, depth) = placed_footprint(d)?;
    let h = placed_height(d)?;
    let (w, h, depth) = if tip.0 % 2 == 1 {
        (w, depth, h)
    } else {
        (w, h, depth)
    };
    let (w, h, depth) = if tip.1 % 2 == 1 {
        (h, w, depth)
    } else {
        (w, h, depth)
    };
    Some((w, h, depth))
}

/// **The floor a piece covers once tipped** — [`placed_footprint`] for a placement that has been
/// turned onto its side.
///
/// The horizontal shadow of [`tipped_extents`], and the answer every lattice question wants: a
/// 0.1 × 1.0 m wall tipped a quarter turn lies down and covers 0.1 × 2.4 m of floor, so a fill that
/// stepped by the standing footprint would lay each row inside the last.
///
/// # Why an upright piece never consults its height
///
/// `tip == (0, 0)` short-circuits to [`placed_footprint`], and that is a statement about the
/// question rather than a fallback: a piece standing up covers its footprint whatever its height,
/// so a descriptor with a measured footprint and **no** height must still answer. Routing it
/// through [`tipped_extents`] would make it `None` — an unmeasured piece — and silently drop every
/// heightless floor tile onto the centre-snapping path. Once it is tipped the height *is* one of
/// the floor dimensions, so `None` there is the honest answer and not a degraded one.
pub fn tipped_footprint(d: &Descriptor, tip: (u8, u8)) -> Option<(f32, f32)> {
    if tip == (0, 0) {
        return placed_footprint(d);
    }
    tipped_extents(d, tip).map(|(w, _, depth)| (w, depth))
}

/// The divisions after `quarter` 90° turns about +Y — x and z swap on every odd turn.
///
/// Separate from [`Subgrid::rotated`] because the lattice no longer carries its own divisions: a
/// caller that turns a tile needs both halves, and having to ask for both is what keeps them in step.
pub fn rotate_div(div: (u32, u32, u32), quarter: u8) -> (u32, u32, u32) {
    if quarter.is_multiple_of(2) {
        div
    } else {
        (div.2, div.1, div.0)
    }
}

impl Subgrid {
    /// How many cells the lattice has, if it were written out in full.
    pub fn volume(div: (u32, u32, u32)) -> u32 {
        div.0.saturating_mul(div.1).saturating_mul(div.2)
    }

    /// What is at `at`, or `None` for a cell nobody has said anything about.
    ///
    /// The one accessor that does **not** need the divisions: it searches what was written rather
    /// than asking what could exist.
    pub fn at(&self, at: (u32, u32, u32)) -> Option<&SubCell> {
        self.cells.iter().find(|c| c.at == at)
    }

    /// Is `at` inside a lattice of `div`?
    pub fn holds(at: (u32, u32, u32), div: (u32, u32, u32)) -> bool {
        at.0 < div.0 && at.1 < div.1 && at.2 < div.2
    }

    /// The cell at `at`, created empty if nobody has written one yet.
    ///
    /// `None` when `at` is outside the lattice — a caller asking about a cell that cannot exist gets
    /// an answer, not a row appended somewhere unreachable.
    fn entry(&mut self, at: (u32, u32, u32), div: (u32, u32, u32)) -> Option<&mut SubCell> {
        if !Subgrid::holds(at, div) {
            return None;
        }
        if let Some(i) = self.cells.iter().position(|c| c.at == at) {
            return self.cells.get_mut(i);
        }
        self.cells.push(SubCell {
            at,
            ..SubCell::default()
        });
        self.cells.last_mut()
    }

    /// **Drop any cell that has gone back to saying nothing.**
    ///
    /// The sparse invariant is not decoration: an author who marks a cell solid and then unmarks it
    /// must leave the file as it was, or every tile ever poked at accretes rows of `solid: false`
    /// that mean exactly what absence means.
    fn prune(&mut self) {
        self.cells.retain(|c| c.solid || c.edge.is_some());
    }

    /// Toggle a cell's occupancy. Returns what it became, or `None` if `at` is outside.
    pub fn toggle_solid(&mut self, at: (u32, u32, u32), div: (u32, u32, u32)) -> Option<bool> {
        let now = {
            let cell = self.entry(at, div)?;
            cell.solid = !cell.solid;
            cell.solid
        };
        self.prune();
        Some(now)
    }

    /// Mark a cell solid, whatever it was. Returns `None` if `at` is outside.
    ///
    /// Distinct from [`Self::toggle_solid`] because a mesh scan states a fact rather than flipping
    /// one: running it twice must leave the same lattice, which a toggle would not.
    pub fn set_solid(&mut self, at: (u32, u32, u32), div: (u32, u32, u32)) -> Option<()> {
        self.entry(at, div)?.solid = true;
        self.prune();
        Some(())
    }

    /// Set or clear a cell's edge label. An empty string clears it — the same keystroke that types a
    /// token has to be able to take it back.
    pub fn set_edge(
        &mut self,
        at: (u32, u32, u32),
        div: (u32, u32, u32),
        token: &str,
    ) -> Option<()> {
        let cell = self.entry(at, div)?;
        cell.edge = (!token.trim().is_empty()).then(|| token.trim().to_owned());
        self.prune();
        Some(())
    }

    /// Forget everything about a cell.
    pub fn clear(&mut self, at: (u32, u32, u32)) {
        self.cells.retain(|c| c.at != at);
    }

    /// **The lattice as it sits after `quarter` 90° turns about +Y.**
    ///
    /// A placement carries a yaw ([`crate::map::Placed::yaw`]) and the lattice does not know about
    /// it, so anything comparing two placed tiles face to face has to turn one of them first.
    /// Reading a face straight off the authored lattice would be silently wrong for every rotated
    /// piece — which is exactly the piece a face-matching rule exists to check.
    ///
    /// The convention is the project's one forward rule: **a positive yaw turns +X toward −Z**
    /// (`stack::covers`). So local +X becomes −Z, and a cell on the +X face lands on the −Z face:
    ///
    /// ```text
    /// (x, y, z) -> (z, y, dx - 1 - x)      div (dx, dy, dz) -> (dz, dy, dx)
    /// ```
    ///
    /// Four turns are the identity, which `rotating_four_times_is_the_identity` pins.
    ///
    /// `div` is the lattice's divisions **before** the turn; the turned divisions are
    /// [`rotate_div`]'s job. Two calls rather than one because the lattice no longer carries its
    /// divisions, and a caller forced to name both cannot let them drift apart.
    ///
    /// Lives here rather than beside the matcher because the schema owns its own transforms for the
    /// same reason it owns its own edits: the sparse invariant is this type's to keep.
    pub fn rotated(&self, quarter: u8, div: (u32, u32, u32)) -> Subgrid {
        let mut out = self.clone();
        let mut at_div = div;
        for _ in 0..(quarter % 4) {
            let (dx, dy, dz) = at_div;
            out = Subgrid {
                cells: out
                    .cells
                    .iter()
                    .map(|c| SubCell {
                        at: (
                            c.at.2,
                            c.at.1,
                            dx.saturating_sub(1) - c.at.0.min(dx.saturating_sub(1)),
                        ),
                        ..c.clone()
                    })
                    .collect(),
            };
            at_div = (dz, dy, dx);
        }
        out
    }

    /// Refuse a lattice that cannot be true.
    ///
    /// Every rule here is one whose violation is silent: a zero division makes the lattice empty
    /// while still claiming to have one, an out-of-range cell is a value nothing will ever read, and
    /// a duplicate is two answers to one question with the first quietly winning.
    pub fn validate(&self, owner: &str, div: (u32, u32, u32)) -> Result<(), String> {
        let (dx, dy, dz) = div;
        if dx == 0 || dy == 0 || dz == 0 {
            return Err(format!(
                "`{owner}`'s subgrid divides {dx}x{dy}x{dz}; an axis with no divisions has no cells"
            ));
        }
        let mut seen: Vec<(u32, u32, u32)> = Vec::with_capacity(self.cells.len());
        for c in &self.cells {
            if c.at.0 >= dx || c.at.1 >= dy || c.at.2 >= dz {
                return Err(format!(
                    "`{owner}`'s subgrid cell {:?} is outside its {dx}x{dy}x{dz} lattice",
                    c.at
                ));
            }
            if seen.contains(&c.at) {
                return Err(format!(
                    "`{owner}`'s subgrid names cell {:?} twice — one cell, one answer",
                    c.at
                ));
            }
            seen.push(c.at);
        }
        Ok(())
    }
}

/// One cell of a [`Subgrid`]. See that type for what the three facets are for.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct SubCell {
    /// Which cell, `(x, y, z)`, zero-based.
    pub at: (u32, u32, u32),
    /// Solid space, as rasterised from the mesh by [`crate::import::occupancy`].
    ///
    /// **Nothing reads this to decide anything, and that is a ruling rather than an omission.** It was
    /// meant to refine `stack::covers` so clearance would respect a piece's shape instead of its
    /// bounding box — `FVS-Q-9`, closed *no* on 2026-08-05 after being built and measured. At the
    /// shipped `divisions: 1` a lattice-aware `covers` agrees with the bounding box 96% of the time,
    /// because the props are mostly smaller than a few cells; resolution fine enough to hold a shape
    /// needs `divisions: 3`, which makes a wall 810 cells. And `divisions` cannot be raised for this
    /// alone, because it is one project-wide number precisely so that two faces are comparable — see
    /// [`Subgrid`]. Coarse-for-matching and fine-for-clearance cannot be the same number.
    ///
    /// What it *is* for: the author's confirmation that the lattice lines up with the mesh. The editor
    /// marks it with `rescan mesh` and draws it, which is how you see that a wall's lattice really does
    /// fill the wall. `Descriptor::clearance` is the field that decides anything about space.
    pub solid: bool,
    /// What this cell presents to whatever is placed beside it. Matched face-to-face.
    ///
    /// **The only facet that decides anything**, now that `anchor` is gone. A cell said which role an
    /// interacting item could occupy here, and it was authored, validated against a vocabulary axis,
    /// drawn in the editor, saved to disk — and **read by nothing**, across 289 `anchor: None` entries
    /// and an empty axis in every shipped kit. `composition::Body::Slot` is the thing it was reaching
    /// for and could not be: a hole belongs to a *tile*, at a position on the tile's grid, so it can
    /// sit in open air and differ between two tiles that share a mesh. An anchor belonged to the mesh,
    /// so every wall in the project carried the same one.
    pub edge: Option<String>,
}

/// Where a piece belongs, for whatever is placing it. **Not a semantic axis.**
///
/// `kind`/`effects`/`look` describe the thing itself; this describes where a *generator* should
/// consider putting it. The distinction matters because both are lists of opaque strings and it would
/// be very easy to have one list — which is precisely the free-text soup the audit found, where
/// `affordances`, `tags` and `group` were three unvalidated vocabularies nobody could tell apart.
///
/// These two survive the migration on their merits: `furnish::room_profile` matches `rooms` against
/// room types to choose a room's freestanding set, and items sharing a `group` are drawn together by
/// a soft `Near` relation. Unlike `category` and the four unread affordance tokens, something reads
/// them.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Placement {
    /// Room-type tokens this piece suits — *a toilet suits a bathroom*. Cross-validated against the
    /// project's room types, the way `mycelia::validate_damp_coverage` already checks its own table
    /// against `config.ron:dungeon.room_types`.
    pub rooms: Vec<String>,
    /// Pieces sharing a token are drawn together (a toilet and a basin), as a soft relation rather
    /// than a hard constraint — a room that cannot fit both should still get one.
    pub group: Option<String>,
}

/// Every layer a piece can be put on, in the order an editor should cycle them.
///
/// The list is built from the project's surface vocabulary rather than hardcoded, because
/// `OnSurface` is the one variant whose payload is a *token* — a project with a `worktop` and a
/// `shelf` should offer both, and a project with neither should offer no surface mount at all rather
/// than one that can never match.
///
/// Order is deliberate: the common cases first. Most pieces stand on the floor, and an author
/// cycling to find `OnFloor` past four exotic variants is an author who will bind their own key.
pub fn mount_options(surfaces: &[String]) -> Vec<Mount> {
    let mut out = vec![
        Mount::OnFloor,
        Mount::OnSurface {
            class: String::new(),
        },
        // The standing sibling, expanded from the same vocabulary just below and for the same reason:
        // a project offering no classes should offer no face mount rather than one nothing can carry.
        Mount::OnFace {
            class: String::new(),
            height: 1.8,
        },
        Mount::OnWall { height: 1.8 },
        Mount::OnCeiling,
        Mount::Tiled,
        Mount::Decal {
            on: DecalHost::Floor,
        },
        // The same default `OnWall` offers, because it is the same question: eye level for a sign.
        Mount::Decal {
            on: DecalHost::Wall { height: 1.8 },
        },
        Mount::Decal {
            on: DecalHost::Ceiling,
        },
        Mount::InOpening { clear: None },
    ];
    // Replace the placeholder with one entry per real class, so every offered mount can actually be
    // satisfied by something in this project.
    let at = out
        .iter()
        .position(|m| matches!(m, Mount::OnSurface { class } if class.is_empty()));
    if let Some(at) = at {
        out.remove(at);
        for (i, class) in surfaces.iter().enumerate() {
            out.insert(
                at + i,
                Mount::OnSurface {
                    class: class.clone(),
                },
            );
        }
        // **The two-mount entries, one per class, right after the classes they lift from** (E-060).
        //
        // They were the first class alone, on the argument that an `or the floor` entry per class
        // would double the list. That is true and it was the wrong trade: this list is a cycle an
        // author taps through with `M`, and **an entry that does not exist cannot be reached at any
        // price**. `assets/grimoire/vocab.ron` declares three classes, so a dressing set that belongs
        // on a *shelf* or the floor had no rung, and the labeller's prompt offered the shape for one
        // class while refusing the other two — the only way through was hand-editing the file, which
        // is exactly what the cycle exists to make unnecessary. With the shipped three classes the
        // list goes from 15 rungs to 17, which is a tap or two.
        //
        // **After the plain entries rather than beside each one**, so the cycle reads as two passes
        // over the vocabulary — every class, then the same classes with `or floor` — instead of an
        // interleaving an author has to decode.
        let at = at + surfaces.len();
        for (i, class) in surfaces.iter().enumerate() {
            out.insert(
                at + i,
                Mount::OnSurfaceOrFloor {
                    class: class.clone(),
                },
            );
        }
    }
    // And the same for the face placeholder. Done second so the surface expansion above has already
    // moved it, rather than both editing positions computed against the original list.
    let at = out
        .iter()
        .position(|m| matches!(m, Mount::OnFace { class, .. } if class.is_empty()));
    if let Some(at) = at {
        out.remove(at);
        for (i, class) in surfaces.iter().enumerate() {
            out.insert(
                at + i,
                Mount::OnFace {
                    class: class.clone(),
                    height: 1.8,
                },
            );
        }
    }
    out
}

/// **The height a mount carries**, metres, or `None` for one where the question does not arise.
///
/// Two variants carry a height and they carry the same one for the same reason — a wall's height is
/// nobody else's to state, unlike the floor and the ceiling, which the map states. [`DecalHost`]'s
/// own doc says so.
///
/// This exists because the editor could put a piece **on** a wall and then not say how far up: the
/// mount cycles through [`mount_options`], every entry of which is a literal, so `1.8` was the only
/// wall height reachable without hand-editing the RON.
pub fn mount_height(m: &Mount) -> Option<f32> {
    match m {
        Mount::OnWall { height } => Some(*height),
        Mount::Decal {
            on: DecalHost::Wall { height },
        } => Some(*height),
        _ => None,
    }
}

/// The same mount at a different height — or `None` when it has no height to set.
///
/// Returns a new mount rather than mutating one, so a caller that asks the wrong question gets an
/// answer it has to handle instead of a silent no-op on a piece it thought it had changed.
pub fn with_mount_height(m: &Mount, height: f32) -> Option<Mount> {
    match m {
        Mount::OnWall { .. } => Some(Mount::OnWall { height }),
        Mount::Decal {
            on: DecalHost::Wall { .. },
        } => Some(Mount::Decal {
            on: DecalHost::Wall { height },
        }),
        _ => None,
    }
}

/// A short label for a mount, for a panel that has one line to say it in.
pub fn mount_label(mount: Option<&Mount>) -> String {
    match mount {
        None => "unset".to_owned(),
        Some(Mount::OnFloor) => "on floor".to_owned(),
        Some(Mount::OnSurface { class }) => format!("on {class}"),
        Some(Mount::OnSurfaceOrFloor { class }) => format!("on {class} or floor"),
        Some(Mount::OnFace { class, height }) => format!("on {class} face at {height:.1} m"),
        Some(Mount::OnWall { height }) => format!("on wall at {height:.1} m"),
        Some(Mount::OnCeiling) => "on ceiling".to_owned(),
        Some(Mount::Tiled) => "tiled".to_owned(),
        Some(Mount::Decal { on }) => match on {
            DecalHost::Floor => "decal on floor".to_owned(),
            DecalHost::Ceiling => "decal on ceiling".to_owned(),
            DecalHost::Wall { height } => format!("decal on wall at {height:.1} m"),
        },
        Some(Mount::InOpening { clear }) => match clear {
            Some((w, h)) => format!("in opening {w:.2} x {h:.2} m"),
            None => "in opening".to_owned(),
        },
    }
}

/// **One of a tile's four horizontal faces**, in the vocabulary the lattice already uses.
///
/// `crate::adjacency::face` reads a face; [`Align::front`] names one; and cell picking will report
/// the one a ray entered. Three things about the same four directions, so they are one type rather
/// than three spellings of a quarter turn.
///
/// The world meaning is `crate::wfc`'s, which is `grammar::learn`'s step table: **North is −Z, East
/// is +X, South is +Z, West is −X.**
///
/// # Why a face and not degrees
///
/// `front` was a yaw in degrees, and degrees can express things a tile cannot have. A front at 37°
/// points at a corner: there is no column of cells there, so nothing can read it, and
/// `adjacency::quarter_turns` already refuses off-square yaws for exactly that reason. Naming a face
/// makes the quantisation part of the type instead of a rule some later caller forgets.
///
/// It is also lossless for the shipped data — every `front` in the repo is `90.0`, which is [`Self::East`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Face {
    North,
    East,
    South,
    West,
}

impl Face {
    /// The yaw, in degrees, whose forward vector points out of this face.
    ///
    /// The engine convention is `forward = (sin yaw, cos yaw)`, so yaw 0 is +Z — [`Self::South`] —
    /// and the quarter turns follow from there. This is what every reader that composes a facing
    /// with a placement's yaw wants.
    pub fn yaw_degrees(self) -> f32 {
        match self {
            Face::South => 0.0,
            Face::East => 90.0,
            Face::North => 180.0,
            Face::West => 270.0,
        }
    }

    /// The face a yaw points out of, to the nearest quarter turn.
    ///
    /// **Snapping is the point, not a loss.** `glb::front_detail` measures a continuous angle out of
    /// centroid asymmetry, and that angle is evidence about which face is the front rather than a
    /// facing in its own right — a chair modelled 3° off square still fronts +X. The importer shows
    /// the raw measurement beside this so an author can overrule a borderline call.
    pub fn from_yaw(deg: f32) -> Face {
        if !deg.is_finite() {
            return Face::South;
        }
        match (deg / 90.0).round().rem_euclid(4.0) as u8 {
            1 => Face::East,
            2 => Face::North,
            3 => Face::West,
            _ => Face::South,
        }
    }

    /// This face as [`crate::wfc`]'s edge index, so a front and a lattice face are the same four
    /// numbers to everything downstream.
    pub fn dir(self) -> crate::placement::ir::Dir {
        match self {
            Face::North => crate::wfc::N,
            Face::East => crate::wfc::E,
            Face::South => crate::wfc::S,
            Face::West => crate::wfc::W,
        }
    }

    /// The face opposite this one.
    pub fn opposite(self) -> Face {
        match self {
            Face::North => Face::South,
            Face::East => Face::West,
            Face::South => Face::North,
            Face::West => Face::East,
        }
    }

    /// How an author reads it, matching `adjacency`'s fault messages.
    pub fn label(self) -> &'static str {
        match self {
            Face::North => "N",
            Face::East => "E",
            Face::South => "S",
            Face::West => "W",
        }
    }
}

/// **[`Align::rotate`] as quarter turns**, or a refusal naming the piece and the angle.
///
/// The same rule `adjacency::quarter_turns` holds for a placement's yaw, for the same reason: a tile
/// is square to the world, so a rotation that is not a quarter turn leaves it with no face any rule
/// can read. Refused rather than rounded — rounding would silently store an orientation the author
/// did not ask for, and the mesh would render at one angle while every measurement described another.
pub fn quarter_turns_xyz(rotate: (i32, i32, i32), owner: &str) -> Result<(u8, u8, u8), String> {
    let axis = |deg: i32, name: &str| -> Result<u8, String> {
        if deg % 90 != 0 {
            return Err(format!(
                "`{owner}`'s rotation is {deg} degrees about {name}; a tile only sits square to the \
                 world, so a rotation must be a multiple of 90"
            ));
        }
        Ok((deg.rem_euclid(360) / 90) as u8)
    };
    Ok((
        axis(rotate.0, "X")?,
        axis(rotate.1, "Y")?,
        axis(rotate.2, "Z")?,
    ))
}

/// Corrections for what the artist got wrong. Every one is measured, never dialled by eye — the kit's
/// own doc says so of `scale`, and the importer exists to make that true of the rest.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Align {
    /// Uniform art correction: real-world size ÷ authored size.
    pub scale: Option<f32>,
    /// Stretch on Y alone to reach a target height, in metres.
    ///
    /// **Game policy, not an art fact** — a 2.0 m wall mesh made to reach a 2.4 m wall height is a
    /// statement about one game's architecture. It lives here for now because that is where the Site
    /// kit kept it (as a derived `y_scale`), but under the patch model it is more honestly a
    /// project-level layer over the descriptor base. Open question 3 in the plan.
    pub stretch_y: Option<f32>,
    /// Metres to lift off the ground plane. A geometric fix — the Ozea floor plate is 0.06 m thick and
    /// so are its inlays, so a decal at y = 0 is exactly coplanar and the depth winner is undefined.
    /// Not a depth bias.
    pub y_offset: Option<f32>,
    /// Local XZ offset of the mesh's bbox centre from its origin. Placement reasons about a footprint
    /// symmetric about the origin, so an off-centre mesh seated against a wall pokes through it.
    pub pivot: Option<(f32, f32)>,
    /// **A default rotation for a mesh authored the wrong way up**, in degrees per axis, applied X
    /// then Y then Z.
    ///
    /// Every value is a multiple of 90 — [`quarter_turns_xyz`] refuses anything else. A tile's
    /// lattice, its faces and its footprint are all square to the world; a mesh dropped in at 37°
    /// has no honest extent, so the schema cannot express one.
    ///
    /// # The extent stored beside this is already rotated
    ///
    /// **This is a render instruction, not a measurement correction.** The importer measures the
    /// mesh, applies this rotation to the bounds ([`crate::glb::Measured::rotated`]), and writes the
    /// *rotated* `extent`, `pivot` and `y_offset`. So every reader of `extent` — `stack`, `fill`,
    /// `thumbs`, [`divisions`] — sees the piece as it will stand in the world, and none of them
    /// needs to know this field exists.
    ///
    /// The cost of that choice is an invariant a file can break: editing `rotate` by hand leaves
    /// `extent` describing the old orientation, and nothing downstream can tell. Change it through
    /// the editor, which re-measures.
    pub rotate: Option<(i32, i32, i32)>,
    /// **Which of the mesh's own faces is its front**, in its local space.
    ///
    /// Composed with a placement's yaw by whoever needs a world facing:
    /// `placement.yaw + front.yaw_degrees()`. See [`Face`] for why this is a face rather than the
    /// arbitrary angle it used to be.
    ///
    /// `None` means *the mesh is symmetric and has no front*, which is a different claim from
    /// `Some(Face::South)`. The kit records that distinction deliberately: a stool measures symmetric
    /// to within a centimetre, and "asserting a facing on a stool would be asserting a fact about the
    /// art that is not true."
    pub front: Option<Face>,
}

/// How much room it takes. Metres.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Extent {
    /// (width, depth) before yaw.
    pub footprint: Option<(f32, f32)>,
    /// The piece's top **above its base**: `ymax − ymin` of its mesh, whatever [`Align::y_offset`] is.
    /// For anything offering a surface this is the plane props rest on: a guest placed `on` the piece
    /// rests this far above the piece's base ([`crate::stack::surface_lift`] takes away the offset
    /// `datum` added). Declare `ymax − ymin`, not `ymax`; [`Library::height_disagreements`] checks it
    /// against the glb.
    pub height: Option<f32>,
}

/// Where a piece attaches — the layering axis, replacing `Role` + `rests_on` + the floor-marking
/// height heuristic.
///
/// # Each variant names *where a piece sits* and decides *what it contests*
///
/// Those are two different things, and only the first is in the name. The second is what makes an
/// edit refused, and it lives in [`crate::stack::same_layer`]'s match arms — invisible at the call
/// site, so an author picking a mount is choosing collision semantics while reading a positional
/// word. Three variants put a piece at deck level and mean three different things about collision.
/// The table is the schema; it is restated here so choosing a mount does not require reading
/// `stack.rs`.
///
/// | variant | height | contests |
/// |---|---|---|
/// | `OnFloor` (and an absent mount) | the deck | every other floor-standing piece |
/// | `Tiled` | the deck | only other `Tiled` — which is what lets floor go under a dressed room |
/// | `Decal { .. }` | its host's plane | **nothing.** Two decals may share a wall, which is the point of them |
/// | `OnCeiling` | the ceiling | only other `OnCeiling` |
/// | `InOpening { .. }` | the opening | only other `InOpening` — so a door and its wall cannot conflict |
/// | `OnWall { height }` | that height | only `OnWall` **at the same height**, within 1 mm |
/// | `OnSurface { class }` | its host's top | only `OnSurface` **on the same host** — two lamps contest one table, not two tables |
/// | `OnSurfaceOrFloor { class }` | whichever it took | whichever it took: with a host it contests that host's top, without one it stands in the floor layer |
///
/// **A heterogeneous pair contests nothing at all** (`same_layer`'s `_ => false`), which is why the
/// bounded claim on [`crate::stack::blocking`] matters: the rule is per-layer and in plan, so a tall
/// floor-standing piece and a wall-mounted one behind it never meet.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Mount {
    /// Stands on the floor. The common case.
    OnFloor,
    /// Fixed to a wall face at this height in metres (a sconce at 1.8).
    OnWall { height: f32 },
    /// Hangs from the ceiling.
    OnCeiling,
    /// Fills a doorway. `clear` is the (width, height) of the hole in metres — not derivable from the
    /// mesh's own height, since jambs and a lintel sit inside its bounding box.
    ///
    /// `None` means **nobody has measured it**, which is the honest state of the shipped
    /// `furniture_kenney.ron` door: it carries `role: Anchor(host: Opening)` and no opening size,
    /// because the manifest schema has no field for one. The Site kit does record it
    /// (`DoorPiece::opening`, measured off the POSITION accessors), so a converted kit piece carries
    /// `Some`. Making this required would have forced the converter to invent a number for a row that
    /// has never had one.
    InOpening { clear: Option<(f32, f32)> },
    /// Rests on another piece that offers this surface class.
    OnSurface { class: String },
    /// **Either rests on a piece offering this class, or stands on the floor** — and which one is
    /// decided per placement, by what the author dropped it on.
    ///
    /// The case a single `OnSurface` cannot state, and the one a dressing set needs: a smith's tools,
    /// a horseshoe pile or a bundle of rods belongs *on a bench* in one room and *on the floor* in the
    /// next, and a library entry describes the piece, not the room. Before this, the only way to say
    /// so in data was a second descriptor with the same mesh and the other mount, which doubles the
    /// library entries and splits one piece's labels across two ids.
    ///
    /// # The placement records which one it took
    ///
    /// [`crate::map::Placed::on`] **is** the record: `Some(host)` says it took the surface and names
    /// the host; `None` says it took the floor. That is why a reload reproduces the seating without a
    /// second field — the height is already derived from `on`, and nothing here is consulted at
    /// resolve time. A variant with no per-placement state would put the same piece on the floor after
    /// a reload, which is the failure a derived height exists to prevent.
    ///
    /// # What it does not combine with
    ///
    /// A floor and a named surface class only. `OnWall`, `OnCeiling`, `InOpening` and `Decal` are
    /// *planes*, and "on the wall or on the floor" is a sentence about two different fixtures rather
    /// than about one piece; refusing the combination keeps the variant from becoming a general
    /// union type nothing can reason about.
    OnSurfaceOrFloor { class: String },
    /// **Fixed to a named face of a named host**, `height` metres up it.
    ///
    /// The standing sibling of [`Self::OnSurface`], and the one that makes a fixture belong to a
    /// wall rather than to a number. It resolves exactly as `OnSurface` does — the host named by
    /// [`crate::map::Placed::on`] is resolved first, and its Y is the base this height is measured
    /// from — so raising the host raises the fixture, and deleting the host is an error naming both
    /// rather than a sconce left hanging in air.
    ///
    /// # Why not just [`Self::OnWall`]
    ///
    /// `OnWall` carries a height and *no host*. It answers `map.origin.1 + height`, a constant on the
    /// descriptor shared by every wall light in the project — so it cannot say *which* wall, cannot
    /// notice when that wall goes, and puts a fixture at the same height in a 2.4 m room and a 4 m
    /// one. It is kept for the pieces that genuinely mean "at this height in this map", and this is
    /// for the ones that mean "on that".
    ///
    /// `height` is measured up the face from the host's own base rather than from the map's floor,
    /// because that is what makes it survive the host moving.
    OnFace { class: String, height: f32 },
    /// **Lies flat on a host surface** — a decal, a floor marking, a wall poster, a ceiling stain.
    ///
    /// The case the old schemas could not express. `Decal` claims no volume and never participates
    /// in the overlap rule: two decals may share a wall, which is the whole point of them.
    ///
    /// The host carries the height where a height is a thing that exists — see [`DecalHost`].
    Decal { on: DecalHost },
    /// Laid on a grid by a tiling solver.
    Tiled,
}

/// The plane a decal lies on.
///
/// Its own enum rather than [`crate::placement::ir::Host`] for two reasons, and both are about not
/// being able to say something meaningless. `Opening` is not a plane you can stick a poster to. And a
/// **wall** needs a height while a floor and a ceiling do not: the map states where those are, and
/// nothing anywhere states how high a poster hangs.
///
/// That last one shipped as a hole. `Mount::OnWall` has carried a height since it was written, and
/// `Decal` — the variant that exists *for* wall decals — had nowhere to put one, so a wall decal
/// could be authored and then had no answer for where it went. Encoding it in the variant means the
/// unanswerable state is not constructible rather than merely rejected.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DecalHost {
    /// A floor marking — a hazard stripe, a painted lane, a stain. Lies on the map's floor.
    Floor,
    /// A ceiling stain or a painted marking. Lies on the map's ceiling.
    Ceiling,
    /// A poster, a sign, a scorch mark, at this height in metres — the same field `OnWall` carries,
    /// for the same reason.
    Wall { height: f32 },
}

/// Which way a clearance requirement points, relative to the piece's own front.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Dir {
    Front,
    Back,
    Left,
    Right,
    /// All four sides — a dining table wants room to pull chairs out on every edge.
    Around,
}

/// Space that must stay free, in metres.
///
/// Tutenel et al. (2010) distinguish **off-limits** features, which may overlap nothing, from
/// **clearance** features, which may overlap only other clearance features — so two chairs can share
/// the space they each need to be pulled out into, but neither may share it with a wall.
///
/// Numbers come from Merrell et al. 2011, already implemented here as `solvers::metropolis`: 0.91 m
/// beside a bed, 0.76 m in front of a seat, 0.61 m in front of shelving, 0.91 m around a dining table.
///
/// Without this the schema cannot forbid a chair flush against a wall with its seat socket inside the
/// wall — the same class of bug as the mug misclassification, one level up.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Clearance {
    pub dir: Dir,
    pub dist: f32,
}

/// What a piece makes available to others.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Offers {
    /// Surface classes this piece's top provides, from the closed vocabulary in
    /// [`crate::placement::surfaces`].
    pub surfaces: Vec<String>,
    /// **Vertical face classes this piece presents** — `"wall-inner"`, `"wall-outer"`.
    ///
    /// The standing sibling of [`Self::surfaces`], validated against the *same* axis, because a class
    /// is a class: what differs is not the token but how the height is read off it. A top answers
    /// [`Mount::OnSurface`] with the host's own height; a face answers [`Mount::OnFace`] with a
    /// distance measured **up** it. Two lists rather than one flag, so a piece offering a top and a
    /// face says both and neither reading can be taken for the other.
    ///
    /// # The gap this closes
    ///
    /// [`Mount::OnWall`] carries a bare height and no host, so a sconce's Y was
    /// `map.origin.1 + 1.8` — a constant on the descriptor, shared by every wall light in the
    /// project and answering to no wall in particular. `docs/2026-08-09-unified-composition.md` §1
    /// states the consequence: *"a sconce is not attached to its wall… Delete the wall and the sconce
    /// hangs there, and nothing reports it."* A face gives the relationship somewhere to live, so the
    /// host resolves first and its absence is an error naming both.
    #[serde(default)]
    pub faces: Vec<String>,
    /// Named attachment points — where a thing goes, or where an agent stands.
    pub sockets: Vec<Socket>,
}

/// A named point on a piece, in its own local space.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Socket {
    pub id: String,
    /// Which interaction role may occupy it — `"diner"`, `"merchant"`.
    ///
    /// Free now, expensive later. A table plus four chairs is *one* affordance with four seats, not
    /// four affordances (FFXV's smart locations; Smart Zones' main/supporting/extra strata), and role
    /// allocation needs somewhere to attach. Adding it after interactions ship is a schema migration.
    pub role: Option<String>,
    /// Local position (x, y, z), metres.
    pub at: (f32, f32, f32),
    /// Local yaw, degrees — which way an occupant faces.
    pub yaw: f32,
}

/// A descriptor with every inherited field filled in and the required ones proven present.
///
/// The runtime and the solvers consume this; [`Descriptor`] is only ever the authored form.
#[derive(Clone, Debug, PartialEq)]
pub struct Resolved {
    pub id: String,
    pub mesh: String,
    pub scale: f32,
    pub stretch_y: Option<f32>,
    pub y_offset: f32,
    pub pivot: (f32, f32),
    pub front: Option<Face>,
    pub footprint: (f32, f32),
    pub height: f32,
    pub mount: Mount,
    pub clearance: Vec<Clearance>,
    pub offers: Offers,
    pub kind: Vec<String>,
    pub effects: Vec<String>,
    pub look: Vec<String>,
}

impl Descriptor {
    /// Layer `patch` over `self`, field by field. A field the patch leaves empty is inherited.
    ///
    /// List fields **replace** rather than concatenate. Appending would make a patch unable to
    /// *remove* a tag, and "add only" is the kind of one-way door that turns into a second mechanism
    /// for removal later.
    pub fn patched_with(&self, patch: &Descriptor) -> Descriptor {
        Descriptor {
            id: if patch.id.is_empty() {
                self.id.clone()
            } else {
                patch.id.clone()
            },
            mesh: patch.mesh.clone().or_else(|| self.mesh.clone()),
            align: Align {
                scale: patch.align.scale.or(self.align.scale),
                stretch_y: patch.align.stretch_y.or(self.align.stretch_y),
                y_offset: patch.align.y_offset.or(self.align.y_offset),
                pivot: patch.align.pivot.or(self.align.pivot),
                rotate: patch.align.rotate.or(self.align.rotate),
                front: patch.align.front.or(self.align.front),
            },
            extent: Extent {
                footprint: patch.extent.footprint.or(self.extent.footprint),
                height: patch.extent.height.or(self.extent.height),
            },
            mount: patch.mount.clone().or_else(|| self.mount.clone()),
            clearance: pick(&self.clearance, &patch.clearance),
            offers: Offers {
                surfaces: pick(&self.offers.surfaces, &patch.offers.surfaces),
                faces: Vec::new(),
                sockets: pick(&self.offers.sockets, &patch.offers.sockets),
            },
            kind: pick(&self.kind, &patch.kind),
            effects: pick(&self.effects, &patch.effects),
            look: pick(&self.look, &patch.look),
            // A patch that states a lattice replaces it, on the same rule the lists follow: an
            // append could not remove a cell. `None` is the patch saying nothing — which is a
            // different claim from `Some(Subgrid::default())`, the patch clearing every cell.
            subgrid: patch.subgrid.clone().or_else(|| self.subgrid.clone()),
            placement: Placement {
                rooms: pick(&self.placement.rooms, &patch.placement.rooms),
                group: patch
                    .placement
                    .group
                    .clone()
                    .or_else(|| self.placement.group.clone()),
            },
            // A patch that says nothing about the note inherits it. Replacing a note with silence
            // needs `note: Some("")` — deliberate, because a note is somebody's reasoning and losing
            // it should take an act.
            note: patch.note.clone().or_else(|| self.note.clone()),
            // **The patch's record wins whole, or the base's is inherited whole.**
            //
            // Not merged per axis, and that is the point rather than a shortcut: a policy patch that
            // rewrites a piece's `kind` is a *different* labeller from whoever wrote the base, and
            // a merge would leave the base's `by.kind` standing over a value the patch replaced —
            // a record that names the wrong author, which is worse than no record at all.
            labels: patch.labels.clone().or_else(|| self.labels.clone()),
        }
    }

    /// **The lattice to write into**, created empty if this is the first thing said about it.
    ///
    /// Pair every batch of edits with [`Self::settle_lattice`], or a piece whose last cell was
    /// cleared keeps an empty lattice where it used to have nothing.
    pub fn lattice_mut(&mut self) -> &mut Subgrid {
        self.subgrid.get_or_insert_with(Subgrid::default)
    }

    /// **Drop a lattice that has gone back to saying nothing.**
    ///
    /// `Subgrid::prune` keeps the *cells* sparse; this keeps the *field* sparse, and it is the same
    /// argument one level up: a piece an author poked at and undid must leave the file as it was.
    ///
    /// `None` and `Some(Subgrid::default())` are only interchangeable **here**, in a base
    /// descriptor, where both can mean nothing but "no cells". In a patch they are two different
    /// claims — silence versus "clear them" — which is why [`Self::patched_with`] distinguishes
    /// them and this does not.
    pub fn settle_lattice(&mut self) {
        if self.subgrid.as_ref().is_some_and(|g| g.cells.is_empty()) {
            self.subgrid = None;
        }
    }

    /// Fill in defaults and prove the required fields are present.
    ///
    /// One path, no fallback: a descriptor with no footprint is refused by id rather than resolved to
    /// `(0.0, 0.0)`. A zero reservation overlaps nothing and would let the piece sit inside a wall
    /// with every rule reporting success.
    pub fn resolve(&self) -> Result<Resolved, String> {
        if self.id.is_empty() {
            return Err("descriptor: an entry has no `id`".to_owned());
        }
        let need = |what: &str| format!("descriptor `{}`: missing `{what}`", self.id);

        let footprint = self
            .extent
            .footprint
            .ok_or_else(|| need("extent.footprint"))?;
        let mount = self.mount.clone().ok_or_else(|| need("mount"))?;

        // A decal has no meaningful height and never rests anything on itself, so it is the one shape
        // that may omit it. Everything else must state it: a surface's height IS the plane props sit
        // on, and defaulting that to zero would sink them into the floor.
        let height = match (self.extent.height, &mount) {
            (Some(h), _) => h,
            (None, Mount::Decal { .. }) => 0.0,
            (None, _) => return Err(need("extent.height")),
        };

        for c in &self.clearance {
            if !c.dist.is_finite() || c.dist < 0.0 {
                return Err(format!(
                    "descriptor `{}`: clearance {:?} is {} m — must be finite and non-negative",
                    self.id, c.dir, c.dist
                ));
            }
        }

        let scale = self.align.scale.unwrap_or(1.0);
        if !scale.is_finite() || scale <= 0.0 {
            return Err(format!(
                "descriptor `{}`: align.scale is {scale} — must be finite and positive",
                self.id
            ));
        }

        Ok(Resolved {
            id: self.id.clone(),
            mesh: self.mesh.clone().ok_or_else(|| need("mesh"))?,
            scale,
            stretch_y: self.align.stretch_y,
            y_offset: self.align.y_offset.unwrap_or(0.0),
            pivot: self.align.pivot.unwrap_or((0.0, 0.0)),
            front: self.align.front,
            footprint,
            height,
            mount,
            clearance: self.clearance.clone(),
            offers: self.offers.clone(),
            kind: self.kind.clone(),
            effects: self.effects.clone(),
            look: self.look.clone(),
        })
    }
}

impl Resolved {
    /// Does this piece claim floor space that another may not share?
    ///
    /// Replaces `site::layout::occupies_floor` and its height threshold. An `Decal` claims nothing
    /// (that is what a decal is), and something resting on a surface is the host's problem, not the
    /// floor's — two mugs on one table still collide with each other, which the overlap rule handles
    /// on the surface rather than here.
    pub fn occupies_floor(&self) -> bool {
        matches!(self.mount, Mount::OnFloor | Mount::Tiled)
    }
}

/// Patch semantics for a list: a non-empty patch replaces, an empty one inherits.
fn pick<T: Clone>(base: &[T], patch: &[T]) -> Vec<T> {
    if patch.is_empty() {
        base.to_vec()
    } else {
        patch.to_vec()
    }
}

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

    /// **The contract: `extent` is the placed size, and `scale` never multiplies it again.**
    ///
    /// `site/books` is the proof datum — raw mesh 0.5096 m wide, recorded `footprint (0.306, ..)`
    /// with `scale 0.6`, and 0.5096 × 0.6 = 0.306 — and `src/site/visuals.rs` states it in words:
    /// the extents are *"the post-scale values because every placement rule reads them"*. A previous
    /// version of these helpers multiplied by scale here, which shrank every space answer for `books`
    /// to 0.6× of a value that was already 0.6× — clicks missed the visible mesh and its reservation
    /// no longer covered what it drew. If this test ever fails, that double-application is back.
    #[test]
    fn everything_that_asks_how_much_room_reads_the_extent_as_placed() {
        // The books datum, verbatim from the site kit.
        let mut d = crate_desc();
        d.extent.footprint = Some((0.306, 0.106));
        d.extent.height = Some(0.178);
        d.align.scale = Some(0.6);

        assert_eq!(
            placed_footprint(&d),
            Some((0.306, 0.106)),
            "extent IS the answer"
        );
        assert_eq!(placed_height(&d), Some(0.178));

        // The reservation covers the drawn mesh exactly: raw 0.5096 × scale 0.6 = 0.306 drawn, and
        // covers() must reach the drawn half-width and no further.
        assert!(crate::stack::covers(&d, (0.0, 0.0), 0.0, (0.15, 0.0)));
        assert!(!crate::stack::covers(&d, (0.0, 0.0), 0.0, (0.16, 0.0)));

        // And the lattice derives from the same numbers.
        let div = divisions(&d, 1).unwrap_or_else(|e| panic!("{e}"));
        assert_eq!(
            div,
            (
                crate::grid::cells(0.306).0,
                crate::grid::cells(0.178).0,
                crate::grid::cells(0.106).0
            )
        );

        // Explicit unity, absent, and any other scale are all the same answer — scale is a render
        // instruction, invisible to every space question by construction.
        for s in [None, Some(1.0), Some(0.6), Some(2.0)] {
            let mut v = d.clone();
            v.align.scale = s;
            assert_eq!(
                placed_footprint(&v),
                Some((0.306, 0.106)),
                "scale {s:?} leaked into space"
            );
            assert_eq!(divisions(&v, 3), divisions(&d, 3));
        }
    }

    /// A stretched wall is taller than its extent — `stretch_y` is game policy layered on top of the
    /// art, the one factor that is NOT baked in — and it never touches the footprint.
    #[test]
    fn stretch_reaches_the_placed_height_and_scale_does_not() {
        let mut d = crate_desc();
        d.extent.footprint = Some((1.0, 1.0));
        d.extent.height = Some(2.0);
        d.align.scale = Some(0.5);
        d.align.stretch_y = Some(1.2);
        assert_eq!(
            placed_height(&d),
            Some(2.4),
            "2.0 * 1.2 — the scale is already in the 2.0"
        );
        assert_eq!(placed_footprint(&d), Some((1.0, 1.0)));
    }

    /// An unmeasured piece stays unmeasured — no default may invent a zero, which is the value that
    /// overlaps nothing and lets a prop sit inside a wall.
    #[test]
    fn an_unmeasured_piece_yields_nothing_rather_than_zero() {
        let mut d = crate_desc();
        d.extent.footprint = None;
        d.align.scale = Some(0.6);
        assert_eq!(placed_footprint(&d), None);
        assert!(
            divisions(&d, 1).is_err(),
            "and the lattice is refused by name"
        );
    }

    /// **Both wall mounts carry a height, and nothing else does.** The floor and the ceiling are the
    /// map's to state; a wall's is not, which is why these two carry one and `OnFloor` does not.
    #[test]
    fn the_two_wall_mounts_are_the_ones_with_a_height() {
        assert_eq!(mount_height(&Mount::OnWall { height: 1.6 }), Some(1.6));
        assert_eq!(
            mount_height(&Mount::Decal {
                on: DecalHost::Wall { height: 2.1 }
            }),
            Some(2.1)
        );
        for no_height in [
            Mount::OnFloor,
            Mount::OnCeiling,
            Mount::Tiled,
            Mount::InOpening { clear: None },
            Mount::OnSurface {
                class: "worktop".into(),
            },
            Mount::Decal {
                on: DecalHost::Floor,
            },
            Mount::Decal {
                on: DecalHost::Ceiling,
            },
        ] {
            assert_eq!(mount_height(&no_height), None, "{no_height:?}");
            assert_eq!(
                with_mount_height(&no_height, 1.0),
                None,
                "{no_height:?} has no height to set, and must say so rather than no-op"
            );
        }
    }

    /// Setting a height keeps the mount it was set on. A poster must not become a sconce because
    /// somebody typed a number at it.
    #[test]
    fn setting_a_height_does_not_change_which_mount_it_is() {
        let poster = Mount::Decal {
            on: DecalHost::Wall { height: 1.8 },
        };
        let moved = with_mount_height(&poster, 1.2).unwrap_or_else(|| panic!("has a height"));
        assert!(matches!(
            moved,
            Mount::Decal {
                on: DecalHost::Wall { .. }
            }
        ));
        assert_eq!(mount_height(&moved), Some(1.2));

        let sconce = with_mount_height(&Mount::OnWall { height: 1.8 }, 2.4)
            .unwrap_or_else(|| panic!("has a height"));
        assert!(matches!(sconce, Mount::OnWall { .. }));
        assert_eq!(mount_height(&sconce), Some(2.4));
    }

    /// Every wall mount `mount_options` offers can be re-heighted — otherwise the editor could cycle
    /// onto one it then had no way to adjust, which is the gap this pair closes.
    #[test]
    fn every_offered_wall_mount_can_be_re_heighted() {
        for m in mount_options(&["worktop".to_owned()]) {
            if mount_height(&m).is_some() {
                assert!(
                    with_mount_height(&m, 1.0).is_some(),
                    "{m:?} reports a height but cannot be given one"
                );
            }
        }
    }

    /// The surface mount is the one whose payload is a project token, so the options have to come
    /// from the project — offering `OnSurface { "worktop" }` where nothing offers a worktop is
    /// offering a mount that can never be satisfied.
    #[test]
    fn mount_options_come_from_the_projects_surface_vocabulary() {
        let none = mount_options(&[]);
        assert!(
            !none.iter().any(|m| matches!(m, Mount::OnSurface { .. })),
            "a project with no surface classes must offer no surface mount"
        );

        let two = mount_options(&["support".to_owned(), "worktop".to_owned()]);
        let classes: Vec<&str> = two
            .iter()
            .filter_map(|m| match m {
                Mount::OnSurface { class } => Some(class.as_str()),
                _ => None,
            })
            .collect();
        assert_eq!(classes, ["support", "worktop"]);
        assert!(
            !two.iter()
                .any(|m| matches!(m, Mount::OnSurface { class } if class.is_empty())),
            "the placeholder must not survive into the offered list"
        );
    }

    /// The common case comes first. An author cycling past four exotic variants to reach "on floor"
    /// will bind their own key instead.
    #[test]
    fn the_first_option_is_the_one_most_pieces_want() {
        assert_eq!(mount_options(&[])[0], Mount::OnFloor);
    }

    #[test]
    fn every_mount_has_a_label_short_enough_for_one_line() {
        for m in mount_options(&["worktop".to_owned()]) {
            let label = mount_label(Some(&m));
            assert!(!label.is_empty(), "{m:?} has no label");
            assert!(label.len() <= 24, "{m:?} label is too long: {label}");
        }
        assert_eq!(mount_label(None), "unset");
    }

    fn crate_desc() -> Descriptor {
        Descriptor {
            id: "crate".into(),
            mesh: Some("ozea/crate.glb".into()),
            extent: Extent {
                footprint: Some((0.6, 0.6)),
                height: Some(0.5),
            },
            mount: Some(Mount::OnFloor),
            kind: vec!["container".into()],
            ..Default::default()
        }
    }

    /// **A tipped piece covers the floor it is lying on, and an upright one never needs its height.**
    ///
    /// The second half is the one with teeth. Routing the untipped case through [`tipped_extents`]
    /// would make every descriptor with a measured footprint and no recorded height come back
    /// unmeasured — and unmeasured means centre-snapping, so a whole kit of flat floor tiles would
    /// quietly move onto a different lattice from the one they were authored on. That is a
    /// regression with no error message attached, which is why the short circuit is stated in the
    /// code and pinned here rather than left as an optimisation somebody could "simplify" away.
    #[test]
    fn a_tipped_piece_reports_the_floor_it_lies_on() {
        // 0.6 x 0.6 on the floor, 0.5 tall — a tip has to move the 0.5 into the footprint.
        let d = crate_desc();
        assert_eq!(tipped_footprint(&d, (0, 0)), Some((0.6, 0.6)));
        // About X: depth and height trade, so it lies 0.5 deep.
        assert_eq!(tipped_footprint(&d, (1, 0)), Some((0.6, 0.5)));
        // About Z: width and height trade.
        assert_eq!(tipped_footprint(&d, (0, 1)), Some((0.5, 0.6)));
        // A half turn puts a different face down and leaves the box alone.
        assert_eq!(tipped_footprint(&d, (2, 2)), Some((0.6, 0.6)));
        // It agrees with `tipped_extents` by construction — the horizontal two of its three.
        for tip in [(0, 0), (1, 0), (0, 1), (1, 1), (3, 2)] {
            let (w, _h, depth) = tipped_extents(&d, tip).unwrap_or_else(|| panic!("{tip:?}"));
            assert_eq!(tipped_footprint(&d, tip), Some((w, depth)), "tip {tip:?}");
        }

        // **Standing up, the height is not part of the question.**
        let heightless = Descriptor {
            extent: Extent {
                footprint: Some((0.6, 0.6)),
                height: None,
            },
            ..crate_desc()
        };
        assert_eq!(
            tipped_footprint(&heightless, (0, 0)),
            Some((0.6, 0.6)),
            "an upright piece covers its footprint whatever its height"
        );
        // Tipped, the height IS one of the floor dimensions, so not knowing it is not knowing the
        // box — `None`, propagated, never a guess.
        assert_eq!(tipped_footprint(&heightless, (1, 0)), None);
    }

    #[test]
    fn a_descriptor_resolves_its_defaults() {
        let r = crate_desc().resolve().expect("resolves");
        assert_eq!(r.scale, 1.0, "scale defaults to unity");
        assert_eq!(r.y_offset, 0.0);
        assert_eq!(r.pivot, (0.0, 0.0));
        assert_eq!(r.front, None, "no front is a claim, not a missing value");
        assert!(r.occupies_floor());
    }

    /// The failure this prevents is silent: a zero footprint overlaps nothing, so every placement rule
    /// reports success while the prop sits inside a wall.
    #[test]
    fn a_missing_footprint_is_refused_by_name() {
        let mut d = crate_desc();
        d.extent.footprint = None;
        let err = d.resolve().expect_err("must refuse");
        assert!(err.contains("crate") && err.contains("footprint"), "{err}");
    }

    #[test]
    fn a_missing_height_is_refused_unless_it_is_a_decal() {
        let mut d = crate_desc();
        d.extent.height = None;
        assert!(d.resolve().is_err(), "a solid piece must state its height");

        d.mount = Some(Mount::Decal {
            on: DecalHost::Wall { height: 1.8 },
        });
        let r = d.resolve().expect("a decal may omit height");
        assert_eq!(r.height, 0.0);
        assert!(!r.occupies_floor(), "an decal claims no floor");
    }

    #[test]
    fn a_wall_decal_is_expressible() {
        // The thing neither old schema could say. `PropPlacement` had no Y, host or normal, so the
        // only wall-mounted object in the game was hardcoded in Rust.
        let d = Descriptor {
            id: "hazard_sign".into(),
            mesh: Some("signage/hazard.glb".into()),
            extent: Extent {
                footprint: Some((0.4, 0.4)),
                height: None,
            },
            mount: Some(Mount::Decal {
                on: DecalHost::Wall { height: 1.8 },
            }),
            ..Default::default()
        };
        let r = d.resolve().expect("resolves");
        assert_eq!(
            r.mount,
            Mount::Decal {
                on: DecalHost::Wall { height: 1.8 }
            }
        );
        assert!(!r.occupies_floor());
    }

    #[test]
    fn a_patch_inherits_what_it_does_not_state() {
        let base = crate_desc();
        let patch = Descriptor {
            id: String::new(),
            align: Align {
                front: Some(Face::East),
                ..Default::default()
            },
            ..Default::default()
        };
        let merged = base.patched_with(&patch);
        assert_eq!(merged.id, "crate", "an empty id inherits");
        assert_eq!(merged.mesh.as_deref(), Some("ozea/crate.glb"));
        assert_eq!(merged.extent.footprint, Some((0.6, 0.6)));
        assert_eq!(
            merged.align.front,
            Some(Face::East),
            "the patch wins where it speaks"
        );
    }

    /// Replace, not append — so a patch can take a tag away. An append-only list needs a second
    /// mechanism for removal the moment anyone wants one.
    #[test]
    fn a_patch_replaces_a_list_rather_than_appending() {
        let base = crate_desc();
        let patch = Descriptor {
            kind: vec!["debris".into()],
            ..Default::default()
        };
        assert_eq!(base.patched_with(&patch).kind, vec!["debris".to_string()]);

        let empty = Descriptor::default();
        assert_eq!(
            base.patched_with(&empty).kind,
            vec!["container".to_string()],
            "an empty list inherits rather than clearing"
        );
    }

    #[test]
    fn a_negative_clearance_is_refused() {
        let mut d = crate_desc();
        d.clearance = vec![Clearance {
            dir: Dir::Front,
            dist: -0.5,
        }];
        assert!(d.resolve().is_err());
    }

    #[test]
    fn the_schema_round_trips_through_ron() {
        let d = Descriptor {
            clearance: vec![Clearance {
                dir: Dir::Around,
                dist: 0.91,
            }],
            offers: Offers {
                surfaces: vec!["worktop".into()],
                faces: Vec::new(),
                sockets: vec![Socket {
                    id: "seat_n".into(),
                    role: Some("diner".into()),
                    at: (0.0, 0.75, -0.5),
                    yaw: 0.0,
                }],
            },
            effects: vec!["uses-electricity".into()],
            ..crate_desc()
        };
        let text =
            ron::ser::to_string_pretty(&d, ron::ser::PrettyConfig::default()).expect("serializes");
        let back: Descriptor = ron::from_str(&text).expect("parses");
        assert_eq!(d, back);
    }

    /// **Absence of evidence must not become evidence.**
    ///
    /// Every descriptor written before 2026-08-20 carries no `labels`, and so does every one a
    /// generator fills from a manifest. A round trip that minted an empty record for those would
    /// turn *"nobody has said"* into *"something wrote this, by nobody, on no date"* — and the
    /// editor's tag block reads that record to decide which chips it may draw as checked. The
    /// `skip_serializing_if` on the field is what keeps the file honest; this is the assertion that
    /// keeps the `skip_serializing_if`.
    #[test]
    fn a_descriptor_written_before_provenance_keeps_no_record_through_a_round_trip() {
        let d: Descriptor = ron::from_str("(id: \"crate\")").expect("parses");
        assert_eq!(d.labels, None, "no record means no record");

        let text =
            ron::ser::to_string_pretty(&d, ron::ser::PrettyConfig::default()).expect("serializes");
        assert!(
            !text.contains("labels"),
            "an absent record must not be written back as an empty one: {text}"
        );

        let back: Descriptor = ron::from_str(&text).expect("parses");
        assert_eq!(back.labels, None, "and it must still be absent on the way in");
    }

    /// **A provenance record with no date is refused**, rather than dated the dawn of time.
    ///
    /// [`LabelOrigin`] carried a container-level `#[serde(default)]`, so this literal parsed and
    /// `at` came out `""` — a record claiming a model wrote these values at no moment at all. The
    /// date is the field a person deciding what to re-check actually reads, so an empty one is worse
    /// than a missing record: a missing record says *"nobody has said"*, and this said *"checked,
    /// never"*.
    ///
    /// The message is matched with `contains` rather than an equality, so a `ron` bump that rewords
    /// its own missing-field text does not redden this — but a *successful* parse gets its own panic
    /// rather than an empty string that happens not to contain the word, because a refusal test that
    /// passes when nothing was refused is the failure mode this whole step is about.
    #[test]
    fn a_provenance_record_with_no_date_is_refused_at_parse() {
        let parsed = ron::from_str::<Descriptor>(
            "(id: \"crate\", labels: Some(( by: ( kind: Some(Model) ), model: Some(\"qwen3-vl\") )))",
        );
        let err = match parsed {
            Ok(d) => panic!(
                "a record with no date parsed, and `at` came out {:?}",
                d.labels.map(|l| l.at)
            ),
            Err(e) => e.to_string(),
        };
        assert!(err.contains("at"), "the refusal must name the missing field: {err}");
    }

    /// **A whole record survives the file**, which is the only reason writing one is worth anything.
    ///
    /// The mixed case is the normal case — a model's `kind` kept and its note rewritten by hand — so
    /// the fixture deliberately stamps two axes differently. `the_schema_round_trips_through_ron`
    /// builds from `crate_desc()` and never sets `labels` at all, so nothing pinned this until now:
    /// dropping a variant off [`By`] or a field off [`AxisOrigin`] would have gone unnoticed by the
    /// suite and shown up as a chip painted the wrong colour.
    #[test]
    fn a_provenance_record_round_trips_whole() {
        let d = Descriptor {
            labels: Some(LabelOrigin {
                by: AxisOrigin {
                    kind: Some(By::Model),
                    note: Some(By::Human),
                    ..AxisOrigin::default()
                },
                model: Some("qwen3-vl".into()),
                at: "2026-08-22".into(),
                model_at: Some("2026-08-20".into()),
                confidence: Some(Confidence::High),
            }),
            ..crate_desc()
        };
        let text =
            ron::ser::to_string_pretty(&d, ron::ser::PrettyConfig::default()).expect("serializes");
        let back: Descriptor = ron::from_str(&text).expect("parses");
        assert_eq!(d, back);
    }

    #[test]
    fn a_record_written_before_model_at_reads_its_model_date_from_at() {
        let old: LabelOrigin = ron::from_str(
            r#"(by: (kind: Some(Model)), model: Some("qwen3-vl"), at: "2026-08-22")"#,
        )
        .expect("parses");
        assert_eq!(old.model_date(), Some("2026-08-22"));
        let human: LabelOrigin = ron::from_str(r#"(at: "2026-08-22")"#).expect("parses");
        assert_eq!(human.model_date(), None);
        let stamped = LabelOrigin::stamped("qwen3-vl", "2026-08-20", None);
        assert_eq!(stamped.model_date(), Some("2026-08-20"));
    }
}

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

    /// The engine convention is `forward = (sin yaw, cos yaw)`, so yaw 0 points at +Z — which is
    /// South in `wfc`'s naming. Everything else follows, and getting this backwards would turn every
    /// seat in the game a quarter turn.
    #[test]
    fn a_faces_yaw_points_out_of_it() {
        for face in [Face::North, Face::East, Face::South, Face::West] {
            let yaw = face.yaw_degrees().to_radians();
            let (x, z) = (yaw.sin(), yaw.cos());
            let want = match face {
                Face::North => (0.0, -1.0),
                Face::East => (1.0, 0.0),
                Face::South => (0.0, 1.0),
                Face::West => (-1.0, 0.0),
            };
            assert!(
                (x - want.0).abs() < 1e-5 && (z - want.1).abs() < 1e-5,
                "{}: forward is ({x:.3}, {z:.3}), wanted {want:?}",
                face.label()
            );
        }
    }

    /// Round trip, and the snap: an angle within an eighth turn of a face resolves to it.
    #[test]
    fn an_angle_snaps_to_the_face_it_is_nearest() {
        for face in [Face::North, Face::East, Face::South, Face::West] {
            assert_eq!(Face::from_yaw(face.yaw_degrees()), face);
            // Off square by up to 44 degrees either way, and wrapped a full turn, still the same face.
            for drift in [-44.0, -1.0, 1.0, 44.0] {
                assert_eq!(Face::from_yaw(face.yaw_degrees() + drift), face, "{drift}");
                assert_eq!(Face::from_yaw(face.yaw_degrees() + drift + 360.0), face);
                assert_eq!(Face::from_yaw(face.yaw_degrees() + drift - 360.0), face);
            }
        }
        // The shipped value, which is what the four hand-measured fronts were written as.
        assert_eq!(Face::from_yaw(90.0), Face::East);
    }

    /// A yaw that is not a number cannot pick a face, and picking one at random would be a facing
    /// nobody authored. South is the identity — the zero-degree face — so it is the honest answer.
    #[test]
    fn a_yaw_that_is_not_an_angle_falls_to_the_identity_face() {
        assert_eq!(Face::from_yaw(f32::NAN), Face::South);
        assert_eq!(Face::South.yaw_degrees(), 0.0);
    }

    #[test]
    fn opposite_is_its_own_inverse_and_never_itself() {
        for face in [Face::North, Face::East, Face::South, Face::West] {
            assert_eq!(face.opposite().opposite(), face);
            assert_ne!(face.opposite(), face);
        }
    }

    /// A front and a lattice face are the same four numbers, so a piece's front can be used to read
    /// the cells it presents without a second mapping in between.
    #[test]
    fn a_face_is_the_same_direction_the_lattice_uses() {
        assert_eq!(Face::North.dir(), crate::wfc::N);
        assert_eq!(Face::East.dir(), crate::wfc::E);
        assert_eq!(Face::South.dir(), crate::wfc::S);
        assert_eq!(Face::West.dir(), crate::wfc::W);
    }

    /// A face survives the file it is written in.
    #[test]
    fn a_front_round_trips_through_ron() {
        let before = Descriptor {
            id: "chair".into(),
            align: Align {
                front: Some(Face::East),
                ..Align::default()
            },
            ..Descriptor::default()
        };
        let text = ron::ser::to_string_pretty(&before, ron::ser::PrettyConfig::default())
            .unwrap_or_else(|e| panic!("{e}"));
        assert!(text.contains("front: Some(East)"), "{text}");
        let after: Descriptor = ron::from_str(&text).unwrap_or_else(|e| panic!("{e}"));
        assert_eq!(before, after);
    }
}

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

    fn grid(cells: Vec<SubCell>) -> Subgrid {
        Subgrid { cells }
    }

    fn cell(at: (u32, u32, u32)) -> SubCell {
        SubCell {
            at,
            ..SubCell::default()
        }
    }

    const D3: (u32, u32, u32) = (3, 3, 3);

    /// A descriptor of exactly this size, unstretched — the shape most of these tests want.
    fn sized(w: f32, h: f32, d: f32) -> Descriptor {
        Descriptor {
            id: "x".into(),
            extent: Extent {
                footprint: Some((w, d)),
                height: Some(h),
            },
            ..Descriptor::default()
        }
    }

    /// The default is the whole point of the feature being cheap: a piece that says nothing about its
    /// inside costs no rows at all.
    #[test]
    fn a_tile_says_nothing_about_its_inside_by_default() {
        let g = Subgrid::default();
        assert_eq!(Subgrid::volume(D3), 27);
        assert!(g.cells.is_empty());
        assert!(g.validate("x", D3).is_ok());
    }

    /// Both facets on one cell — the thing this schema exists to allow.
    #[test]
    fn one_cell_can_be_solid_and_an_edge() {
        let c = SubCell {
            at: (2, 0, 1),
            solid: true,
            edge: Some("wall".into()),
        };
        let g = grid(vec![c.clone()]);
        assert!(g.validate("table", D3).is_ok());
        let got = g.at((2, 0, 1)).unwrap_or_else(|| panic!("no cell"));
        assert_eq!(got, &c);
        assert!(
            g.at((0, 0, 0)).is_none(),
            "unwritten cells are absent, not default rows"
        );
    }

    #[test]
    fn a_cell_outside_the_lattice_is_refused() {
        let e = grid(vec![cell((3, 0, 0))])
            .validate("desk", D3)
            .err()
            .unwrap_or_else(|| panic!("accepted"));
        assert!(e.contains("outside"), "{e}");
    }

    #[test]
    fn naming_one_cell_twice_is_refused() {
        let e = grid(vec![cell((1, 1, 1)), cell((1, 1, 1))])
            .validate("desk", D3)
            .err()
            .unwrap_or_else(|| panic!("accepted"));
        assert!(e.contains("twice"), "{e}");
    }

    #[test]
    fn an_axis_with_no_divisions_is_refused() {
        let e = grid(vec![])
            .validate("desk", (3, 0, 3))
            .err()
            .unwrap_or_else(|| panic!("accepted"));
        assert!(e.contains("no cells"), "{e}");
    }

    /// A lattice survives the file, which is what makes it authorable at all.
    #[test]
    fn a_lattice_round_trips_through_ron() {
        let before = Descriptor {
            id: "desk".into(),
            subgrid: Some(grid(vec![SubCell {
                at: (0, 0, 2),
                solid: true,
                edge: Some("wall".into()),
            }])),
            ..Descriptor::default()
        };
        let text = ron::ser::to_string_pretty(&before, ron::ser::PrettyConfig::default())
            .unwrap_or_else(|e| panic!("{e}"));
        let after: Descriptor = ron::from_str(&text).unwrap_or_else(|e| panic!("{e}"));
        assert_eq!(before, after);
    }

    /// An old library has no `subgrid` key at all and must still parse — the field defaults.
    #[test]
    fn a_descriptor_written_before_the_lattice_still_parses() {
        let d: Descriptor = ron::from_str("(id: \"crate\")").unwrap_or_else(|e| panic!("{e}"));
        assert_eq!(d.subgrid, None);
    }

    /// **The point of deriving divisions.** Two pieces of the same height present faces of the same
    /// length, so their edge tokens can be compared; two of different heights do not, and refusing is
    /// the honest answer rather than matching on a prefix.
    ///
    /// The numbers are the ones the plan was approved on, at the shipped `divisions: 1`.
    #[test]
    fn a_pieces_divisions_come_from_its_own_size() {
        let at = |d: Descriptor| divisions(&d, 1).unwrap_or_else(|err| panic!("{err}"));
        assert_eq!(at(sized(0.5, 0.5, 0.5)), (1, 1, 1), "crate");
        assert_eq!(at(sized(0.5, 0.9, 0.5)), (1, 2, 1), "chair");
        assert_eq!(at(sized(1.0, 0.75, 1.0)), (2, 2, 2), "table");
        assert_eq!(at(sized(3.0, 2.4, 0.5)), (6, 5, 1), "wall");
        assert_eq!(at(sized(1.0, 2.4, 0.5)), (2, 5, 1), "doorway");

        // A wall and a doorway are both 2.4 m, so both present five rows and can agree.
        let wall = at(sized(3.0, 2.4, 0.5));
        let doorway = at(sized(1.0, 2.4, 0.5));
        assert_eq!(wall.1, doorway.1);
        // A crate is not, and must not silently match the bottom of a wall.
        assert_ne!(at(sized(0.5, 0.5, 0.5)).1, wall.1);
    }

    /// Raising the project's number refines every axis of every piece by the same factor — the
    /// remedy Merrell & Manocha §4.5 names for objects that do not land on round multiples.
    #[test]
    fn dividing_a_tile_more_finely_refines_every_piece_equally() {
        let wall = sized(3.0, 2.4, 0.5);
        let one = divisions(&wall, 1).unwrap_or_else(|e| panic!("{e}"));
        let three = divisions(&wall, 3).unwrap_or_else(|e| panic!("{e}"));
        assert_eq!(three, (one.0 * 3, one.1 * 3, one.2 * 3));
    }

    /// A decal may omit its height (`Descriptor::resolve` says so) and still gets one layer rather
    /// than a degenerate lattice — `grid::cells` never returns zero.
    #[test]
    fn a_piece_with_no_height_still_has_one_layer() {
        let mut d = sized(1.0, 0.0, 1.0);
        d.extent.height = None;
        assert_eq!(
            divisions(&d, 1).unwrap_or_else(|err| panic!("{err}")),
            (2, 1, 2)
        );
    }

    /// No footprint is refused by id, not resolved to a zero lattice that every rule reads as fine.
    #[test]
    fn a_piece_with_no_footprint_is_refused_by_name() {
        let d = Descriptor {
            id: "mystery".into(),
            ..Descriptor::default()
        };
        let err = divisions(&d, 1).err().unwrap_or_default();
        assert!(
            err.contains("mystery") && err.contains("footprint"),
            "{err}"
        );
    }

    /// Zero divisions-per-tile is refused here too, so no caller can build a lattice with no cells.
    #[test]
    fn a_project_that_divides_a_tile_zero_ways_is_refused() {
        let err = divisions(&sized(1.0, 1.0, 1.0), 0)
            .err()
            .unwrap_or_default();
        assert!(err.contains("no cells"), "{err}");
    }

    /// A quarter turn swaps x and z; two turns are back to square; four are the identity.
    #[test]
    fn turning_swaps_the_x_and_z_divisions() {
        let div = (6, 5, 1);
        assert_eq!(rotate_div(div, 0), div);
        assert_eq!(rotate_div(div, 1), (1, 5, 6));
        assert_eq!(rotate_div(div, 2), div);
        assert_eq!(rotate_div(div, 3), (1, 5, 6));
        assert_eq!(rotate_div(div, 4), div);
    }
}

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

    const D3: (u32, u32, u32) = (3, 3, 3);

    #[test]
    fn toggling_a_cell_solid_and_back_leaves_no_trace() {
        let mut g = Subgrid::default();
        assert_eq!(g.toggle_solid((1, 0, 1), D3), Some(true));
        assert_eq!(g.cells.len(), 1);
        assert_eq!(g.toggle_solid((1, 0, 1), D3), Some(false));
        // The sparse invariant: a cell that says nothing is absent, not a row of `solid: false`.
        assert!(
            g.cells.is_empty(),
            "an unmarked cell must leave no row behind"
        );
    }

    /// A mesh scan states a fact rather than flipping one, so running it twice is running it once.
    #[test]
    fn marking_solid_is_idempotent_where_toggling_is_not() {
        let mut g = Subgrid::default();
        g.set_solid((1, 0, 1), D3)
            .unwrap_or_else(|| panic!("in range"));
        g.set_solid((1, 0, 1), D3)
            .unwrap_or_else(|| panic!("in range"));
        assert_eq!(g.cells.len(), 1);
        assert!(
            g.at((1, 0, 1)).is_some_and(|c| c.solid),
            "a second scan must not unmark it"
        );
    }

    #[test]
    fn a_cell_outside_the_lattice_cannot_be_written() {
        let mut g = Subgrid::default();
        assert_eq!(g.toggle_solid((3, 0, 0), D3), None);
        assert_eq!(g.set_edge((0, 9, 0), D3, "wall"), None);
        assert!(
            g.cells.is_empty(),
            "an out-of-range write must not append anything"
        );
    }

    /// A token set and then emptied takes its row with it — the sparse invariant, which is what stops
    /// a tile anyone has poked at accreting rows that mean exactly what absence means.
    #[test]
    fn a_token_set_and_cleared_leaves_no_row() {
        let mut g = Subgrid::default();
        g.set_edge((0, 0, 2), D3, "wall")
            .unwrap_or_else(|| panic!("in range"));
        let c = g.at((0, 0, 2)).unwrap_or_else(|| panic!("written"));
        assert_eq!(c.edge.as_deref(), Some("wall"));

        // Whitespace is empty: a token of spaces is not a token.
        g.set_edge((0, 0, 2), D3, "  ")
            .unwrap_or_else(|| panic!("in range"));
        assert!(g.cells.is_empty());
    }

    /// A cell keeps whichever facets are still set — clearing one must not drop the others.
    #[test]
    fn clearing_one_facet_keeps_the_rest() {
        let mut g = Subgrid::default();
        g.toggle_solid((1, 1, 1), D3);
        g.set_edge((1, 1, 1), D3, "wall")
            .unwrap_or_else(|| panic!("in range"));
        g.set_edge((1, 1, 1), D3, "")
            .unwrap_or_else(|| panic!("in range"));
        let c = g.at((1, 1, 1)).unwrap_or_else(|| panic!("still solid"));
        assert!(c.solid);
        assert!(c.edge.is_none());
    }

    #[test]
    fn clear_forgets_the_whole_cell() {
        let mut g = Subgrid::default();
        g.toggle_solid((2, 2, 2), D3);
        g.set_edge((2, 2, 2), D3, "wall")
            .unwrap_or_else(|| panic!("in range"));
        g.clear((2, 2, 2));
        assert!(g.at((2, 2, 2)).is_none());
        assert!(g.validate("x", D3).is_ok());
    }

    /// A patch saying nothing about the lattice inherits it; a patch stating one replaces it, and an
    /// **empty** stated lattice clears every cell. Those last two were indistinguishable before
    /// `subgrid` became an `Option`, and the inherit reading silently won.
    #[test]
    fn a_patch_can_state_an_empty_lattice_without_it_meaning_silence() {
        let base = Descriptor {
            id: "wall".into(),
            subgrid: Some(Subgrid {
                cells: vec![SubCell {
                    at: (0, 0, 0),
                    solid: true,
                    ..SubCell::default()
                }],
            }),
            ..Descriptor::default()
        };

        let silent = base.patched_with(&Descriptor::default());
        assert_eq!(silent.subgrid, base.subgrid, "no opinion inherits");

        let cleared = base.patched_with(&Descriptor {
            subgrid: Some(Subgrid::default()),
            ..Descriptor::default()
        });
        assert_eq!(
            cleared.subgrid,
            Some(Subgrid::default()),
            "an explicitly empty lattice clears the cells rather than inheriting them"
        );
    }
}

/// The rotation field: what it accepts, what it refuses, and how it layers.
#[cfg(test)]
mod rotate_tests {
    use super::*;

    #[test]
    fn a_rotation_is_read_as_quarter_turns() {
        assert_eq!(quarter_turns_xyz((0, 0, 0), "x"), Ok((0, 0, 0)));
        assert_eq!(quarter_turns_xyz((90, 180, 270), "x"), Ok((1, 2, 3)));
        // Wrapped either way — an author writing -90 means three quarters, not an error.
        assert_eq!(quarter_turns_xyz((-90, 360, 720), "x"), Ok((3, 0, 0)));
    }

    /// Refused rather than rounded. Rounding would draw the mesh at one angle while every
    /// measurement beside it described another.
    #[test]
    fn a_rotation_that_is_not_a_quarter_turn_is_refused_by_name() {
        let err = quarter_turns_xyz((0, 45, 0), "lamp")
            .err()
            .unwrap_or_default();
        assert!(
            err.contains("lamp") && err.contains("45") && err.contains("about Y"),
            "{err}"
        );
    }

    /// A patch may state a rotation, and silence inherits — the rule every other `Align` field holds.
    #[test]
    fn a_rotation_layers_like_every_other_correction() {
        let base = Descriptor {
            id: "door".into(),
            align: Align {
                rotate: Some((90, 0, 0)),
                ..Align::default()
            },
            ..Descriptor::default()
        };
        assert_eq!(
            base.patched_with(&Descriptor::default()).align.rotate,
            Some((90, 0, 0)),
            "silence inherits"
        );
        assert_eq!(
            base.patched_with(&Descriptor {
                align: Align {
                    rotate: Some((0, 90, 0)),
                    ..Align::default()
                },
                ..Descriptor::default()
            })
            .align
            .rotate,
            Some((0, 90, 0)),
            "a stated rotation wins"
        );
    }

    /// A rotation survives the file, and reads as degrees rather than as a count nobody can picture.
    #[test]
    fn a_rotation_round_trips_through_ron() {
        let before = Descriptor {
            id: "door".into(),
            align: Align {
                rotate: Some((90, 0, 180)),
                ..Align::default()
            },
            ..Descriptor::default()
        };
        let text = ron::ser::to_string_pretty(&before, ron::ser::PrettyConfig::default())
            .unwrap_or_else(|e| panic!("{e}"));
        assert!(text.contains("rotate: Some((90, 0, 180))"), "{text}");
        assert_eq!(
            ron::from_str::<Descriptor>(&text).unwrap_or_else(|e| panic!("{e}")),
            before
        );
    }
}

/// **Which lattice cell a ray enters, and through which face.**
///
/// The lattice is an axis-aligned box from `origin` spanning `size`, divided `div` ways per axis.
/// `dir` need not be normalised. Returns `None` when the ray misses, or when it starts inside the box
/// — a camera inside the piece it is authoring has no "entry" to report, and guessing one would put
/// the cursor on a face the author cannot see.
///
/// # What the face means, and what it does not
///
/// The face is **where you are looking from**, which is what makes a token's audience legible: an
/// author putting `seam` on a cell wants to know which neighbour will read it. It is not a new place
/// to store anything — [`SubCell::edge`] is one token per *cell*, and
/// [`crate::adjacency::face`] collects the tokens of whichever cells lie on the face being compared.
/// A corner cell is on two faces and presents the same token to both, by design.
///
/// `None` for the face means the ray came in through the top or the bottom. Those are real surfaces
/// to click on — you pick cells from above constantly — but they are not faces any rule reads, since
/// adjacency is horizontal. Saying `None` is more honest than naming a direction that matches nothing.
///
/// Slab method: clip the ray against each pair of parallel planes and keep the latest entry. The axis
/// that produced that entry is the face it came through.
pub fn pick_cell(
    ray_origin: [f32; 3],
    dir: [f32; 3],
    origin: [f32; 3],
    size: [f32; 3],
    div: (u32, u32, u32),
) -> Option<((u32, u32, u32), Option<Face>)> {
    let n = [div.0, div.1, div.2];
    if n.contains(&0) || size.iter().any(|s| *s <= 0.0) {
        return None;
    }
    if ray_origin.iter().chain(dir.iter()).any(|v| !v.is_finite()) {
        return None;
    }

    let mut t_enter = f32::NEG_INFINITY;
    let mut t_exit = f32::INFINITY;
    // Which axis let the ray in last, and from which side. `None` until a slab actually bounds it —
    // a ray parallel to every axis but one still has a face.
    let mut entry: Option<(usize, bool)> = None;

    for a in 0..3 {
        let (lo, hi) = (origin[a], origin[a] + size[a]);
        if dir[a].abs() < 1e-9 {
            // Parallel to this slab: inside it forever, or outside it forever.
            if ray_origin[a] < lo || ray_origin[a] > hi {
                return None;
            }
            continue;
        }
        let (mut t1, mut t2) = ((lo - ray_origin[a]) / dir[a], (hi - ray_origin[a]) / dir[a]);
        // `from_low` tracks which plane t1 is, before the swap loses that.
        let from_low = t1 <= t2;
        if !from_low {
            std::mem::swap(&mut t1, &mut t2);
        }
        if t1 > t_enter {
            t_enter = t1;
            entry = Some((a, from_low));
        }
        t_exit = t_exit.min(t2);
        if t_enter > t_exit {
            return None;
        }
    }

    // Behind the camera, or starting inside. Both are "no entry face to report".
    if t_enter <= 0.0 {
        return None;
    }

    let hit = [
        ray_origin[0] + dir[0] * t_enter,
        ray_origin[1] + dir[1] * t_enter,
        ray_origin[2] + dir[2] * t_enter,
    ];
    let mut cell = [0u32; 3];
    for a in 0..3 {
        let frac = (hit[a] - origin[a]) / size[a];
        // Clamped rather than refused: the entry point sits exactly on a boundary by construction, and
        // floating point puts it a hair outside about half the time.
        cell[a] = ((frac * n[a] as f32) as i64).clamp(0, n[a] as i64 - 1) as u32;
    }

    let face = entry.and_then(|(axis, from_low)| match (axis, from_low) {
        // Entering through the low-X plane means looking at the piece's WEST face.
        (0, true) => Some(Face::West),
        (0, false) => Some(Face::East),
        // North is −Z, so the low-Z plane is the north face.
        (2, true) => Some(Face::North),
        (2, false) => Some(Face::South),
        // Top or bottom: a surface, but not a face adjacency reads.
        _ => None,
    });
    Some(((cell[0], cell[1], cell[2]), face))
}

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

    const ORIGIN: [f32; 3] = [0.0, 0.0, 0.0];
    const SIZE: [f32; 3] = [3.0, 2.4, 0.5];
    /// The shipped wall's lattice.
    const DIV: (u32, u32, u32) = (6, 5, 1);

    /// Looking at the wall from +X hits its EAST face and the last x cell.
    #[test]
    fn a_ray_from_the_east_reports_the_east_face() {
        let got = pick_cell([10.0, 1.2, 0.25], [-1.0, 0.0, 0.0], ORIGIN, SIZE, DIV);
        let ((x, _, _), face) = got.unwrap_or_else(|| panic!("must hit"));
        assert_eq!(x, DIV.0 - 1, "the near column, not the far one");
        assert_eq!(face, Some(Face::East));
    }

    /// And from −X, the west face and cell zero. The pair together is the check that the sign of the
    /// slab is not inverted — one of them alone would pass either way.
    #[test]
    fn a_ray_from_the_west_reports_the_west_face() {
        let got = pick_cell([-10.0, 1.2, 0.25], [1.0, 0.0, 0.0], ORIGIN, SIZE, DIV);
        let ((x, _, _), face) = got.unwrap_or_else(|| panic!("must hit"));
        assert_eq!(x, 0);
        assert_eq!(face, Some(Face::West));
    }

    /// North is −Z and South is +Z, matching `crate::wfc`. Getting this backwards would put every
    /// token on the wrong side of every wall.
    #[test]
    fn the_z_faces_follow_the_projects_own_compass() {
        let from_north = pick_cell([1.5, 1.2, -10.0], [0.0, 0.0, 1.0], ORIGIN, SIZE, DIV);
        assert_eq!(from_north.and_then(|(_, f)| f), Some(Face::North));
        let from_south = pick_cell([1.5, 1.2, 10.0], [0.0, 0.0, -1.0], ORIGIN, SIZE, DIV);
        assert_eq!(from_south.and_then(|(_, f)| f), Some(Face::South));
    }

    /// **Looking down picks a cell but names no face.** Adjacency is horizontal, so there is no face
    /// here that any rule reads — and inventing one would put a token where nothing looks for it.
    #[test]
    fn looking_down_gives_a_cell_and_no_face() {
        let got = pick_cell([1.5, 10.0, 0.25], [0.0, -1.0, 0.0], ORIGIN, SIZE, DIV);
        let ((_, y, _), face) = got.unwrap_or_else(|| panic!("must hit"));
        assert_eq!(y, DIV.1 - 1, "the top layer");
        assert_eq!(face, None, "top and bottom are surfaces, not faces");
    }

    /// The cell tracks where along the face the ray landed, which is the whole point of picking.
    #[test]
    fn the_cell_follows_the_hit_along_the_face() {
        // Each x cell is 0.5 m wide; aim at the middle of the third.
        let got = pick_cell([1.25, 0.25, 10.0], [0.0, 0.0, -1.0], ORIGIN, SIZE, DIV);
        let ((x, y, z), _) = got.unwrap_or_else(|| panic!("must hit"));
        assert_eq!((x, y, z), (2, 0, 0), "third column, bottom layer");
    }

    /// A miss is a miss, in every direction it can be one.
    #[test]
    fn a_ray_that_misses_reports_nothing() {
        // Past the end of the wall.
        assert!(pick_cell([10.0, 1.2, 9.0], [-1.0, 0.0, 0.0], ORIGIN, SIZE, DIV).is_none());
        // Above it.
        assert!(pick_cell([10.0, 9.0, 0.25], [-1.0, 0.0, 0.0], ORIGIN, SIZE, DIV).is_none());
        // Pointing away from it.
        assert!(pick_cell([10.0, 1.2, 0.25], [1.0, 0.0, 0.0], ORIGIN, SIZE, DIV).is_none());
        // Starting inside: there is no entry face to name.
        assert!(pick_cell([1.5, 1.2, 0.25], [0.0, 0.0, -1.0], ORIGIN, SIZE, DIV).is_none());
    }

    /// Degenerate input is refused rather than answered with cell zero.
    #[test]
    fn a_lattice_or_ray_that_cannot_be_picked_is_refused() {
        assert!(pick_cell([0.0, 0.0, 10.0], [0.0, 0.0, -1.0], ORIGIN, SIZE, (0, 1, 1)).is_none());
        assert!(pick_cell([0.0, 0.0, 10.0], [0.0, 0.0, -1.0], ORIGIN, [0.0; 3], DIV).is_none());
        assert!(pick_cell([f32::NAN, 0.0, 10.0], [0.0, 0.0, -1.0], ORIGIN, SIZE, DIV).is_none());
    }

    /// **Every cell is reachable**, and the cell a ray picks is the cell whose box contains the hit.
    /// Swept rather than sampled at one point, because an off-by-one in the clamp would show up only
    /// at the edges.
    #[test]
    fn sweeping_the_face_walks_every_column_in_order() {
        let mut seen = Vec::new();
        for i in 0..DIV.0 {
            // The middle of each column.
            let x = (i as f32 + 0.5) * SIZE[0] / DIV.0 as f32;
            let got = pick_cell([x, 0.25, 10.0], [0.0, 0.0, -1.0], ORIGIN, SIZE, DIV);
            let ((cx, _, _), _) = got.unwrap_or_else(|| panic!("column {i} must hit"));
            seen.push(cx);
        }
        assert_eq!(seen, (0..DIV.0).collect::<Vec<_>>());
    }
}