File size: 92,409 Bytes
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502e0c9
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502e0c9
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502e0c9
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502e0c9
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502e0c9
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502e0c9
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502e0c9
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502e0c9
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502e0c9
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
;
;
/* ============================================================================

   TERRAN FRONTLINE COMMAND — UNIT CHASSIS KIT   (internal kit key `nova`)

   ----------------------------------------------------------------------------

   DISCIPLINE. TECHNOLOGY. UNITY.



   The base roster was the oldest art in the game and it showed: every ground

   vehicle was `runningGear()` plus `hullShell()` plus one distinguishing lump,

   which at command-view zoom is fifteen identical grey slabs. Worse, it was

   also the most EXPENSIVE roster in the game — the Nova Goliath cost 4,072

   triangles against the Dominion walker's 2,232 for the same slot, because

   runningGear() spends 1,400-2,300 of them on per-wheel hydraulic rams hidden

   behind a track skirt that this camera never looks under. The army that

   looked the cheapest was paying the most for it.



   Everything here is built on `trackUnit()` instead (496/560/712 triangles for

   light/medium/heavy — the same read for a quarter of the cost), and the

   difference is spent on the part of the silhouette a player actually sees:

   the deck, the weapon and the outline.



   THE DOCTRINE. The Frontline Command is a professional combined-arms force

   and its machines are MAINTAINED. The vocabulary, repeated on every chassis

   so the army reads as one manufacturer before it reads as one colour:



     - welded monocoque hulls: a lower tub, a smaller upper deck stepped in on

       top of it, and a bevel on every exposed edge. No applique, no rivets;

     - a RAKED glacis that actually rakes fore-aft (see the wedge note below),

       with a brow lip and tow shackles either side of it;

     - deliberate panel breaks — a recessed seam with a bright edge — rather

       than large blank plate;

     - crew hatches, sensor masts and vision blocks. These are crewed vehicles

       and it should be obvious;

     - stowage strapped down where a crew would strap it, and ammunition

       lockers bolted to the flanks;

     - DUCTED exhaust exiting sideways behind a heat shield. The Dominion vents

       straight up through open stacks because nobody paid for ducting; that

       contrast is the point;

     - the livery band lands in the SAME PLACE on every chassis — the top edge

       of the track skirt — with a second panel on the turret cheeks.



   Restraint is the character. It is the opposite of the Dominion's riveted

   expendable iron and the opposite of the Coalition's cockpitless hovering

   machines, and it gets there by being deliberately plainer than either.



   CONTRACT, copied from mdlNovaRhino(): +X is forward (the direction the unit

   shoots), +Y is up, Z is lateral, the lowest point of a ground vehicle sits

   at y=0, and a builder returns {hull, tur, s, turH}. Unturreted units return

   tur:null and no turH.



   MATERIALS ARE LOAD-BEARING.

     TEAM_A / TEAM_B / TEAM_T are the only surfaces the faction colour reaches

       in full; everything else takes a partial wash. Two to five panels per

       model, never the whole hull. Hulls are MET/MET_L/MET_D on purpose so the

       body still carries the wash; weapons and machinery are TWR_MACH, which

       the shader washes far less, so the gun reads as steel against a tinted

       vehicle.

     SERVO marks a LEG — the vertex stage swings anything wearing it through

       the walk cycle. It appears in this file ONLY on mdlTfcWalker and the two

       infantry, which are the only Nova slots with `legs:1` AND actual legs.

       The Goliath's TYPES row has said legs:1 for as long as it has existed;

       the old model was a tracked tank, so the flag animated nothing.

     HOT / ENERGY / LAMP are emissive and are rationed to exhaust bores, muzzle

       collars, equipment cores and hatch beacons.



   THE WEDGE TRAP. MeshBuilder.wedge() slopes across its THIRD axis, so a wedge

   dropped in at yaw 0 ramps SIDEWAYS — every "glacis" authored in the base

   roster is really a lateral A-frame, and it was never caught because a sloped

   shape still looks sloped in a screenshot. glacisX() yaws a quarter turn so

   the ridge sits at the rear and the slope runs down to the nose. Every sloped

   plate in this file goes through it or through tfcProw().



   Names are prefixed tfc/mdlTfc because this repo is one global scope and

   `verifyglobals.mjs` fails the build on a duplicate top-level name.

   ============================================================================ */

/* ---------------------------------------------------------------------------

   FRONTLINE COMMAND DETAIL KIT

   --------------------------------------------------------------------------- */

/* A welded panel break: a shallow recess with a bright seam along it. This is

   the cheapest thing in the file (22 triangles) and it does more work than

   anything else — it is what stops a 6x4 armour face reading as an untextured

   slab, and it is the single detail that separates a Frontline hull from the

   Dominion's bolted plate at the distance this game is played at. */
function tfcSeam(m,x,y,z,len,wid,col,yaw){
  m.inset(x,y,z,len,wid,0.20,0.34,DARKER,yaw||0);
  m.box(x,y+0.03,z,len*0.90,0.09,0.13,col||MET_L,yaw||0);
  return m;
}

/* Crew hatch: rim, lid stepped in, grab handle. A vehicle with a hatch is a

   vehicle with people in it, which is exactly the read the Coalition's drones

   must not have. */
function tfcHatch(m,x,y,z,r,col){
  m.cyl(x,y,z,r,r*0.94,0.20,7,col||MET_D);
  m.cyl(x,y+0.20,z,r*0.80,r*0.74,0.16,7,MET_L);
  m.box(x,y+0.36,z,r*0.26,0.12,r*1.05,MET_L);
  return m;
}

/* Tow shackle: bracket and eye. Four per vehicle, front and rear, because

   recovery gear is what "we get our vehicles back" looks like in geometry. */
function tfcTow(m,x,y,z,yaw){
  m.box(x,y,z,0.52,0.58,0.40,MET_D,yaw||0);
  m.ring(x,y+0.60,z,0.20,0.40,7,MET_L);
  return m;
}

/* Ducted exhaust: a horizontal outlet with a real shadowed bore and a heat

   shield plate over it. Deliberately NOT the Dominion's open vertical stack.

   Points -X, so it exits behind the vehicle. */
function tfcExhaust(m,x,y,z,n,r){
  for(let k=0;k<n;k++){
    const oz=z+(k-(n-1)/2)*r*2.9;
    cylX(m,x,y,oz,r*1.9,r*1.14,r*0.98,7,MET_D,false);
    tubeX(m,x-r*0.7,y,oz,r*0.55,r*1.00,r*0.52,7,TWR_BORE);
    ringX(m,x-r*0.62,y,oz,r*0.28,r*0.56,7,HOT);
  }
  m.box(x-r*0.55,y+r*1.55,z,r*3.1,0.20,r*3.0*n,MET_L);
  return m;
}

/* Running gear. trackUnit() already builds the right components at the right

   price and puts the livery on the skirt's top edge — the one piece of running

   gear an overhead camera can see — so this only adds the Frontline tells: a

   bright welded seam along the skirt and two mud-flap ribs per side. */
function tfcTracks(m,len,wid,h,gauge,n,cls){
  const K=cls==null?1:cls;
  trackUnit(m,len,wid,h,gauge,n,MET_D,K,TEAM_A);
  const W=wid*[0.84,1.0,1.24][K];
  for(const sd of [-1,1]){
    m.box(0,h*1.44,sd*(gauge+W*0.14),len*0.90,0.14,W*0.26,MET_L);
    for(const f of [-1,1])
      m.box(f*len*0.40,h*0.92,sd*(gauge+W*0.56),len*0.09,h*0.46,0.20,MET_L);
  }
  return m;
}

/* Welded two-tier hull. The lower tub carries the plan shape; the upper deck

   is the SAME outline pulled in, which is what reads as a plate break from

   directly above. Returns the deck height so callers stack hardware on it

   without repeating the arithmetic. */
function tfcHull(m,x,yBase,len,wid,hLow,hHigh,colLow,colHigh){
  const L=len*0.5, W=wid*0.5;
  const prof=[[-L,-W*0.76],[-L*0.64,-W],[L*0.32,-W],[L*0.84,-W*0.62],[L,-W*0.26],
              [ L, W*0.26],[L*0.84, W*0.62],[L*0.32, W],[-L*0.64, W],[-L, W*0.76]];
  m.extrude(x,yBase,0,prof,hLow,colLow||MET);
  m.extrude(x,yBase+hLow,0,prof.map(p=>[p[0]*0.90,p[1]*0.84]),hHigh,colHigh||MET_L);
  return yBase+hLow+hHigh;
}

/* Raked prow: glacis (through glacisX, so it slopes fore-aft), brow lip, a

   seam across the plate and a tow shackle either side. */
function tfcProw(m,x,y,z,len,h,wid){
  glacisX(m,x,y,z,len,h,wid,MET_L);
  m.box(x-len*0.34,y+h*0.92,z,len*0.30,0.22,wid*0.66,MET_L);
  m.box(x+len*0.30,y+h*0.16,z,len*0.20,h*0.26,wid*0.70,MET_D);
  for(const sd of [-1,1]) tfcTow(m,x+len*0.28,y+h*0.04,z+sd*wid*0.30,0);
  return m;
}

/* Turret bearing ring. A turret sitting straight on a deck reads as a box

   balanced on a box; the ring is what makes it a machine that traverses. */
function tfcRing(t,x,r,h){
  t.cyl(x,-h,0,r,r*0.90,h,10,TWR_PAD);
  t.ring(x,0,0,r*0.62,r*0.94,10,MET_D);
  return t;
}

/* Welded turret: faceted body, deck stepped in, cheek livery, smoke

   dischargers, a bustle rack and a commander's cupola with a hatch. Everything

   a Frontline turret has in common with every other Frontline turret. */
function tfcTurret(t,len,wid,h,cupZ){
  const L=len*0.5, W=wid*0.5;
  const prof=[[-L,-W*0.80],[-L*0.52,-W],[L*0.30,-W*0.94],[L*0.88,-W*0.52],
              [ L,-W*0.22],[ L, W*0.22],[L*0.88, W*0.52],[L*0.30, W*0.94],
              [-L*0.52, W],[-L, W*0.80]];
  t.extrude(0,0,0,prof,h*0.70,MET);
  t.extrude(0,h*0.70,0,prof.map(p=>[p[0]*0.84,p[1]*0.78]),h*0.30,MET_L);
  for(const sd of [-1,1]){
    t.bevelBox(-L*0.10,h*0.20,sd*W*0.92,len*0.54,h*0.44,0.40,0.13,sd>0?TEAM_A:TEAM_B);
    t.box(-L*0.60,h*0.72,sd*W*0.54,len*0.16,h*0.26,len*0.13,DARKER);
    for(let k=0;k<3;k++) t.cyl(-L*0.66+k*len*0.09,h*0.98,sd*W*0.54,0.20,0.17,len*0.10,6,DARKER);
  }
  t.bevelBox(-L*0.88,h*0.18,0,len*0.28,h*0.66,wid*0.60,0.22,MET_D);
  t.box(-L*0.88,h*0.86,0,len*0.22,0.16,wid*0.44,TEAM_T);
  tfcHatch(t,-L*0.24,h*1.00,(cupZ==null?-W*0.42:cupZ),Math.min(0.95,wid*0.15),MET_D);
  return t;
}

/* One SERVO leg. Everything is painted SERVO because the vertex stage swings

   SERVO geometry below the hip line through the gait — mixing ordinary armour

   into a limb leaves those plates standing where the foot used to be. S scales

   the whole limb so the same anatomy can serve more than one machine. */
function tfcLeg(m,z,S){
  m.bevelBox(-0.40*S,0,z,4.30*S,0.95*S,2.75*S,0.32*S,SERVO);          // planted foot
  m.box(1.50*S,0.20*S,z,1.10*S,0.52*S,2.25*S,SERVO);                  // toe cleat
  m.box(-1.95*S,0.18*S,z,0.75*S,0.48*S,2.00*S,SERVO);                 // heel
  m.cyl(-0.35*S,0.95*S,z,0.88*S,0.74*S,0.62*S,8,SERVO);               // ankle bearing
  m.extrude(-0.20*S,1.52*S,z,[[-1.15*S,-1.10*S],[0.85*S,-1.10*S],[1.20*S,-0.46*S],
            [1.20*S,0.46*S],[0.85*S,1.10*S],[-1.15*S,1.10*S]],3.00*S,SERVO);
  m.bevelBox(-0.05*S,2.05*S,z,1.90*S,2.00*S,2.60*S,0.26*S,SERVO);     // shin armour
  /* Shin ram built by hand rather than through hydraulic(): that helper hard-

     codes MET_L for the bright ram and DARKER for the gland nut whatever colour

     it is handed, so two thirds of every "SERVO" leg ram in this engine is NOT

     SERVO and stands still while the limb it belongs to swings away from it.

     Same three components, all of them actually painted as a leg. */
  m.cyl(-1.20*S,1.60*S,z,0.24*S,0.23*S,1.74*S,7,SERVO);               // ram body
  m.cyl(-1.20*S,3.34*S,z,0.14*S,0.14*S,1.30*S,7,SERVO);               // ram rod
  m.cyl(-1.20*S,3.20*S,z,0.29*S,0.29*S,0.30*S,7,SERVO);               // gland nut
  m.cyl(0.15*S,4.55*S,z,1.34*S,1.18*S,1.10*S,8,SERVO);                // knee bearing
  m.bevelBox(0.10*S,5.30*S,z,3.00*S,1.45*S,2.85*S,0.32*S,SERVO);      // thigh armour
  m.extrude(-0.10*S,6.15*S,z,[[-1.05*S,-1.05*S],[0.95*S,-1.05*S],[1.25*S,-0.48*S],
            [1.25*S,0.48*S],[0.95*S,1.05*S],[-1.05*S,1.05*S]],2.05*S,SERVO);
  return m;
}

/* A cylinder lying ACROSS the vehicle, on the Z axis.



   THE OTHER ENGINE TRAP, and it is worth writing down beside the wedge one.

   MeshBuilder.cyl()'s ninth argument is `cap`, not a yaw — cyl has no rotation

   parameter at all. Every `cyl(..., Math.PI/2)` in this library therefore

   builds a cylinder STANDING ON END with its end caps switched on, which is

   why wheelAsm()'s tyres, runningGear()'s hubs, the Scorcher's fuel drums and

   the Warden's hose reel are all vertical discs rather than anything lying

   across a hull — and, like the wedge, it was never caught because a circle

   still looks like a circle in a screenshot. cylX() already solves this for the

   +X axis by rotating a scratch mesh onto it; this is the same move for +Z.

   Local +Y becomes +Z, so the cylinder runs from z to z+len. */
function tfcCylZ(m,x,y,z,len,r1,r2,seg,col,cap){
  const mm=MB(); mm.m=m.m; mm.tm=m.tm;
  mm.cyl(0,0,0,r1,r2,len,seg,col,cap);
  for(let i=0;i<mm.v.length;i+=VFLOATS){
    const px=mm.v[i], py=mm.v[i+1], pz=mm.v[i+2];
    const nx=mm.v[i+3], ny=mm.v[i+4], nz=mm.v[i+5];
    mm.v[i]=px+x; mm.v[i+1]=-pz+y; mm.v[i+2]=py+z;      // rotate +90 about X
    mm.v[i+3]=nx; mm.v[i+4]=-nz; mm.v[i+5]=ny;
  }
  const o=m.n;
  for(let i=0;i<mm.v.length;i++) m.v.push(mm.v[i]);
  for(const ix of mm.i) m.i.push(ix+o);
  m.n+=mm.n; m.m=mm.m; m.tm=mm.tm;
  return m;
}

/* Diagonal member. Every primitive in this library is axis-aligned, which is

   why every crane, jib and outrigger in the roster is a staircase of boxes —

   the renders of the first pass made that unmistakable on the engineer, whose

   "telescopic boom" was three blocks with a yaw on them (a yaw rotates in the

   GROUND plane, so it swung the boom sideways instead of raising it).

   boneSeg() already builds a tapered tube between two arbitrary points for the

   Brood limbs; it writes quads directly, so it does not pass through the

   colour->material wrapper and the material has to be set by hand. */
function tfcStrut(m,a,b,r,col){
  m.m=COL_MAT.get(col)||0; m.tm=COL_TEAM.has(col)?1:0;
  boneSeg(m,a,b,r,r*0.88,6,col);
  return m;
}

/* Engine deck. A recessed dark grille panel at the rear of every hull. The

   renders of the first pass were the argument for this: with the whole deck in

   one mid grey the hull read as an untextured slab from directly above, which

   is the one angle this camera always has. A dark inset behind a bright rail is

   the cheapest value break there is, and it also says where the engine is. */
function tfcDeckGear(m,x,y,len,wid,trim){
  deckCrown(m,x,y,0,len*0.88,wid*0.80,MET_D,trim||TEAM_T);
  insetPanel(m,x-len*0.30,y+0.02,0,len*0.26,wid*0.44,0.22,DARK,0);
  for(let k=0;k<3;k++) m.box(x-len*0.30,y+0.16,-wid*0.16+k*wid*0.16,len*0.22,0.13,0.13,MET_L);
  return m;
}

/* Strapped stowage: a rolled tarp with two straps over it and a locker beside

   it. The base roster used RUST for stowage on every vehicle, which reads as

   neglect; Frontline kit is canvas over painted steel. */
function tfcStow(m,x,y,z,len,wid,seed){
  m.bevelBox(x,y,z,len*0.52,wid*0.62,wid*0.86,wid*0.24,CONC_D);
  for(const f of [-1,1]) m.box(x+f*len*0.13,y+wid*0.30,z,0.12,wid*0.34,wid*0.92,MET_D);
  m.greeble(x+len*0.36,y,z,len*0.46,wid*0.80,wid*0.46,3,MET_D,0,seed||7);
  return m;
}

/* Hazard chevrons across a service vehicle's deck. Unarmed machines need a

   read of their own from directly above, and this is the one the eye already

   knows from every works vehicle it has ever seen. */
function tfcChevrons(m,x,y,z,n,span,wid){
  /* Value contrast, not emission. Painting alternate bars LAMP made three

     unarmed vehicles glow in the dark from their deck markings and pushed them

     past 10% emissive verts; bright trim against a dark reveal reads as a

     hazard stripe in daylight and costs nothing at night. */
  for(let k=0;k<n;k++)
    m.box(x-span*0.5+k*(span/Math.max(1,n-1)),y,z,0.40,0.13,wid,k&1?DARKER:MET_L,0.44);
  return m;
}

/* The shared Frontline soldier. The production tab says INFANTRY and the

   player expects a person: helmet with a visor band, armoured torso over a

   narrower waist, pauldrons carrying the livery at the widest point, webbing

   pouches on the belt and a backpack. Legs are SERVO so the walk shader

   marches them with the same system that walks the mechs; arms and weapon stay

   rigid so the gun does not wobble. Returns the joint heights the weapon

   builders hang hardware off. */
function tfcSoldier(m,o){
  o=o||{};
  const H=o.h||8.6, W=o.w||1.0;
  const hip=H*0.46, sh=H*0.74, hd=H*0.86;
  for(const sd of [-1,1]){
    m.bevelBox(0.25,0,sd*0.62*W,1.45*W,0.52,0.92*W,0.18,DARK);            // boot
    m.bevelBox(0.05,0.52,sd*0.62*W,0.78*W,hip*0.42,0.78*W,0.17,SERVO);    // shin
    tfcCylZ(m,0.05,hip*0.50,sd*0.62*W-0.24,0.48,0.44*W,0.44*W,6,MET_D,true); // knee
    m.bevelBox(0.0,hip*0.55,sd*0.62*W,0.90*W,hip*0.48,0.90*W,0.19,SERVO); // thigh
    m.box(0.0,hip*1.02,sd*0.62*W,0.98*W,0.46,0.98*W,MET_D);               // hip guard
  }
  m.bevelBox(0,hip,0,1.28*W,(sh-hip)*0.34,1.46*W,0.20,DARK);              // waist
  m.bevelBox(0.08,hip+(sh-hip)*0.28,0,1.86*W,(sh-hip)*0.76,2.05*W,0.28,o.chest||MET);
  /* Raked breastplate — the armour angle is what stops a torso reading as a

     box, and it is the same move as the vehicles' glacis at 1/20th the scale. */
  glacisX(m,0.62*W,hip+(sh-hip)*0.34,0,0.80*W,(sh-hip)*0.50,1.85*W,o.chest||MET_L);
  m.box(-0.55*W,hip+(sh-hip)*0.5,0,0.95*W,(sh-hip)*0.60,1.80*W,o.pack||MET_D);  // backpack
  for(const sd of [-1,1]){
    m.bevelBox(0,sh-0.24,sd*1.22*W,1.30*W,0.58,0.92*W,0.28,TEAM_A);       // pauldron livery
    m.bevelBox(0.14,sh-1.12,sd*1.28*W,0.70*W,1.05,0.60*W,0.19,SERVO);     // upper arm
    m.bevelBox(0.82*W,sh-1.52,sd*1.02*W,1.26*W,0.56,0.52*W,0.17,DARK);    // forearm
    m.box(0.30,hip+0.10,sd*0.96*W,0.62*W,0.52,0.42*W,MET_D);              // webbing pouch
  }
  m.bevelBox(0.08,sh+0.14,0,1.12*W,(hd-sh)*0.9,1.22*W,0.32,o.helm||MET_L);// helmet
  m.box(0.60*W,sh+0.40,0,0.30,0.40,0.92*W,GLASS);                         // visor band
  m.box(0.10,hd+0.02,0,0.85*W,0.16,1.00*W,TEAM_T);                        // helmet band livery
  if(o.antenna) m.cyl(-0.34*W,hd,0.48*W,0.05,0.03,0.95,5,DARKER);
  return {hip,sh,hd};
}

/* ---------------------------------------------------------------------------

   0 — STRIKER.  Line rifleman, hp 40 / dmg 5.4 / rng 62.

   The cheapest thing the Command fields and the one it fields most of, so it

   is also the unit that sets the scale of everything else on the board: a tank

   only reads as big when a person is standing next to it.

   --------------------------------------------------------------------------- */
function mdlTfcTrooper(){
  const m=MB();
  const b=tfcSoldier(m,{h:8.6,w:1.0,antenna:1});
  /* Service rifle held across the body and pointed +X, so the soldier faces

     where he shoots: receiver, barrel, muzzle brake, magazine, optic. */
  const gy=b.hip+1.55;
  m.bevelBox(1.30,gy,0.15,2.50,0.52,0.42,0.13,MET_D);
  m.box(1.05,gy+0.44,0.15,1.20,0.26,0.30,TWR_MACH);                       // optic
  cylX(m,2.55,gy+0.10,0.15,1.70,0.14,0.12,6,DARKER,false);                // barrel
  m.box(4.20,gy-0.12,0.15,0.34,0.42,0.44,TWR_MACH);                       // muzzle brake
  tubeX(m,4.28,gy+0.10,0.15,0.24,0.18,0.09,6,TWR_BORE);                   // bore
  m.box(1.55,gy-0.50,0.15,0.36,0.58,0.32,MET_D);                          // magazine
  m.box(0.24,gy+0.04,0.15,0.66,0.38,0.32,DARK);                           // stock
  m.box(-0.58,b.sh+0.36,0,0.62,0.18,0.30,ENERGY);                         // radio lamp
  return {hull:m.build(),tur:null,s:0.92};
}

/* ---------------------------------------------------------------------------

   9 — PYRO.  Flame trooper, hp 240 / dmg 11 / rng 58.

   Same species, bulked out: a flame suit is sealed and heavy, the helmet is a

   full hood, and the backpack is the fuel plant. The pilot light is the tell

   at range and the only emissive on the model.

   --------------------------------------------------------------------------- */
function mdlTfcFlamer(){
  const m=MB();
  const b=tfcSoldier(m,{h:8.2,w:1.22,chest:MET_D,helm:MET_D,pack:DARK});
  for(const sd of [-1,1]){
    m.cyl(-1.05,b.hip+0.35,sd*0.60,0.50,0.50,3.30,7,TWR_MACH);            // fuel bottle
    m.cyl(-1.05,b.hip+3.65,sd*0.60,0.50,0.30,0.46,7,MET_D);               // valve cap
    m.cyl(-1.05,b.hip+1.85,sd*0.60,0.55,0.55,0.24,7,MET_L);               // pressure band
  }
  m.box(-1.05,b.sh+0.30,0,1.30,0.20,1.90,MET_L);                          // bottle yoke
  cylX(m,-0.70,b.sh-0.42,0,1.85,0.15,0.13,5,DARKER,false);                // feed hose
  /* Projector: short, fat, shrouded, with the pilot flame at the lip. */
  const gy=b.hip+1.45;
  m.bevelBox(1.42,gy,0.15,1.95,0.66,0.58,0.15,MET_D);
  cylX(m,2.40,gy+0.08,0.15,1.05,0.32,0.36,7,TWR_MACH,false);
  tubeX(m,3.42,gy+0.08,0.15,0.55,0.40,0.20,7,TWR_BORE);
  cylX(m,3.80,gy+0.08,0.15,0.46,0.11,0.09,5,HOT,true);                    // pilot light
  m.box(0.94,gy-0.46,0.15,0.46,0.48,0.36,DARK);                           // grip
  return {hull:m.build(),tur:null,s:0.95};
}

/* ---------------------------------------------------------------------------

   2 — GOLIATH.  Assault walker, hp 450 / dmg 42 / rng 104, aoe 10.

   TYPES has carried legs:1 on this slot since it was written and the game's

   own intel copy calls it an assault walker; the old model was a tracked tank,

   so the flag animated nothing and the army had no legged silhouette at all

   between the Striker and the TITAN. Rebuilt as what the data already said it

   was — and that also solves the readability problem, because at 60 pixels a

   walker is the only thing in a Frontline column that is not a tracked box.

   Legs are SERVO and everything above the hip is not.

   --------------------------------------------------------------------------- */
function mdlTfcWalker(){
  const m=MB();
  /* STANCE. The first pass put the legs at z=+-4.1 under a torso +-4.3 wide

     and shoulders wider still, so from every angle inside the camera's pitch

     band the walker read as a turret on a plinth — the legs were never visible

     at all. Standing them outboard of the hull is the entire difference

     between a walker and a tank with a tall superstructure. */
  for(const sd of [-1,1]){
    tfcLeg(m,sd*5.45,1.00);
    /* Knee shroud and shin skirt are painted SERVO like the limb they belong

       to. Armour left in an ordinary material stands where the foot used to be

       once the gait swings the leg out from under it. */
    m.bevelBox(0.10,4.35,sd*5.45,2.90,0.70,3.05,0.24,SERVO);              // knee shroud
    armorPlate(m,-0.20,2.20,sd*7.05,4.40,1.50,0.40,0,SERVO,0);            // shin skirt
    tfcStrut(m,[-1.10,8.10,sd*3.40],[0.10,6.30,sd*5.45],0.34,MET_D);      // hip brace
  }
  /* Hip bridge — the legs hang off this, so it is visibly a single casting. */
  m.extrude(0,8.20,0,[[-4.10,-4.60],[2.40,-5.10],[4.35,-2.70],[4.35,2.70],
            [2.40,5.10],[-4.10,4.60]],2.10,MET);
  m.extrude(0.10,10.30,0,[[-3.50,-4.00],[2.10,-4.40],[3.75,-2.30],[3.75,2.30],
            [2.10,4.40],[-3.50,4.00]],0.70,MET_L);
  for(const sd of [-1,1]) m.box(-0.40,11.00,sd*4.05,5.20,0.24,1.30,sd>0?TEAM_A:TEAM_B);
  /* Torso — lower casemate, stepped upper plate, raked chest. */
  m.extrude(0,11.00,0,[[-4.00,-3.75],[2.30,-4.20],[4.30,-2.20],[4.30,2.20],
            [2.30,4.20],[-4.00,3.75]],2.90,MET);
  m.extrude(0.15,13.90,0,[[-3.30,-3.05],[2.00,-3.40],[3.55,-1.75],[3.55,1.75],
            [2.00,3.40],[-3.30,3.05]],0.85,MET_L);
  tfcProw(m,4.40,11.20,0,3.10,2.70,6.60);                                 // chest glacis
  tfcSeam(m,-1.20,14.76,0,3.20,4.00,MET_L,0);
  m.box(2.20,14.76,0,1.80,0.22,4.60,TEAM_T);                              // collar band livery
  for(const sd of [-1,1]){
    m.bevelBox(0.30,11.60,sd*4.35,4.20,2.40,1.20,0.28,MET_D);             // shoulder plate
    m.box(0.30,14.02,sd*4.40,3.80,0.26,1.00,sd>0?TEAM_A:TEAM_B);          // shoulder livery
    m.bevelBox(-0.60,11.30,sd*4.00,5.60,2.10,0.44,0.16,sd>0?TEAM_A:TEAM_B);// flank livery
    m.box(-4.15,11.45,sd*2.85,1.45,2.50,0.90,DARK);                       // torso grille
    for(let k=0;k<3;k++) m.box(-3.95,11.75+k*0.74,sd*3.32,1.00,0.26,0.26,MET_L);
    m.cyl(4.30,11.75,sd*3.10,0.28,0.24,1.05,6,MET_L);                     // lift point
  }
  ventBank(m,-2.90,14.76,0,2.70,5.00,5,MET_D,0);
  m.bevelBox(-4.25,11.30,0,2.20,3.20,5.80,0.42,MET_D);                    // powerplant
  tfcExhaust(m,-5.30,12.90,0,2,0.52);
  m.greeble(-4.25,14.50,0,1.80,4.40,0.50,4,TWR_MACH,0,71);
  sensorMast(m,-1.95,14.80,2.20,2.10,MET_L);
  tfcStow(m,-2.40,14.80,-2.60,3.00,1.10,31);

  const t=MB();
  tfcRing(t,-0.70,3.30,1.05);
  tfcTurret(t,7.60,6.40,3.10,-2.60);
  t.box(3.10,0.95,0,1.10,2.20,3.00,TWR_MACH);                             // mantlet
  gunX(t,3.20,1.85,7.60,0.68,TWR_MACH);                                   // splash cannon
  cylX(t,2.45,1.85,0,2.60,1.12,0.92,10,MET_D,false);                      // recoil sleeve
  for(const x of [5.10,7.90]) ringX(t,x,1.85,0,0.76,1.02,9,x>7?HOT:MET_L);
  cylX(t,2.70,0.72,-2.15,3.10,0.26,0.22,6,TWR_MACH,false);                // coaxial
  tubeX(t,5.85,0.72,-2.15,0.40,0.34,0.16,7,TWR_BORE);
  tfcStow(t,-3.10,3.20,0,2.60,1.00,17);
  return {hull:m.build(),tur:t.build(),s:0.86,turH:14.75,muzzle:10.8};
}

/* ---------------------------------------------------------------------------

   3 — THUMPER.  Field artillery, hp 135 / dmg 60 / rng 265, minRng 80.

   Thin-skinned by design: a light track run, a shallow hull and every gram of

   the mass in the tube and the recoil gear. The DEPLOYED SPADES at the back

   are the plan-view tell that separates artillery from a gun tank, and the

   mount is open-topped — a crew serving a piece, not a sealed turret.

   --------------------------------------------------------------------------- */
function mdlTfcArty(){
  const m=MB();
  tfcTracks(m,12.6,3.00,2.60,3.45,5,0);
  const deck=tfcHull(m,-0.20,2.10,12.2,7.20,1.30,0.80);                   // deck ~4.20
  tfcProw(m,5.30,2.10,0,2.60,1.90,5.80);
  for(const sd of [-1,1]){
    /* Recoil spade: a plate on a ram, dug in behind the vehicle. Carried

       OUTBOARD of the track run so it breaks the plan-view outline — the first

       pass tucked them between the tracks where the overhead camera could not

       see them at all, which threw away the entire artillery tell. */
    m.box(-6.60,1.10,sd*4.60,0.70,3.00,2.40,MET_D);
    m.wedge(-7.25,0.05,sd*4.60,1.80,1.10,2.30,MET_L,Math.PI/2,true);
    hydraulic(m,-5.40,2.20,sd*4.10,2.20,0.28,TWR_MACH,0);
    m.box(-6.62,3.00,sd*4.60,0.34,0.22,1.80,sd>0?TEAM_A:TEAM_B);
    kitBox(m,2.30,deck,sd*2.55,2.30,0.90,1.30,MET_D,0);                   // shell lockers
    m.box(-1.30,deck+0.02,sd*3.05,4.60,0.16,0.70,MET_L);                  // deck walkway
  }
  tfcDeckGear(m,-0.20,deck,12.2,7.20);
  tfcExhaust(m,-5.10,3.10,1.90,1,0.44);
  tfcSeam(m,1.60,deck+0.02,0,3.00,3.00,MET_L,0);
  tfcHatch(m,3.10,deck,-2.10,0.72,MET_D);
  sensorMast(m,-4.30,deck,1.90,2.20,MET_L);

  const t=MB();
  tfcRing(t,-0.60,2.55,0.85);
  /* OPEN MOUNT: a shield plate and two side cheeks rather than a closed

     turret. Artillery is served, not driven. */
  t.extrude(0,0,0,[[-3.20,-2.70],[1.20,-3.00],[2.60,-1.70],[2.60,1.70],
            [1.20,3.00],[-3.20,2.70]],1.90,MET);
  t.bevelBox(1.90,1.90,0,1.90,1.70,5.10,0.34,MET_L);                      // gun shield
  for(const sd of [-1,1]){
    t.bevelBox(-0.90,1.90,sd*2.45,4.20,1.35,0.42,0.16,sd>0?TEAM_A:TEAM_B);// cheek livery
    t.cyl(1.30,1.00,sd*1.70,1.20,1.10,0.80,8,MET_D);                      // trunnion
    hydraulic(t,-1.40,0.90,sd*1.95,2.40,0.30,TWR_MACH,0);                 // elevation ram
    t.box(-2.60,1.95,sd*1.60,1.60,0.90,1.00,DARKER);                      // ready rack
  }
  gunX(t,1.60,2.05,13.40,0.56,TWR_MACH);                                  // long tube
  cylX(t,1.30,2.05,0,2.90,1.02,0.86,9,MET_D,false);                       // recuperator
  for(const x of [6.40,10.60]) ringX(t,x,2.05,0,0.60,0.84,9,MET_L);
  t.box(-3.00,1.90,0,1.60,1.10,3.40,TWR_MACH);                            // breech block
  t.box(-3.00,3.00,0,1.20,0.16,2.40,TEAM_T);
  return {hull:m.build(),tur:t.build(),s:1.0,turH:4.30,muzzle:15.0};
}

/* ---------------------------------------------------------------------------

   16 — BOMBARD.  Siege platform, hp 400 / dmg 95 / rng 400, minRng 100.

   The longest reach in the game, and the silhouette has to say so from across

   the map: ONE very long thin tube on a BRACED carriage. The four deployed

   outrigger jacks are the whole read — nothing else in the roster plants

   itself on the ground to fire, and a player who sees them knows immediately

   that the thing shooting at his base is 400 units away.

   --------------------------------------------------------------------------- */
function mdlTfcSiege(){
  const m=MB();
  tfcTracks(m,14.2,3.60,2.90,4.05,6,1);
  const deck=tfcHull(m,-0.30,2.40,13.6,8.60,1.60,0.95);                   // deck ~4.95
  tfcProw(m,5.90,2.40,0,2.90,2.10,6.90);
  for(const sd of [-1,1]){
    /* OUTRIGGER JACKS, front and rear, DEPLOYED and reaching clear of the

       track run. The first pass had them at z=5.4 with the skirts already out

       to 6.0, so from above the one detail that says "this thing plants itself

       to shoot 400 units" was hidden inside its own silhouette. */
    for(const fx of [4.30,-5.60]){
      m.box(fx,3.05,sd*5.60,2.00,0.70,3.00,MET_D);
      m.cyl(fx+(fx>0?0.60:-0.60),0.90,sd*7.30,0.46,0.42,2.40,7,MET_L);
      m.bevelBox(fx+(fx>0?0.60:-0.60),0,sd*7.30,2.40,0.90,2.40,0.34,MET_D);
      m.box(fx+(fx>0?0.60:-0.60),0.90,sd*7.30,1.60,0.18,1.60,sd>0?TEAM_A:TEAM_B);
    }
    kitBox(m,-2.20,deck,sd*3.00,2.80,1.00,1.60,MET_D,0);
    m.box(-0.30,deck+0.02,sd*3.60,6.20,0.16,0.72,MET_L);
  }
  tfcDeckGear(m,-0.30,deck,13.6,8.60);
  tfcExhaust(m,-6.10,3.50,2.20,1,0.50);
  tfcSeam(m,2.40,deck+0.02,0,3.00,3.40,MET_L,0);
  tfcHatch(m,3.40,deck,-2.40,0.78,MET_D);
  sensorMast(m,-5.10,deck,2.20,2.60,MET_L);

  const t=MB();
  tfcRing(t,-0.80,3.05,1.00);
  t.extrude(0,0,0,[[-3.60,-3.10],[1.30,-3.40],[2.80,-1.90],[2.80,1.90],
            [1.30,3.40],[-3.60,3.10]],2.30,MET);
  t.bevelBox(-2.90,2.30,0,2.40,0.90,5.20,0.30,MET_L);
  t.bevelBox(2.00,2.30,0,2.00,1.90,5.60,0.38,MET_L);                      // shield plate
  for(const sd of [-1,1]){
    t.bevelBox(-1.10,2.30,sd*2.80,4.60,1.50,0.44,0.18,sd>0?TEAM_A:TEAM_B);
    t.cyl(1.20,1.10,sd*2.00,1.34,1.20,0.90,8,MET_D);                      // trunnion
    hydraulic(t,-1.70,1.00,sd*2.30,2.70,0.32,TWR_MACH,0);
    cylX(t,0.60,2.55,sd*1.05,6.40,0.32,0.28,7,TWR_MACH,false);            // recoil cylinder
  }
  /* THE TUBE. Thin for its length on purpose: a siege mortar reads as a pipe,

     not as a tank gun, and the two collars along it give the eye something to

     measure that length against. */
  gunX(t,1.30,2.10,17.20,0.62,TWR_MACH);
  cylX(t,1.10,2.10,0,3.10,1.14,0.94,10,MET_D,false);
  for(const x of [7.20,12.60,16.20]) ringX(t,x,2.10,0,0.66,0.92,9,x>16?HOT:MET_L);
  t.box(-3.40,2.10,0,1.80,1.30,3.80,TWR_MACH);                            // breech
  t.box(-3.40,3.40,0,1.30,0.18,2.60,TEAM_T);
  return {hull:m.build(),tur:t.build(),s:1.0,turH:5.05,muzzle:18.5};
}

/* ---------------------------------------------------------------------------

   10 — VULTURE.  Anti-air, hp 170 / dmg 52 / rng 172, tg:'air'.

   It cannot shoot ground AT ALL, so the mount has to be unmistakable from

   above or the player wastes it: twin high-elevation autocannon with visible

   ammunition feeds, and a flat PLANAR RADAR ARRAY rather than a dish, because

   the Frontline Command's whole pitch is that its technology is current.

   Tier 1, so this is on the strictest triangle budget in the file.

   --------------------------------------------------------------------------- */
function mdlTfcAA(){
  const m=MB();
  tfcTracks(m,11.0,2.80,2.40,3.20,5,0);
  const deck=tfcHull(m,-0.20,2.00,10.8,6.60,1.20,0.70);                   // deck ~3.90
  tfcProw(m,4.70,2.00,0,2.30,1.70,5.30);
  kitBox(m,-2.60,deck,2.30,2.20,0.85,1.20,MET_D,0);
  tfcDeckGear(m,-0.20,deck,10.8,6.60);
  tfcExhaust(m,-4.60,2.80,1.70,1,0.40);

  const t=MB();
  tfcRing(t,-0.40,2.30,0.80);
  t.extrude(0,0,0,[[-2.70,-2.30],[1.10,-2.60],[2.40,-1.40],[2.40,1.40],
            [1.10,2.60],[-2.70,2.30]],1.70,MET);
  t.extrude(0,1.70,0,[[-2.20,-1.85],[0.90,-2.10],[1.95,-1.15],[1.95,1.15],
            [0.90,2.10],[-2.20,1.85]],0.55,MET_L);
  for(const sd of [-1,1]){
    /* Barrels PITCHED UP through tfcStrut, because every barrel primitive in

       this library runs along an axis and a level barrel reads as a gun tank —

       which gets this unit driven at armour it literally cannot hurt. Elevation

       is the entire silhouette of an anti-air mount and nothing else in the

       roster has it. */
    const z=sd*1.30;
    tfcStrut(t,[0.70,1.55,z],[5.30,4.30,z],0.30,TWR_MACH);                // barrel
    tfcStrut(t,[4.60,3.88,z],[5.75,4.57,z],0.44,MET_L);                   // flash hider
    tfcStrut(t,[5.62,4.49,z],[5.98,4.71,z],0.21,TWR_BORE);                // bore
    t.box(0.10,1.55,sd*2.20,1.90,1.00,0.90,DARKER);                       // ammunition feed
    t.box(-0.60,2.32,sd*2.15,2.60,0.20,0.52,sd>0?TEAM_A:TEAM_B);          // cheek livery
    t.box(-1.30,1.10,sd*1.60,0.50,1.30,0.44,TWR_MACH);                    // elevation ram
  }
  t.bevelBox(1.10,1.55,0,1.60,1.60,3.30,0.30,TWR_MACH);                   // trunnion cradle
  /* PLANAR ARRAY: a flat panel on a mast. Reads as radar from any angle and

     costs a fraction of a dish — and a dish is what the old model used, which

     is also what the Dominion's Warden uses, so it was never diagnostic. */
  t.cyl(-2.30,2.25,0,0.30,0.26,1.30,6,MET_D);
  t.bevelBox(-2.30,3.55,0,0.36,2.70,3.90,0.18,MET_L);
  t.box(-2.12,3.70,0,0.16,2.30,3.40,TEAM_T);
  t.box(-2.30,6.25,0,0.26,0.34,0.26,LAMP);
  return {hull:m.build(),tur:t.build(),s:1.0,turH:4.00,muzzle:6.0,muzzleZ:1.30};
}

/* ---------------------------------------------------------------------------

   6 — LONGBOW / 22 — LANCER.  Precision long guns.

   Longbow is a 205-range beam at 110 hull; Lancer is a 230-range gauss lance

   at 190 hull and 150 damage a shot. Both are the same doctrinal object: a

   light, fragile carrier whose entire mass is ONE very long weapon, with

   capacitor banks feeding it and stabiliser jacks under it. They share a

   chassis for the same reason the Dominion's two long guns do — the roles are

   the same shape — and the sim scales them apart at draw time.

   --------------------------------------------------------------------------- */
function mdlTfcLance(){
  const m=MB();
  tfcTracks(m,11.6,2.80,2.45,3.30,5,0);
  const deck=tfcHull(m,-0.20,2.05,11.4,6.80,1.25,0.75);                   // deck ~4.05
  tfcProw(m,5.00,2.05,0,2.40,1.75,5.40);
  for(const sd of [-1,1]){
    /* Capacitor bank: the sniper role has to stay readable when the weapon is

       tracking away from the camera, and this is the part that says it. */
    m.cyl(-3.30,deck,sd*2.05,0.68,0.68,2.10,8,TWR_MACH);
    m.ring(-3.30,deck+2.10,sd*2.05,0.70,0.92,8,ENERGY);
    m.box(-3.30,deck+0.90,sd*2.05,0.32,0.30,1.55,TEAM_T);
    m.box(-5.20,1.10,sd*3.30,1.20,1.20,0.90,MET_D);                       // stabiliser jack
    m.cyl(-5.20,0.30,sd*3.30,0.60,0.52,0.85,7,MET_L);
    kitBox(m,1.90,deck,sd*2.45,2.00,0.85,1.20,MET_D,0);
  }
  tfcDeckGear(m,-0.20,deck,11.4,6.80);
  tfcExhaust(m,-4.90,2.90,1.80,1,0.42);
  tfcHatch(m,3.00,deck,-1.95,0.70,MET_D);
  sensorMast(m,-4.20,deck,1.90,2.10,MET_L);

  const t=MB();
  tfcRing(t,-0.50,2.40,0.85);
  t.extrude(0,0,0,[[-3.00,-2.30],[1.10,-2.55],[2.30,-1.30],[2.30,1.30],
            [1.10,2.55],[-3.00,2.30]],2.00,MET);
  t.bevelBox(-2.30,2.00,0,2.00,0.85,4.10,0.28,MET_L);
  for(const sd of [-1,1]){
    t.bevelBox(-0.80,1.70,sd*2.10,3.60,1.10,0.40,0.15,sd>0?TEAM_A:TEAM_B);
    hydraulic(t,-1.60,0.60,sd*1.55,1.90,0.24,TWR_MACH,0);
  }
  /* THE LANCE. A slim rail with four accelerator collars stepping down its

     length — the taper is what makes 12 units of barrel read as a weapon

     rather than as a pipe someone left on the deck. */
  t.bevelBox(1.80,2.00,0,3.20,1.10,2.00,0.28,MET_D);                      // breech housing
  cylX(t,2.60,2.45,0,11.60,0.44,0.34,8,TWR_MACH,false);
  for(let k=0;k<4;k++) ringX(t,4.10+k*2.55,2.45,0,0.48,0.78-k*0.05,9,k===3?ENERGY:MET_L);
  tubeX(t,14.10,2.45,0,0.52,0.40,0.19,8,TWR_BORE);
  for(const sd of [-1,1]) cylX(t,2.90,1.55,sd*0.95,8.20,0.20,0.17,6,MET_D,false);  // rails
  t.box(-2.60,2.90,0,1.30,0.18,2.30,TEAM_T);
  return {hull:m.build(),tur:t.build(),s:1.0,turH:4.15,muzzle:14.6};
}

/* ---------------------------------------------------------------------------

   7 — HORNET / 20 — REAPER / 21 — CINDER / 27 — HARBINGER.

   Four slots, one doctrinal object: a BOX OF TUBES on an elevating cradle.

   Ranges 175 / 120 / 96 / 210 and splash 24 / 52 / 46 / 60 — every one of them

   throws area fire out of open tubes, and the sim already tells them apart on

   screen because `size` runs 16 / 19 / 17 / 24, so the Harbinger draws half

   again as large as the Hornet from the same mesh.

   The cradle elevates and the tubes are genuinely open: a closed box reads as

   cargo, and this roster has already made that mistake once.

   --------------------------------------------------------------------------- */
function mdlTfcLauncher(){
  const m=MB();
  tfcTracks(m,12.0,3.00,2.55,3.40,5,1);
  const deck=tfcHull(m,-0.20,2.10,11.8,7.60,1.35,0.80);                   // deck ~4.25
  tfcProw(m,5.10,2.10,0,2.50,1.85,6.10);
  for(const sd of [-1,1]){
    kitBox(m,-3.20,deck,sd*2.70,2.60,0.95,1.40,MET_D,0);                  // reload lockers
    m.box(1.60,deck+0.02,sd*3.20,4.40,0.16,0.70,MET_L);                   // deck walkway
    m.cyl(4.60,deck-0.20,sd*2.60,0.28,0.24,1.00,6,MET_L);                 // tie-down
  }
  tfcDeckGear(m,-0.20,deck,11.8,7.60);
  tfcExhaust(m,-5.20,3.00,1.95,1,0.44);
  tfcHatch(m,3.20,deck,-2.20,0.72,MET_D);
  sensorMast(m,-4.40,deck,2.00,2.20,MET_L);
  tfcStow(m,-1.40,deck,3.10,2.80,1.00,53);

  const t=MB();
  tfcRing(t,-0.60,2.70,0.90);
  t.extrude(0,0,0,[[-3.10,-2.90],[1.20,-3.20],[2.50,-1.80],[2.50,1.80],
            [1.20,3.20],[-3.10,2.90]],1.60,MET);
  /* CRADLE: a trapezoid body with cheek plates at each end and rams under it,

     pitched up so the rack reads as elevated rather than as a lid. */
  t.extrude(-0.40,1.60,0,[[-3.00,-2.60],[2.40,-2.20],[3.00,-1.10],[3.00,1.10],
            [2.40,2.20],[-3.00,2.60]],1.90,MET_L);
  for(const sd of [-1,1]){
    t.bevelBox(-0.40,1.60,sd*2.55,5.60,2.00,0.50,0.20,sd>0?TEAM_A:TEAM_B);// cheek livery
    hydraulic(t,-2.20,0.70,sd*1.70,2.10,0.28,TWR_MACH,0);
  }
  t.box(-3.20,1.80,0,0.90,2.20,4.60,MET_D);                               // backplate
  /* Six open tubes in two rows, each with a muzzle collar. */
  for(let row=0;row<2;row++) for(let col=-1;col<=1;col++){
    const y=2.90+row*1.45, z=col*1.65;
    cylX(t,-2.60,y,z,5.60,0.62,0.58,7,TWR_MACH,false);
    ringX(t,2.90,y,z,0.60,0.80,8,MET_L);
    tubeX(t,2.94,y,z,0.62,0.66,0.35,8,TWR_BORE);
  }
  /* The rack's livery is a spine BETWEEN the two rows of tubes, not a lid over

     them. The first pass floated a wide plate above the rack and from directly

     overhead it covered every muzzle — a launcher with no visible tubes is a

     cargo truck. */
  t.box(-0.20,3.56,0,5.20,0.20,0.70,TEAM_T);
  for(const sd of [-1,1]) t.box(-0.20,3.56,sd*2.55,5.20,0.20,0.60,sd>0?TEAM_A:TEAM_B);
  t.bevelBox(-3.40,3.10,0,1.30,2.60,3.80,0.30,MET_D);                     // reload gantry
  return {hull:m.build(),tur:t.build(),s:1.0,turH:4.35,muzzle:3.6,muzzleZ:1.65};
}

/* ---------------------------------------------------------------------------

   18 — SCORCHER.  Armoured flame tank, hp 640 / dmg 13 / rng 80, aoe 38.

   The design language the players already know is "two big pressure vessels on

   the deck", and it stays — but Frontline plumbing is BANDED, VALVED and

   painted, not a pair of rusty drums. The heat shield over the projector and

   the flash at the lip are the only emissive on it.

   --------------------------------------------------------------------------- */
function mdlTfcFlameTank(){
  const m=MB();
  tfcTracks(m,14.0,3.60,2.95,3.95,6,1);
  const deck=tfcHull(m,-0.20,2.45,13.4,8.40,1.70,1.00);                   // deck ~5.15
  tfcProw(m,5.70,2.45,0,2.90,2.20,6.70);
  for(const sd of [-1,1]){
    /* Pressure vessel: a banded cylinder lying fore-aft with proper end caps

       and a valve stack, cradled on the deck. */
    cylX(m,-6.00,deck+1.70,sd*2.60,4.80,1.70,1.70,8,MET,false);           // vessel body
    cylX(m,-6.45,deck+1.70,sd*2.60,0.45,1.35,1.72,8,MET_D,true);          // rear dome
    cylX(m,-1.20,deck+1.70,sd*2.60,0.45,1.72,1.35,8,MET_D,true);          // front dome
    for(let k=0;k<2;k++) cylX(m,-4.90+k*2.20,deck+1.70,sd*2.60,0.26,1.76,1.76,8,MET_L,false);
    m.box(-3.60,deck+3.40,sd*2.60,3.60,0.20,0.85,sd>0?TEAM_A:TEAM_B);     // vessel livery
    m.bevelBox(-3.60,deck-0.10,sd*2.60,5.40,0.95,1.50,0.22,MET_D);        // cradle
    m.cyl(-1.20,deck+2.90,sd*2.60,0.34,0.28,0.90,6,TWR_MACH);             // valve
    cylX(m,-0.10,deck+1.10,sd*1.75,4.20,0.22,0.20,6,DARKER,false);        // feed line
  }
  tfcDeckGear(m,-0.20,deck,13.4,8.40);
  tfcExhaust(m,-6.90,3.60,2.40,1,0.48);
  tfcSeam(m,3.00,deck+0.02,0,2.60,3.00,MET_L,0);
  tfcHatch(m,3.60,deck,-2.50,0.78,MET_D);

  const t=MB();
  tfcRing(t,-0.60,2.85,0.95);
  tfcTurret(t,6.40,5.80,2.70,-2.20);
  t.box(2.70,0.80,0,1.10,1.90,2.80,TWR_MACH);                             // mantlet
  /* One heavy projector rather than two thin ones: the Scorcher is the tank

     version of the Pyro, so the nozzle should read at the same weight class. */
  cylX(t,2.20,1.55,0,4.60,0.86,0.76,9,TWR_MACH,false);
  for(const x of [3.40,5.40]) ringX(t,x,1.55,0,0.80,1.06,9,MET_L);
  cylX(t,6.70,1.55,0,1.30,1.06,1.40,9,MET_D,false);                       // flare
  tubeX(t,7.60,1.55,0,1.05,1.42,0.74,9,TWR_BORE);
  t.sphere(8.30,1.55,0,0.36,6,HOT,1,false);                               // pilot flame
  t.wedge(4.60,2.60,0,2.60,0.70,3.00,MET_L,Math.PI/2,true);               // heat shield
  return {hull:m.build(),tur:t.build(),s:1.0,turH:5.25,muzzle:8.3};
}

/* ---------------------------------------------------------------------------

   11 — BULWARK.  Mobile shield, hp 950, dmg 0 — UNARMED.

   TYPES says dmg:0, so this carries no barrel of any kind. It is the toughest

   thing in a Frontline column and the game's copy says it walks in front of

   the advance and absorbs the volley, so it is built as a heavy chassis whose

   whole deck is one enormous emitter: four folding masts around a generator

   drum with a low field disc between them. The disc lies flat, which is the

   only orientation an overhead camera can read.

   --------------------------------------------------------------------------- */
function mdlTfcShield(){
  const m=MB();
  tfcTracks(m,13.2,3.60,2.90,3.95,5,2);
  const deck=tfcHull(m,-0.20,2.40,12.8,8.80,1.90,1.10);                   // deck ~5.40
  tfcProw(m,5.50,2.40,0,2.80,2.30,7.00);
  for(const sd of [-1,1]){
    armorPlate(m,0.20,2.40,sd*5.10,10.40,2.00,0.46,0,MET_D,0);            // heavy skirt
    m.box(-0.30,deck+0.02,sd*3.60,7.20,0.18,0.74,sd>0?TEAM_A:TEAM_B);     // deck livery
    kitBox(m,-4.20,deck,sd*2.40,2.40,0.90,1.40,MET_D,0);
  }
  tfcDeckGear(m,-0.20,deck,12.8,8.80);
  tfcExhaust(m,-6.20,3.50,2.30,1,0.50);
  tfcHatch(m,3.60,deck,-2.60,0.80,MET_D);
  /* GENERATOR DRUM — chamfered, ribbed, obviously a machine and not a barrel. */
  chamferCyl(m,-0.40,deck,0,2.70,2.60,10,MET,MET_L,0);
  for(let k=0;k<6;k++){
    const a=k/6*TAU;
    m.box(-0.40+Math.cos(a)*2.70,deck+0.40,Math.sin(a)*2.70,0.90,1.80,0.34,MET_D,a);
  }
  m.cyl(-0.40,deck+2.60,0,2.20,1.60,0.60,10,TEAM_T);                      // drum cap livery
  /* FOUR FOLDING EMITTER MASTS, raked outward and standing PROUD of the field

     ring. The first pass buried them under a fat torus and the whole vehicle

     read as a doughnut: the mechanism has to be the thing you see and the field

     has to be the thin bright line it projects. */
  for(const sd of [-1,1]) for(const f of [-1,1]){
    const x=-0.40+f*3.40, z=sd*3.30;
    m.bevelBox(x,deck,z,1.60,0.95,1.60,0.28,MET_D);
    m.cyl(x,deck+0.95,z,0.48,0.38,3.80,7,TWR_MACH);
    m.box(x,deck+2.20,z,0.90,0.20,0.90,sd*f>0?TEAM_A:TEAM_B);             // mast collar
    m.cyl(x,deck+4.75,z,1.05,0.26,0.38,8,MET_L);                          // emitter dish
    m.cyl(x,deck+4.85,z,0.36,0.28,0.42,6,ENERGY);
  }
  /* The FIELD: one thin ring at mast-head height. A solid dome costs 112

     triangles and hides the vehicle under it. */
  m.ring(-0.40,deck+3.90,0,3.30,3.90,14,ENERGY);
  return {hull:m.build(),tur:null,s:1.0};
}

/* ---------------------------------------------------------------------------

   19 — CONSTRUCTOR.  Engineer, hp 220, dmg 0 — UNARMED.

   The only unarmed thing a player drives around a battle, so it has to read as

   PLANT rather than as a tank with the gun removed: a wheeled works chassis, a

   flat load bed with cargo strapped to it, a two-stage telescopic boom with a

   fabricator head, hazard chevrons and a rotating amber beacon.

   Kept WHEELED. TYPES carries legs:1 on this slot, but the design language the

   player already knows is a works truck, and wheels plus a SERVO walk cycle is

   the worst of both — the flag stays unused here exactly as it was.

   Tier 1, so it is on the 1,400 budget: the wheels are built from a tyre, a

   rim face and a hub rather than through wheelAsm(), which costs 264 apiece.

   --------------------------------------------------------------------------- */
/* ---------------------------------------------------------------------------

   23 — RESONATOR. Shield-piercing sonic armor, hp 340 / rng 130.

   This was the final ordinary Blue ground-combat slot still falling through

   to the shared roster. A long cannon would lie about the weapon: the unit

   projects a coupled pressure wave. Twin forward waveguides, three stepped

   tuning collars and a visible capacitor spine make the role readable without

   borrowing the Longbow's silhouette.

   --------------------------------------------------------------------------- */
function mdlTfcResonator(){
  const m=MB();
  tfcTracks(m,12.2,3.15,2.55,3.55,5,1);
  const deck=tfcHull(m,-0.35,2.15,11.8,7.35,1.55,0.95);                 // deck ~4.65
  tfcProw(m,5.05,2.15,0,2.55,1.95,5.80);
  tfcDeckGear(m,-1.20,deck,9.60,6.45);
  tfcExhaust(m,-5.50,3.00,2.00,1,0.42);
  for(const sd of [-1,1]){
    m.box(-0.55,deck+0.02,sd*3.05,6.70,0.17,0.62,sd>0?TEAM_A:TEAM_B);   // family livery
    m.bevelBox(-2.85,deck+0.18,sd*2.05,2.60,1.25,1.25,0.26,MET_D);      // capacitor bank
    m.box(-2.85,deck+1.44,sd*2.05,1.70,0.13,0.72,ENERGY);               // charge indicator
    m.cyl(1.10,deck+0.30,sd*2.20,0.36,0.30,1.10,7,MET_L);               // waveguide pivot
    /* The guide is a real dark-bore tube, not a solid neon rod. Its three

       collars widen toward the muzzle like an acoustic horn. */
    cylX(m,1.10,deck+1.45,sd*1.58,5.15,0.52,0.44,9,TWR_MACH,false);
    for(let k=0;k<3;k++){
      const x=2.20+k*1.45, r=0.66+k*0.13;
      ringX(m,x,deck+1.45,sd*1.58,r*0.72,r,10,k===2?ENERGY:MET_L);
    }
    tubeX(m,6.15,deck+1.45,sd*1.58,0.82,1.02,0.55,11,TWR_BORE);
  }
  /* Central phase spine links both guides. Thin energy surfaces are accents;

     the physical dark housing remains most of the visible volume. */
  m.bevelBox(0.10,deck+0.20,0,6.10,1.30,1.10,0.25,NOVA_CARB);
  m.box(0.10,deck+1.52,0,4.90,0.16,0.50,NOVA_MET);
  for(const x of [-1.50,0.10,1.70]){
    m.ring(x,deck+2.05,0,0.58,0.92,10,ENERGY);
    m.sphere(x,deck+2.05,0,0.34,7,ENERGY,1,false);
  }
  tfcHatch(m,-3.95,deck,-2.25,0.72,MET_D);
  sensorMast(m,-4.45,deck,2.10,2.20,MET_L);
  return {hull:m.build(),tur:null,s:1.0};
}

function tfcWheel(m,x,y,z,r,w){
  const sd=z>=0?1:-1;
  tfcCylZ(m,x,y,z-sd*w*0.5,w,r,r,7,TREAD,true);                           // tyre
  tfcCylZ(m,x,y,z+sd*w*0.46,w*0.16,r*0.60,r*0.56,7,MET_L,true);           // rim face
  tfcCylZ(m,x,y,z+sd*w*0.58,w*0.14,r*0.22,r*0.18,6,MET_D,true);           // hub cap
  return m;
}
function mdlTfcEngineer(){
  const m=MB();
  m.bevelBox(0,1.90,0,8.40,2.20,4.60,0.40,MET);                           // chassis rail
  glacisX(m,3.90,4.10,0,2.20,0.90,4.20,MET_L);                            // sloped nose
  for(const sd of [-1,1]){
    for(const fx of [2.60,-2.60]){
      tfcWheel(m,fx,1.50,sd*2.95,1.45,1.35);
      /* The arch spans the wheel FORE-AFT and is narrower than the tyre, so the

         outer face of the wheel still shows from directly above. A raw wedge

         ramps across its third axis, and that is exactly what put the first

         pass's arches across the vehicle instead of over the tyres — where

         they then read as four black blocks with no wheel visible at all. */
      m.box(fx,3.00,sd*2.55,3.30,0.36,1.10,MET);
      m.box(fx,2.30,sd*2.05,3.30,0.80,0.22,MET_D);
    }
    m.bevelBox(0.60,4.75,sd*1.85,3.20,0.95,0.85,0.20,sd>0?TEAM_A:TEAM_B); // side livery
    m.box(-1.40,4.50,sd*2.15,5.00,0.46,0.20,MET_L);                       // bed rail
  }
  /* LOAD BED: a recessed inner extrusion with a lip is what makes a flatbed

     read as a bed rather than as the top of a box. */
  const BB=[[-4.00,-2.10],[1.00,-2.10],[1.00,2.10],[-4.00,2.10]];
  m.extrude(0,4.10,0,BB,0.30,MET_D);
  m.extrude(0,4.40,0,BB.map(q=>[q[0]*0.90,q[1]*0.84]),0.18,DARKER);
  tfcStow(m,-2.20,4.58,0.50,3.00,1.10,41);
  kitBox(m,-3.30,4.58,-1.20,1.40,0.80,1.40,MET_D,0);
  /* TELESCOPIC BOOM. Two stages on a real diagonal, with the upper stage

     stepped in — the step is what says telescopic, and the diagonal is what

     says boom rather than shelf. */
  m.bevelBox(-1.00,4.58,0,2.00,1.60,2.20,0.30,MET_D);                     // turntable
  m.cyl(-1.00,6.18,0,0.80,0.70,0.50,8,MET_L);
  tfcStrut(m,[-1.00,6.50,0],[2.90,8.60,0],0.46,MET_L);                    // lower stage
  tfcStrut(m,[2.60,8.44,0],[6.10,9.90,0],0.32,MET_D);                     // upper stage
  m.bevelBox(6.20,9.55,0,1.20,0.90,1.00,0.22,MET_D);                      // head housing
  cylX(m,6.80,10.00,0,0.80,0.40,0.28,8,ENERGY,false);                     // fabricator head
  tfcStrut(m,[-0.20,4.90,0],[2.40,7.90,0],0.22,MET_D);                    // lift ram
  tfcStrut(m,[2.20,8.30,0.55],[2.20,8.30,-0.55],0.30,MET_L);              // stage pivot
  tfcChevrons(m,-1.40,4.60,0,4,3.00,3.20);
  /* CAB. An engineer with a cab is a machine somebody drives; the old model had

     a bare box for a nose and read as a remote hull. */
  m.bevelBox(3.00,4.10,0,2.00,1.80,3.40,0.32,MET);
  m.box(3.95,5.05,0,0.32,0.95,2.60,GLASS);                                // windscreen
  m.box(3.00,5.90,0,1.70,0.20,2.80,TEAM_T);                               // cab roof livery
  m.box(2.30,5.95,1.30,0.70,0.22,0.55,LAMP);                              // work light
  m.cyl(-3.85,4.60,1.60,0.22,0.17,1.60,6,DARKER);
  m.cyl(-3.85,6.20,1.60,0.36,0.30,0.38,7,LAMP);                           // rotating beacon
  for(const sd of [-1,1]) tfcTow(m,-4.30,2.20,sd*1.50,0);
  deckCrown(m,-1.50,4.60,0,6.40,4.20,MET_D,TEAM_T);
  return {hull:m.build(),tur:null,s:1.0};
}

/* ---------------------------------------------------------------------------

   32 — PROSPECTOR.  Mobile miner, hp 190, dmg 0 — UNARMED.

   This slot had no model of its own at all: UNIT_MDL only ever ran to 31

   entries, and src/airlift.js patches slot 32 to mdlConstructor at load, so

   for the Frontline Command the miner and the engineer were the SAME VEHICLE.

   Two units in the same production tab with one silhouette is the worst

   readability failure in the roster, and it is invisible in every gate because

   nothing crashed.

   Same works-vehicle family as the engineer — unity is the doctrine — but

   TRACKED rather than wheeled, with a front drill head and an ore bin with a

   raised hopper lip, so the two tell apart instantly at 60 pixels.

   --------------------------------------------------------------------------- */
function mdlTfcMiner(){
  const m=MB();
  tfcTracks(m,10.4,2.60,2.20,3.00,4,0);
  const deck=tfcHull(m,-0.40,1.85,10.0,6.20,1.15,0.65);                   // deck ~3.65
  /* ORE BIN: an open hopper with a raised lip and a discharge chute at the

     back. An open container is the whole read for a hauler. */
  const BN=[[-4.30,-2.50],[0.60,-2.50],[0.60,2.50],[-4.30,2.50]];
  m.extrude(-0.20,deck,0,BN,1.90,MET_D);
  m.extrude(-0.20,deck+0.35,0,BN.map(q=>[q[0]*0.86,q[1]*0.82]),1.60,DARKER);
  for(const sd of [-1,1]){
    m.box(-2.05,deck+1.95,sd*2.30,4.90,0.24,0.60,sd>0?TEAM_A:TEAM_B);     // hopper lip livery
    m.cyl(1.10,deck+0.30,sd*2.10,0.26,0.22,0.95,6,MET_L);                 // tie-down
    m.box(-1.20,deck-0.10,sd*3.00,3.60,0.16,0.62,MET_L);
  }
  m.wedge(-5.00,deck+0.30,0,1.60,1.10,4.20,MET_L,Math.PI/2);              // discharge chute
  /* DRILL: a boom carrying a toothed conical bit, raised clear of the hull and

     reaching well past the nose. The first pass tucked a small bit against the

     glacis and from overhead the Prospector still read as "some works vehicle"

     — which is exactly the confusion with the Constructor this rebuild exists

     to end. It is now the largest single feature on the model. */
  m.bevelBox(2.40,deck+0.10,0,2.60,2.00,3.40,0.36,MET_D);                 // boom pivot
  tfcStrut(m,[2.00,deck+1.40,0],[4.80,deck+2.30,0],0.62,MET_L);           // boom arm
  tfcStrut(m,[1.90,deck+0.30,0],[3.90,deck+2.00,0],0.24,MET_D);           // boom ram
  cylX(m,5.00,deck+2.20,0,2.30,1.35,1.55,8,TWR_MACH,false);               // drill housing
  for(const x of [5.40,6.50]) ringX(m,x,deck+2.20,0,1.35,1.75,8,MET_L);
  m.box(5.80,deck+3.70,0,2.20,0.20,1.70,TEAM_T);                          // housing cap livery
  cylX(m,7.30,deck+2.20,0,2.70,1.50,0.20,8,MET_D,true);                   // conical bit
  for(let k=0;k<6;k++){
    const a=k/6*TAU+0.5;
    m.box(7.80,deck+2.20+Math.cos(a)*0.92,Math.sin(a)*0.92,1.90,0.28,0.28,MET_L,0);
  }
  cylX(m,7.10,deck+2.20,0,0.55,0.34,0.28,6,ENERGY,true);                  // cutting head
  tfcDeckGear(m,-0.60,deck+2.00,7.60,5.60);
  tfcExhaust(m,-4.60,2.60,1.60,1,0.38);
  tfcChevrons(m,-3.20,deck+2.02,0,3,2.00,3.00);
  m.cyl(1.40,deck+0.30,-2.30,0.22,0.17,1.55,6,DARKER);
  m.cyl(1.40,deck+1.85,-2.30,0.36,0.30,0.38,7,LAMP);                      // beacon
  m.box(2.70,deck+0.10,-1.90,1.10,0.55,0.90,GLASS);                       // cab vision block
  return {hull:m.build(),tur:null,s:1.0};
}

/* ---------------------------------------------------------------------------

   24 — WARDEN.  Field repair, hp 420, dmg 0 — UNARMED.

   A support vehicle has to read as a TOOL, not as a gun with the barrel taken

   off. The working end is the whole model: a hose reel with visibly spooled

   line, a folding gantry that reaches forward and DOWN over whatever is being

   worked on, and hazard chevrons where the overhead camera looks.

   --------------------------------------------------------------------------- */
function mdlTfcWarden(){
  const m=MB();
  tfcTracks(m,10.2,2.60,2.25,3.05,4,0);
  const deck=tfcHull(m,-0.20,1.90,10.0,6.00,1.20,0.70);                   // deck ~3.80
  tfcProw(m,4.30,1.90,0,2.10,1.60,4.80);
  for(const sd of [-1,1]){
    m.box(-0.80,deck+0.02,sd*2.55,5.20,0.16,0.66,sd>0?TEAM_A:TEAM_B);
    kitBox(m,1.60,deck,sd*2.00,1.80,0.80,1.10,MET_D,0);                   // medical lockers
  }
  /* HOSE REEL: two cheeks with a spooled drum between them reads as a reel at

     a glance; a bare cylinder does not. */
  for(const sd of [-1,1]) tfcCylZ(m,-2.20,deck+1.75,sd*1.75-(sd>0?0:0.36),0.36,1.70,1.70,9,MET_L,true);
  for(let k=0;k<4;k++)
    tfcCylZ(m,-2.20,deck+1.75,-1.42+k*0.72,0.62,1.38-k*0.05,1.38-k*0.05,7,DARKER,false);
  m.tube(-2.20,deck+3.40,0,0.95,0.62,0.34,8,ENERGY);                      // field emitter
  /* FOLDING GANTRY — forward and down over the casualty, which is the shape

     that says "working on something" rather than "aiming". */
  m.bevelBox(1.90,deck,0,1.60,1.80,1.90,0.30,MET_D);
  m.cyl(1.90,deck+1.80,0,0.38,0.34,1.35,6,MET_L);
  tfcStrut(m,[1.90,deck+2.40,0],[5.20,deck+4.10,0],0.36,MET_L);           // jib
  tfcStrut(m,[5.20,deck+4.10,0],[5.60,deck+1.60,0],0.28,MET_D);           // drop link
  tfcStrut(m,[2.20,deck+0.60,0],[4.20,deck+3.20,0],0.20,MET_D);           // lift ram
  m.cyl(5.60,deck+0.90,0,0.80,0.50,0.72,8,ENERGY);                        // repair emitter
  tfcChevrons(m,-0.40,deck+0.04,0,3,2.40,3.40);
  tfcDeckGear(m,-0.20,deck,10.0,6.00);
  tfcExhaust(m,-4.60,2.60,1.55,1,0.36);
  m.cyl(3.30,deck,-2.00,0.22,0.17,1.45,6,DARKER);
  m.cyl(3.30,deck+1.45,-2.00,0.34,0.28,0.36,7,LAMP);                      // beacon
  m.box(-4.30,deck+0.60,0,1.20,0.20,2.20,TEAM_T);
  return {hull:m.build(),tur:null,s:0.95};
}

/* ---------------------------------------------------------------------------

   26 — BASILISK.  Experimental heavy, hp 1100 / dmg 120 / rng 190.

   Tier 3 at 260 mass, and its hull used to be five primitive calls — a quarter

   of the surface break-up of a tier-2 costing a quarter as much. This is the

   largest tracked vehicle the Command fields and everything about it is the

   Goliath's turret argument taken one weight class further: heavy running

   gear, a two-tier hull deep enough to see from above, a full-length raked

   glacis, sponson blisters and a gun that visibly could not fit on anything

   smaller.

   --------------------------------------------------------------------------- */
function mdlTfcHeavy(){
  const m=MB();
  tfcTracks(m,16.4,4.40,3.50,4.70,6,2);
  const deck=tfcHull(m,-0.20,3.10,15.8,10.20,2.30,1.30);                  // deck ~6.70
  tfcProw(m,6.80,3.10,0,3.50,2.90,8.20);
  for(const sd of [-1,1]){
    m.bevelBox(3.20,deck-0.30,sd*3.90,4.80,1.50,2.10,0.32,MET_D);         // sponson blister
    m.box(0.40,deck+0.02,sd*4.20,8.60,0.20,0.86,sd>0?TEAM_A:TEAM_B);      // deck livery
    kitBox(m,-4.20,deck,sd*3.20,3.00,1.05,1.80,MET_D,0);
    m.cyl(6.40,deck-0.20,sd*3.00,0.32,0.28,1.15,6,MET_L);                 // lift point
    m.box(-2.20,deck+0.02,sd*2.20,5.20,0.16,0.62,MET_L);                  // walkway
  }
  tfcDeckGear(m,-0.20,deck,15.8,10.20);
  ventBank(m,-5.40,deck+0.02,3.30,3.20,2.60,4,MET_D,0);
  tfcExhaust(m,-7.10,4.20,2.80,2,0.52);
  tfcSeam(m,3.20,deck+0.02,0,3.20,3.60,MET_L,0);
  tfcHatch(m,4.40,deck,-3.00,0.86,MET_D);
  sensorMast(m,-6.00,deck,2.80,2.90,MET_L);
  tfcStow(m,-2.40,deck,3.90,3.60,1.20,29);

  const t=MB();
  tfcRing(t,-0.80,4.00,1.20);
  tfcTurret(t,9.20,7.80,3.80,-3.20);
  t.box(3.90,1.10,0,1.30,2.70,3.70,TWR_MACH);                             // mantlet
  gunX(t,4.00,2.30,11.40,0.86,TWR_MACH);
  cylX(t,3.10,2.30,0,3.10,1.42,1.16,10,MET_D,false);                      // recoil sleeve
  for(const x of [6.60,10.60]) ringX(t,x,2.30,0,0.96,1.26,10,x>10?HOT:MET_L);
  cylX(t,3.40,0.95,-2.70,3.40,0.30,0.26,6,TWR_MACH,false);                // coaxial
  tubeX(t,6.70,0.95,-2.70,0.44,0.38,0.18,7,TWR_BORE);
  tfcStow(t,-3.70,3.95,0,3.00,1.10,23);
  t.box(-3.70,3.95,0,2.40,0.18,3.40,TEAM_T);
  return {hull:m.build(),tur:t.build(),s:1.05,turH:6.85,muzzle:15.4};
}

/* ---------------------------------------------------------------------------

   THE KIT.

   Eighteen slots from fourteen meshes; with the Rhino's existing bespoke

   chassis that is all twenty-six production slots the Frontline Command can

   field. Slots share a mesh only where the ROLES share a shape and the sim's

   `size` field draws them apart at instance time — the same pattern the

   Dominion kit uses for its two long guns and its two splash carriers.

   --------------------------------------------------------------------------- */
/* BLUE MATERIAL CONVERSION — production path, not the 32k showcase shader.

   The authored geometry predates faction-semantic materials and therefore

   still requests generic PLATE/GREEBLE/TWR_* ids. Rebinding those ids after

   construction gives the whole Blue kit its composite/carbon/actuator/circuit

   language while keeping every mesh, instance stream, draw call and UV intact.

   Bone fractions and the sign used by team-livery panels are preserved. */
const TFC_NOVA_MAT=Object.freeze({
  [MAT.PLATE]:MAT.NOVA_COMPOSITE,
  [MAT.GREEBLE]:MAT.NOVA_CARBON,
  [MAT.TREAD]:MAT.TREAD_WEAR,
  [MAT.GLASS]:MAT.HUD_CANOPY,
  [MAT.TWR_ARMOR]:MAT.NOVA_COMPOSITE,
  [MAT.TWR_MACH]:MAT.NOVA_SERVO,
  [MAT.TWR_COAT]:MAT.NOVA_CARBON,
  [MAT.TWR_GLOW]:MAT.NOVA_CIRCUIT,
  [MAT.TWR_PAD]:MAT.NOVA_CARBON,
  [MAT.SYN_CONDUIT]:MAT.NOVA_CIRCUIT
});
/* Stage N1/N2 bespoke semantic packs. These are deliberately per-asset

   surface contracts, not a claim that we have authored UV map sets. The live

   V2 shader still supplies the shared microdetail, AO and damage response;

   each contract only assigns the authored procedural parts to the appropriate

   armor/machinery/glass hierarchy until a named BaseAO/NRE/mask package is

   baked for that exact model. Keeping this data beside the unit kit prevents a

   faction-wide remap from making every Nova asset look like a Rhino.



   MAPS. Only the Rhino hull+turret is a published in-engine triplet

   (`nova-rhino-v2`, `nova-rhino-v2-turret`, opt-in `?assetskin=rhino`). Every

   other slot keeps maps:null even though generator templates of the same

   filename exist on disk: binding those to a live unwrap paints the wrong

   chart. Do not advertise a map name that is only a quadrant template. */
const TFC_NOVA_BESPOKE_PACKS=Object.freeze({
  0:Object.freeze({
    id:'nova-striker-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TRIM]:MAT.NOVA_COMPOSITE
    })
  }),
  1:Object.freeze({
    id:'nova-rhino-v2', source:'authored', maps:'nova-rhino-v2',
    /* Hull and turret are separate InstMeshes (turret draws at M.turH). One

       unwrap cannot drive both, so the turret has its own triplet. Bound from

       this file after initFactionKits — models.js only skins hull. */
    mapsTur:'nova-rhino-v2-turret', assetSkin:'rhino',
    surfaces:Object.freeze({
      [MAT.TWR_PAD]:MAT.NOVA_CARBON,
      /* Ribbed appliqué instead of the faction default. Every Nova chassis was

         pointed at NOVA_COMPOSITE for both PLATE and TWR_ARMOR, so the per-type

         remap existed but had nothing to say -- a Rhino and a Goliath wore the

         same armour tile at the same repeat frequency. Keys are RAW pre-remap

         ids, so this intercepts before TFC_NOVA_MAT reaches for the default. */
      [MAT.PLATE]:MAT.ARMR_RIB,
      [MAT.TWR_ARMOR]:MAT.ARMR_RIB
    })
  }),
  2:Object.freeze({
    id:'nova-goliath-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TRIM]:MAT.NOVA_COMPOSITE
    })
  }),
  3:Object.freeze({
    id:'nova-thumper-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Open gun-mount rails and ready-rack framing stay carbon-dark, so the

         thin artillery tube reads against its carriage at RTS distance. */
      [MAT.TRIM]:MAT.NOVA_CARBON
    })
  }),
  4:Object.freeze({
    id:'nova-commander-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TRIM]:MAT.NOVA_COMPOSITE,
      [MAT.GLASS]:MAT.HUD_CANOPY
    })
  }),
  5:Object.freeze({
    id:'nova-wasp-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Formation lamps are navigation hardware, not cockpit/energy glow. */
      [MAT.LAMP]:MAT.UNIT_BEACON
    })
  }),
  6:Object.freeze({
    id:'nova-longbow-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Capacitor collars are a deliberate charged-weapon landmark rather

         than the general Nova circuit treatment used by ordinary machinery. */
      [MAT.TWR_GLOW]:MAT.CHARGE_STRIP
    })
  }),
  7:Object.freeze({
    id:'nova-hornet-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Hornet's common launcher chassis also drives Reaper/Cinder/Harbinger.

         The charged strip lives between tubes, leaving each bore physically

         dark at combat distance. */
      [MAT.TWR_GLOW]:MAT.CHARGE_STRIP,
      [MAT.TRIM]:MAT.NOVA_CARBON
    })
  }),
  8:Object.freeze({
    id:'nova-titan-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TRIM]:MAT.NOVA_COMPOSITE,
      [MAT.TWR_GLOW]:MAT.NOVA_CIRCUIT
    })
  }),
  9:Object.freeze({
    id:'nova-pyro-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Heat landmarks stay localized to the projector: blue machinery must

         not become a second emissive system across the infantry body. */
      [MAT.TWR_GLOW]:MAT.WEAPON_GLOW,
      [MAT.LAMP]:MAT.UNIT_BEACON
    })
  }),
  10:Object.freeze({
    id:'nova-vulture-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_GLOW]:MAT.CHARGE_STRIP,
      [MAT.LAMP]:MAT.UNIT_BEACON
    })
  }),
  11:Object.freeze({
    id:'nova-bulwark-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Its field masts are hardware with a contained circuit signature, not

         a vehicle painted with the generic weapon-charge treatment. */
      [MAT.TWR_GLOW]:MAT.NOVA_CIRCUIT,
      [MAT.LAMP]:MAT.UNIT_BEACON
    })
  }),
  14:Object.freeze({
    id:'nova-corvette-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.GLASS]:MAT.HUD_CANOPY,
      [MAT.LAMP]:MAT.UNIT_BEACON,
      [MAT.TWR_GLOW]:MAT.NOVA_CIRCUIT
    })
  }),
  15:Object.freeze({
    id:'nova-dreadnought-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.GLASS]:MAT.HUD_CANOPY,
      [MAT.TRIM]:MAT.NOVA_CARBON,
      [MAT.TWR_GLOW]:MAT.CHARGE_STRIP
    })
  }),
  16:Object.freeze({
    id:'nova-bombard-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TRIM]:MAT.NOVA_CARBON,
      [MAT.TWR_GLOW]:MAT.CHARGE_STRIP
    })
  }),
  17:Object.freeze({
    id:'nova-raptor-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.GLASS]:MAT.HUD_CANOPY,
      [MAT.LAMP]:MAT.UNIT_BEACON,
      [MAT.TWR_GLOW]:MAT.NOVA_CIRCUIT
    })
  }),
  18:Object.freeze({
    id:'nova-scorcher-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TRIM]:MAT.NOVA_COMPOSITE,
      [MAT.TWR_GLOW]:MAT.WEAPON_GLOW
    })
  }),
  19:Object.freeze({
    id:'nova-constructor-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.GLASS]:MAT.HUD_CANOPY,
      [MAT.LAMP]:MAT.UNIT_BEACON,
      [MAT.TWR_GLOW]:MAT.NOVA_CIRCUIT
    })
  }),
  20:Object.freeze({
    id:'nova-reaper-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Hornet, Reaper, Cinder and Harbinger are one mesh in four slots, so the

         SIX TUBES are the only surface that can separate them from above. The

         Reaper is the saturation launcher; sustained fire blackens the tube it

         leaves through, and nothing else on the vehicle needs to say so. */
      [MAT.TWR_MACH]:MAT.SCORCH_METAL
    })
  }),
  21:Object.freeze({
    id:'nova-cinder-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Close support at 96 range: the rack is reloaded under fire and the

         bores are still lit when you see them — the exact opposite read to the

         Reaper's cold blackened tubes on the identical mesh. */
      [MAT.TWR_BORE]:MAT.EMBER_CORE
    })
  }),
  22:Object.freeze({
    id:'nova-lancer-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Shares mdlTfcLance with the Longbow. The Longbow is a beam and its

         optic stays cold; a gauss lance banks its charge in the capacitor

         collars and leaves the muzzle hot.

         Raw ids: this mesh paints its capacitor rings with the ENERGY palette

         entry, which resolves through matResolve('emissive.energy') and arrives

         here as SYN_CONDUIT — it never emits TWR_GLOW at all. */
      [MAT.SYN_CONDUIT]:MAT.CHARGE_STRIP,
      [MAT.TWR_BORE]:MAT.WEAPON_GLOW
    })
  }),
  23:Object.freeze({
    id:'nova-resonator-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* The builder's own word for the widening collars is "acoustic horn".

         Brass says pressure wave; machined servo steel says gun barrel, which

         is the one thing this weapon is not. */
      [MAT.TWR_MACH]:MAT.BRASS
    })
  }),
  24:Object.freeze({
    id:'nova-warden-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* UNARMED, so the only TWR_BORE on the hull is tfcExhaust()'s outlet.

         Left generic it reads as a weapon throat on a repair vehicle. */
      [MAT.TWR_BORE]:MAT.ENGINE_VENT,
      [MAT.LAMP]:MAT.UNIT_BEACON
    })
  }),
  25:Object.freeze({
    id:'nova-kestrel-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Raw id, not the obvious one: the GLASS palette entry resolves through

         matResolve('glass.canopy') to CURTAIN_GLASS, so a canopy contract has

         to key on CURTAIN_GLASS to reach this mesh at all. */
      [MAT.CURTAIN_GLASS]:MAT.HUD_CANOPY,
      [MAT.LAMP]:MAT.UNIT_BEACON
    })
  }),
  26:Object.freeze({
    id:'nova-basilisk-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* The largest tracked hull the Command fields and the only one whose deck

         is wide enough to carry real walkways — so its bright trim becomes deck

         plating rather than the thin highlight every lighter chassis wears. */
      [MAT.TRIM]:MAT.DECK_PLATE,
      [MAT.TWR_BORE]:MAT.WEAPON_GLOW
    })
  }),
  27:Object.freeze({
    id:'nova-harbinger-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Siege battery at size 24 — half again the Hornet from the same mesh.

         Ribbed appliqué is what still reads at that scale, and putting it on

         the hull leaves the tubes free for the two shorter-ranged siblings. */
      [MAT.PLATE]:MAT.ARMR_RIB
    })
  }),
  32:Object.freeze({
    id:'nova-prospector-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* The drill housing is the largest single feature on the model and it

         turns. Hazard banding is what a works vehicle puts on that, and it is

         also the fastest way to stop reading this as the Constructor. */
      [MAT.TWR_MACH]:MAT.WARN,
      [MAT.LAMP]:MAT.UNIT_BEACON
    })
  })
});
function tfcNovaSurfacePass(geo,pack){
  if(!geo||!geo.v)return geo;
  const v=geo.v;
  for(let o=11;o<v.length;o+=VFLOATS){
    const raw=v[o],sgn=raw<0?-1:1,packed=Math.abs(raw);
    const whole=Math.floor(packed),src=whole-1;
    const dst=pack&&pack.surfaces[src]!==undefined?pack.surfaces[src]:TFC_NOVA_MAT[src];
    if(dst!==undefined)v[o]=sgn*((dst+1)+(packed-whole));
  }
  return geo;
}
const TFC_NOVA_FACTORY_CACHE=new Map();
function tfcNovaFactory(fn,slot){
  /* A single builder can intentionally service several role slots. Cache by

     slot too: keying on `fn` alone handed slot 7's wrapper to Reaper, Cinder

     and Harbinger and slot 6's to the Lancer, so four bespoke packs were built

     and never reached a vertex. Same fix, same reason, as domLegionFactory. */
  const key=slot+':'+fn.name;
  if(TFC_NOVA_FACTORY_CACHE.has(key))return TFC_NOVA_FACTORY_CACHE.get(key);
  const wrapped=function(){
    const g=fn();
    const pack=TFC_NOVA_BESPOKE_PACKS[slot]||null;
    tfcNovaSurfacePass(g.hull,pack);tfcNovaSurfacePass(g.tur,pack);
    return g;
  };
  /* initFactionKits caches identical builders by name. Every wrapper therefore

     needs a stable unique name or the first Blue chassis would replace all of

     the others despite a completely clean JavaScript console. */
  Object.defineProperty(wrapped,'name',{value:'tfcBlue'+slot+'_'+fn.name});
  TFC_NOVA_FACTORY_CACHE.set(key,wrapped);
  return wrapped;
}
const UNIT_MDL_NOVA={
  0 :tfcNovaFactory(mdlTfcTrooper,0),    // Striker      — line rifleman (SERVO legs)
  1 :tfcNovaFactory(mdlNovaRhino,1),     // Rhino        — energy-assisted battle tank
  2 :tfcNovaFactory(mdlTfcWalker,2),     // Goliath      — assault walker (SERVO legs)
  3 :tfcNovaFactory(mdlTfcArty,3),       // Thumper      — field artillery, deployed spades
  4 :tfcNovaFactory(mdlCommander,4),     // Commander    — Nova-exclusive command chassis
  5 :tfcNovaFactory(mdlWasp,5),          // Wasp         — clean VTOL interceptor
  6 :tfcNovaFactory(mdlTfcLance,6),      // Longbow      — precision long gun
  7 :tfcNovaFactory(mdlTfcLauncher,7),   // Hornet       — box launcher
  8 :tfcNovaFactory(mdlTitan,8),         // TITAN        — clean-tech walking fortress
  9 :tfcNovaFactory(mdlTfcFlamer,9),     // Pyro         — flame trooper (SERVO legs)
  10:tfcNovaFactory(mdlTfcAA,10),        // Vulture      — anti-air, air targets only
  11:tfcNovaFactory(mdlTfcShield,11),    // Bulwark      — shield generator, UNARMED
  14:tfcNovaFactory(mdlCorvette,14),     // Corvette     — fast energy-fleet escort
  15:tfcNovaFactory(mdlDread,15),        // Dreadnought  — blue-water capital battery
  16:tfcNovaFactory(mdlTfcSiege,16),     // Bombard      — siege platform, longest reach
  17:tfcNovaFactory(mdlRaptor,17),       // Raptor       — armoured close-air gunship
  18:tfcNovaFactory(mdlTfcFlameTank,18), // Scorcher     — armoured flame tank
  19:tfcNovaFactory(mdlTfcEngineer,19),  // Constructor  — engineer, UNARMED
  20:tfcNovaFactory(mdlTfcLauncher,20),  // Reaper       — saturation launcher
  21:tfcNovaFactory(mdlTfcLauncher,21),  // Cinder       — close-support launcher
  22:tfcNovaFactory(mdlTfcLance,22),     // Lancer       — gauss lance
  23:tfcNovaFactory(mdlTfcResonator,23), // Resonator    — paired sonic waveguides
  24:tfcNovaFactory(mdlTfcWarden,24),    // Warden       — field repair, UNARMED
  25:tfcNovaFactory(mdlKestrel,25),      // Kestrel      — thin delta reconnaissance craft
  26:tfcNovaFactory(mdlTfcHeavy,26),     // Basilisk     — experimental heavy
  27:tfcNovaFactory(mdlTfcLauncher,27),  // Harbinger    — siege battery
  32:tfcNovaFactory(mdlTfcMiner,32),     // Prospector   — ore miner, UNARMED
};

/* Stage N3 bespoke structure contracts.

   A vehicle is mostly silhouette; a building is mostly one enormous flat plane

   pointed straight at this camera, which is precisely where the ten-entry

   faction remap reads worst — it hands the factory shed, the runway and the

   radar dish the same NOVA_COMPOSITE panel at the same repeat frequency.



   Two structural facts drive most of what is below, and both come from reading

   the builders rather than the names:

     - novaServicePad() paints its slab TWR_PAD, which the faction remap turns

       into NOVA_CARBON. Nine Nova structures stand on that pad, so nine

       buildings were standing on VEHICLE carbon. FOUNDATION_PAD is one key per

       asset and it is the single largest legibility win in this table.

     - the architectural colours resolve through matResolve() before they ever

       reach here: NOVA_*_ROOF arrives as MAT.ROOF, NOVA_*_GLASS as

       CURTAIN_GLASS, NOVA_*_TRIM as MAT.TRIM and NOVA_*_GLOW as SYN_CONDUIT.

       Those are the raw ids to key on; MAT.NOVA_COMPOSITE is raw too, because

       NOVA_*_ARM emits it directly instead of going through PLATE. */
const TFC_NOVA_BLD_BESPOKE_PACKS=Object.freeze({
  'mex':Object.freeze({
    id:'nova-mex-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_PAD]:MAT.FOUNDATION_PAD,
      /* The four intake collars and the throat ring are the moving parts of an

         ore extractor, and banding is what a mine puts on those. */
      [MAT.TRIM]:MAT.WARN
    })
  }),
  'pgen':Object.freeze({
    id:'nova-pgen-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_PAD]:MAT.FOUNDATION_PAD,
      /* TWR_MACH here is only ventBank()'s fins on the two outboard radiator

         slabs — the whole reason the reactor has a broad silhouette. */
      [MAT.TWR_MACH]:MAT.METAL_LOUVRE
    })
  }),
  'fac':Object.freeze({
    id:'nova-fac-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_PAD]:MAT.FOUNDATION_PAD,
      /* A 43x42 production shed is a building, not a chassis: precast bays on

         the walls and rooftop plant on the 40x39 roof plane give it the two

         surfaces the camera actually spends its time on. */
      [MAT.NOVA_COMPOSITE]:MAT.PRECAST_BAY,
      [MAT.ROOF]:MAT.HVAC_ROOF
    })
  }),
  'turret':Object.freeze({
    id:'nova-turret-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* TWR_COAT on the Sentinel is exactly the cooling cheeks and the Mk3

         reactor crown. A laser emplacement is a heat problem with a gun on it. */
      [MAT.TWR_COAT]:MAT.METAL_LOUVRE
    })
  }),
  'bunker':Object.freeze({
    id:'nova-bunker-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Ribbed appliqué is the Command's hardened-emplacement language (it is

         also what the Rhino wears). Keyed on NOVA_COMPOSITE it lands on the

         casemate shell alone and leaves the plinth as machined armour. */
      [MAT.NOVA_COMPOSITE]:MAT.ARMR_RIB
    })
  }),
  'nest':Object.freeze({
    id:'nova-nest-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_PAD]:MAT.FOUNDATION_PAD,
      [MAT.NOVA_COMPOSITE]:MAT.ARMR_RIB
    })
  }),
  'sgen':Object.freeze({
    id:'nova-sgen-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* The barrier's drum is the part an attacker shoots at, so it joins the

         hardened family. The projector dome stays NOVA_CIRCUIT to match the

         Bulwark's field masts — same doctrine, vehicle and structure. */
      [MAT.TWR_ARMOR]:MAT.ARMR_RIB
    })
  }),
  'tgate':Object.freeze({
    id:'nova-tgate-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* This one is a gantry over a slab, and both were reading as tank hull:

         MET arrives as PLATE (arches and the 66x58 overhead crane deck), CONC_D

         as CONC (the 72x60 floor). Deck plate and poured ground say assembly

         hall; composite armour says nothing at all. */
      [MAT.PLATE]:MAT.DECK_PLATE,
      [MAT.CONC]:MAT.FOUNDATION_PAD
    })
  }),
  'harbor':Object.freeze({
    id:'nova-harbor-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_PAD]:MAT.FOUNDATION_PAD,
      /* NOVA_HARBOR_DECK is named a deck and behaves like one, but resolves to

         MAT.ROOF like every other roof in the kit. A quayside is walked on. */
      [MAT.ROOF]:MAT.DECK_PLATE
    })
  }),
  'seafort':Object.freeze({
    id:'nova-seafort-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Live pontoon deck: a FLOATING battery must never read as poured ground. */
      [MAT.TWR_PAD]:MAT.DECK_PLATE
    })
  }),
  'stormcaller':Object.freeze({
    id:'nova-stormcaller-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_PAD]:MAT.FOUNDATION_PAD,
      [MAT.TWR_GLOW]:MAT.CHARGE_STRIP
    })
  }),
  'bastion':Object.freeze({
    id:'nova-bastion-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_ARMOR]:MAT.ARMR_RIB
    })
  }),
  'techlab':Object.freeze({
    id:'nova-techlab-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_PAD]:MAT.FOUNDATION_PAD,
      /* One 31x26 roof deck, and research is where the faction's clean-tech

         claim has to be visible from the only angle the player ever has. */
      [MAT.ROOF]:MAT.SOLAR_ROOF
    })
  }),
  'aatower':Object.freeze({
    id:'nova-aatower-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* The Skyguard's tracking dome is built from the GLASS palette entry,

         which lands here as CURTAIN_GLASS. It is a radome, not a window. */
      [MAT.CURTAIN_GLASS]:MAT.RADAR_MESH
    })
  }),
  'airfield':Object.freeze({
    id:'nova-airfield-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_PAD]:MAT.FOUNDATION_PAD,
      /* NOVA_AIR_DECK resolves to MAT.ROOF, so the 74x25 RUNWAY has been

         wearing roofing this whole time. Clean asphalt, not worn: a Frontline

         airfield is swept. The hangar roof takes the same id and reads as flat

         membrane roofing, which is what a hangar roof is. */
      [MAT.ROOF]:MAT.ROAD_ASPHALT_CLEAN
    })
  }),
  'uplink':Object.freeze({
    id:'nova-uplink-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* NOVA_UPLINK_ARM is the dish ring and the electronics-bunker cap and

         nothing else. The dish is the entire asset; solid composite on it was

         the difference between a Targeting Array and a grey lollipop. */
      [MAT.NOVA_COMPOSITE]:MAT.RADAR_MESH
    })
  }),
  'hq':Object.freeze({
    id:'nova-hq-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_PAD]:MAT.FOUNDATION_PAD,
      /* MIRROR_TINT is a metal tile. Rewriting the glazing band to it made

         every pane a chrome bevel — white crush + leaked emis, not windows.

         Cool office glass keeps the band and the lights. */
      [MAT.CURTAIN_GLASS]:MAT.BUILD_OFFICE_COOL,
      /* Landmark glowOk only passes TWR_GLOW (22). The faction default

         TWR_GLOW->NOVA_CIRCUIT left every roof lamp dark. Identity remap

         keeps the points; mdlHQ no longer draws a rail in this id. */
      [MAT.TWR_GLOW]:MAT.TWR_GLOW
    })
  }),
  'hellstorm':Object.freeze({
    id:'nova-hellstorm-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* TWR_MACH is the rotary cluster's four-to-eight barrels and its race.

         Gun metal is what tells a rotary apart from the beam towers beside it. */
      [MAT.TWR_MACH]:MAT.BRASS
    })
  }),
  'arc':Object.freeze({
    id:'nova-arc-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* A Tesla coil is wound, and TWR_COAT here is precisely the stacked

         toroid steps and the three outer risers. Copper, then, before it is

         armour. */
      [MAT.TWR_COAT]:MAT.COPPER_ROOF
    })
  }),
  'rail':Object.freeze({
    id:'nova-rail-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* NOVA_RAIL_GLOW resolves to SYN_CONDUIT, and it is used for one thing:

         the energized coil faces along the twin accelerator rails. A rail

         battery banks a charge; it does not run a circuit board. */
      [MAT.SYN_CONDUIT]:MAT.CHARGE_STRIP
    })
  }),
  'nova':Object.freeze({
    id:'nova-nova-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_ARMOR]:MAT.ARMR_RIB
    })
  }),
  'minelaser':Object.freeze({
    id:'nova-minelaser-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Sustained beam, not a data link: its status strips and recoil guides

         should read as heat coming off a weapon. */
      [MAT.TWR_GLOW]:MAT.WEAPON_GLOW
    })
  }),
  'missilebastion':Object.freeze({
    id:'nova-missilebastion-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* The rotating feed and fire-control block is an equipment house bolted

         to a launcher, and it is the mass that separates this from the NOVA

         silo at the same distance. */
      [MAT.TWR_COAT]:MAT.METAL_LOUVRE
    })
  }),
  'plasma':Object.freeze({
    id:'nova-plasma-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_GLOW]:MAT.PLASMA_JET
    })
  }),
  'wall':Object.freeze({
    id:'nova-wall-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* The rampart is already CONC and stays that way. Only the two buttress

         posts are MET_D, and armour on them is what makes a wall line read as

         fortification instead of a concrete kerb with dark blocks in it. */
      [MAT.GREEBLE]:MAT.ARMR_RIB
    })
  }),
  'gate':Object.freeze({
    id:'nova-gate-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* The curtain is a held field across the opening. A circuit pattern on a

         16x15 slab reads as a printed panel; a charge strip reads as energy. */
      [MAT.SYN_CONDUIT]:MAT.CHARGE_STRIP
    })
  }),
  'geo':Object.freeze({
    id:'world-geo-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_PAD]:MAT.FOUNDATION_PAD,
      /* NOVA_GEO_TRIM carries both condenser vent banks. Louvres are what make

         the geothermal plant a heat exchanger rather than a second reactor. */
      [MAT.TRIM]:MAT.METAL_LOUVRE
    })
  }),
  'silo':Object.freeze({
    id:'world-silo-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      /* Three 20-high storage cylinders. Precast concrete is both what bulk

         storage is actually made of and what ties the silo to the factory shed

         as one civil-works family. */
      [MAT.NOVA_COMPOSITE]:MAT.PRECAST_BAY
    })
  }),
  'fab':Object.freeze({
    id:'world-fab-v2', source:'semantic-bake', maps:null,
    surfaces:Object.freeze({
      [MAT.TWR_PAD]:MAT.FOUNDATION_PAD,
      [MAT.ROOF]:MAT.HVAC_ROOF
    })
  })
});

function tfcNovaBldFactory(fn, k) {
  if (!fn) return fn;
  const wrapped = function(...args) {
    const g = fn(...args);
    const pack = TFC_NOVA_BLD_BESPOKE_PACKS[k] || null;
    return tfcNovaSurfacePass(g, pack);
  };
  Object.defineProperty(wrapped, 'name', {value: 'tfcNovaBld_' + k});
  return wrapped;
}

for (const k in TFC_NOVA_BLD_BESPOKE_PACKS) {
  if (typeof BLD_MDL !== 'undefined' && BLD_MDL[k]) {
    BLD_MDL[k] = tfcNovaBldFactory(BLD_MDL[k], k);
  }
  if (typeof BLD_TUR_MDL !== 'undefined' && BLD_TUR_MDL[k]) {
    BLD_TUR_MDL[k] = tfcNovaBldFactory(BLD_TUR_MDL[k], k);
  }
  if (typeof BLD_TIER_MDL !== 'undefined' && BLD_TIER_MDL[k]) {
    const orig = BLD_TIER_MDL[k];
    BLD_TIER_MDL[k] = orig.map(V => ({
      base: tfcNovaBldFactory(V.base, k),
      tur: V.tur ? tfcNovaBldFactory(V.tur, k) : null
    }));
  }
}

/* Turret skins. initFactionKits only calls mfAssetSkin on hull; this file

   must not edit that owner, so we take over after it returns. ?assetskin=rhino

   is the pack token mfPackMaps already honours, but mfAssetSkinEnabled only

   tested =1 — extend that gate here rather than in mesh.js. */
function tfcNovaBindTurretSkins(){
  if(typeof mfAssetSkinEnabled!=='function'||!mfAssetSkinEnabled()) return;
  if(typeof mfAssetSkin!=='function'||typeof FAC_MESH==='undefined'||!FAC_MESH.nova) return;
  let q='';
  try{ q=new URLSearchParams(location.search).get('assetskin')||''; }catch(e){ return; }
  for(const slot in TFC_NOVA_BESPOKE_PACKS){
    const pack=TFC_NOVA_BESPOKE_PACKS[slot];
    if(!pack||!pack.mapsTur) continue;
    if(q!=='1'&&q!==pack.assetSkin) continue;
    const M=FAC_MESH.nova[slot];
    if(M&&M.tur) mfAssetSkin(gl,M.tur,pack.mapsTur);
  }
}
if(typeof mfAssetSkinEnabled==='function'){
  const _tfcSkinOn=mfAssetSkinEnabled;
  mfAssetSkinEnabled=function(){
    try{ if((new URLSearchParams(location.search).get('assetskin')||'')==='rhino') return true; }catch(e){}
    return _tfcSkinOn();
  };
}
/* Per-commander kit meshes. FAC_MESH caches one hull per TYPES slot, so a

   global material swap or a FAC_MESH.nova[4] replace would recolour every

   type-4 on the field — including an enemy Kai when the player picked Holt.

   Same takeover as the Rhino turret skin: models.js stays the owner, we wrap

   initFactionKits and register extras keyed by commander id. Canonical kits

   (Kai / Vex / Renn) keep the faction hull; only silhouette extras get their

   own InstMesh. Additive geometry — we cannot delete the stock arm cannons

   without editing mdlCommander. */
const COMMANDER_KIT_MESH={};
function mfCdrMergeGeo(a,b){
  if(!a) return b;
  if(!b||!b.v||!b.v.length) return a;
  const V=VFLOATS, nA=a.v.length/V;
  if(nA+(b.v.length/V)>65535)
    console.warn('commander kit merge exceeds Uint16 indices');
  const v=new Float32Array(a.v.length+b.v.length);
  v.set(a.v,0); v.set(b.v,a.v.length);
  const i=new Uint16Array(a.i.length+b.i.length);
  i.set(a.i,0);
  for(let k=0;k<b.i.length;k++) i[a.i.length+k]=b.i[k]+nA;
  return {v,i,count:i.length,skel:a.skel,bones:a.bones,assetUV:a.assetUV||null};
}
function mfCdrKitInst(baseFn,decorate,surfacePass,pack){
  const g=baseFn();
  if(decorate){
    const m=MB();
    decorate(m);
    g.hull=mfCdrMergeGeo(g.hull,m.build());
  }
  if(surfacePass) surfacePass(g.hull,pack||null);
  return {hull:new InstMesh(gl,g.hull,12),tur:g.tur?new InstMesh(gl,g.tur,12):null,s:g.s||1,turH:g.turH||0,muzzle:g.muzzle||0,muzzleZ:g.muzzleZ||0};
}
function commanderKitMeshFor(id){
  const k=id||(typeof playerCommanderId!=='undefined'?playerCommanderId:null);
  return (k&&COMMANDER_KIT_MESH[k])||null;
}
function commanderKitMeshFlush(){
  for(const k in COMMANDER_KIT_MESH){
    const M=COMMANDER_KIT_MESH[k]; if(!M||!M.hull) continue;
    M.hull.flush(gl); if(M.tur) M.tur.flush(gl);
  }
}
function mfCdrDecorateHolt(m){
  /* Engineer crane. Mast + forward boom break the backpack roofline; cargo

     crates and welder heads are the weapon-mesh read for Arc Welder. */
  m.bevelBox(-4.2,18.4,0,2.8,4.4,3.4,0.4,MET_D);
  m.cyl(-4.2,22.8,0,0.78,0.58,11.2,10,MET);
  m.box(2.2,33.6,0,13.6,0.92,0.92,MET_L);
  m.bevelBox(9.2,32.6,0,2.4,2.2,2.0,0.32,MET_D);
  m.cyl(10.2,31.2,0,0.58,0.20,2.4,8,ENERGY);
  hydraulic(m,-3.2,22.2,0,11.0,0.30,MET_L,0);
  for(const sd of [-1,1]){
    m.bevelBox(-5.0,16.0,sd*2.6,2.6,2.4,2.4,0.32,TEAM_A);
    m.box(8.8,13.6,sd*6.6,1.8,1.5,1.5,ENERGY);
    cylX(m,9.6,13.6,sd*6.6,1.6,0.38,0.16,8,ENERGY,false);
  }
}
function mfCdrDecorateVale(m){
  /* Spotter rail past the stock muzzle collars, plus a scout dish. */
  for(const sd of [-1,1]){
    cylX(m,9.0,13.6,sd*6.6,8.8,0.44,0.30,8,DARK,false);
    tubeX(m,17.2,13.6,sd*6.6,1.1,0.52,0.22,8,ENERGY);
    m.box(12.8,13.6,sd*6.6,1.4,0.95,0.95,MET_L);
  }
  m.cyl(1.4,25.4,0,0.24,0.18,5.0,6,MET_L);
  m.cyl(1.4,30.6,0,2.8,0.58,0.46,12,TEAM_A);
  m.ring(1.4,30.8,0,1.5,3.0,14,ENERGY);
  sensorMast(m,2.8,25.2,-2.2,3.6,MET_L);
}
function tfcNovaBindCommanderKits(){
  if(typeof gl==='undefined'||!gl) return;
  const pack=TFC_NOVA_BESPOKE_PACKS[4];
  COMMANDER_KIT_MESH.nova_holt=mfCdrKitInst(mdlCommander,mfCdrDecorateHolt,tfcNovaSurfacePass,pack);
  COMMANDER_KIT_MESH.nova_vale=mfCdrKitInst(mdlCommander,mfCdrDecorateVale,tfcNovaSurfacePass,pack);
}
if(typeof initFactionKits==='function'){
  const _tfcInitKits=initFactionKits;
  initFactionKits=function(){
    _tfcInitKits.apply(this,arguments);
    tfcNovaBindTurretSkins();
    tfcNovaBindCommanderKits();
  };
}