DramaticShapeVoxelMod archival mirror · read-only
lib/Buildings.lua

Buildings.lua

lib/Buildings.lua75.5 KB100644d90971f0ad
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870
-- Voxel world mode: a building voxelized from its own sprite.
--
-- A Game Boy overworld building is a fake-3D projection that packs several
-- different 3D facings into one flat drawing: the roof is drawn as if seen
-- from above, the facade as if seen face-on, and the sloped ends as
-- diagonal silhouettes. Raising the whole footprint as one box (what the
-- generic volume path does) folds all three into a wall, so a house comes
-- out as a cube wearing its own elevation.
--
-- This module does the other thing: it classifies each BAND of the drawing
-- by the surface it depicts and applies the matching operation per band --
-- the pipeline written up in assets/docs/buidling_to_voxel/. Two rules govern it:
--
--   1. Every visible voxel colour is a real texel of the drawing. Nothing
--      is invented but the geometry the sprite implies and never paints
--      (undersides, the depth behind the facade), and those wear the
--      drawing's own four shades.
--   2. The sprite is ground truth, not the tile grid. The silhouette, the
--      taper rate, the eave height and every window are MEASURED off the
--      pixels; the profile only says which rows are roof and which are
--      facade.
--
-- The pipeline, per template (see data/voxel_heights.lua `buildings`):
--
--   read     composite the building out of the atlas and flood its
--            silhouette in from the border through light pixels only --
--            the black outline and the #555 shading together are the
--            boundary, and a "not black" test eats the shaded flanks.
--   measure  the topmost drawn row of each column IS the roof's elevation
--            profile (the drawn taper is the slope); the facade's panes
--            are the non-black regions its black frames seal off.
--   build    facade rows extrude straight back over the footprint, the
--            awning band juts past them, panes sink one voxel, and the
--            roof lays the top-facing rows flat -- level over the
--            plateau, stepping down the drawn taper at the ends -- then
--            overwrites the walls it intersects.
--   emit     cull to the shell and merge runs of texel-adjacent faces into
--            single quads, so a 90k-voxel house ships as ~2k quads.
--
-- One model is built per template and stamped at every placement: Red's
-- and Blue's houses are the same seven-placement drawing, so they cost one
-- build between them. mods/DRAMATIC_SHAPE/tools/building_voxels.py is the
-- reference implementation of the same algorithm and prints the voxel and
-- shell counts this one must agree with.
--
-- Purely presentational, like everything else in the mod: the tiles a
-- building claims keep the collision, warps and triggers they always had.

-- the mod namespace (see main.lua): V.data loads a shipped data file
local V = ...

local Budget = V.require("BuildBudget")

local Buildings = {}

-- The four GB shades, lightest first (same cutoffs as Structures.shadeClass,
-- which reasons about the same art).
local WHITE, GREY, DARK, BLACK = 0, 1, 2, 3

-- A pane is a window or a doorway: a non-black region the drawing seals
-- off behind its own black frame. Anything wider or taller than this is a
-- band of the facade itself -- a siding course, the awning's grey field --
-- and must stay flush.
local RECESS_MAX = 24

-- Face shades, matching the rest of the mod's objects: the south face is
-- the drawing itself and draws at full brightness.
local SHADE = { top = 0.95, south = 1.0, north = 0.68,
                side = 0.78, bottom = 0.5 }

-- ------- how far a merged run may reach: the tile lattice
--
-- Merging is what keeps a 90k-voxel house down to ~2k quads, and under a
-- straight projection a run may be as long as it likes -- a straight line
-- is a straight line however finely it is cut. THE WORLD CURVE IS NOT
-- STRAIGHT. It drops every vertex by the square of its distance from the
-- focus (see WorldCurve), so a quad's interior is the CHORD of a parabola
-- its neighbours draw the arc of: a run of length L hangs k*L^2/4 below
-- the short quads butted against it, and the join tears open.
--
-- Nothing bounded a run's length before, and the runs that ran away were
-- the ones wearing a CONSTANT texel -- the roof's black eave outline, its
-- fascia, the shaded underside -- because a flat run has no art to break
-- it. Those reached 102px across a gym, which at V-CURVE 3 hangs some
-- three world pixels under the roof surface beside it: the eave tore off
-- the roof and the drop showed the building's dark interior through the
-- slot. (Strip runs, the drawing marching along the atlas, break at the
-- tileset's own boundaries and were never the problem.)
--
-- So a run stops at the next 8px lattice line. Buildings are stamped at
-- tx*8 (see stamp), so the model's lattice IS the map's: every quad in the
-- scene -- terrain, props, this -- now ends on the same lines, every join
-- is vertex-for-vertex, and the bend carries them together. What is left
-- is the sag WITHIN one cell, k*64/4, which is under a twentieth of a
-- world pixel at any rung.
--
-- It costs quads on a dense city map (Cerulean's object stream goes from
-- 35.7k to 41.6k, and its longest edge from 102px to 8px) and it costs them
-- whether the curve is on or not, which is the deliberate trade: the mesh
-- is cached per map and built asynchronously over seconds, so meshing for
-- the curve's sake only when the curve is on would mean rebuilding every
-- live map on a keypress.
local CELL = 8

-- How far a run starting at `a` may go before it crosses the next lattice
-- line. Floor-mod, so the awning's negative z lands on the same lines the
-- positive side does.
local function runCap(a)
  return CELL - a % CELL
end

local function keyOf(tx, ty)
  return (ty + 64) * 4096 + (tx + 64)
end

local function shadeOf(r, g, b, a)
  if a == 0 then return WHITE end
  local v = math.min(r, g, b)
  if v <= 0.25 then return BLACK end
  if v <= 0.55 then return DARK end
  if v <= 0.85 then return GREY end
  return WHITE
end

-- The shape profile ships with the mod; absent or broken simply means no
-- building templates, and every building falls back to the volume path.
local spec = nil
local function profile()
  if spec == nil then
    local ok, s = pcall(V.data, "voxel_heights")
    spec = (ok and type(s) == "table") and s or false
  end
  return spec or nil
end

local models = {}          -- "<tileset>:<index>" -> prebuilt local quads
local frontSets = {}       -- tileset id -> { [tile] = true } or false

-- The tileset's side-door art: the 2x2 tile grid of one doorway cell
-- (data/voxel_heights.lua `sideDoors`), or nil when the tileset names none.
function Buildings.sideDoorCell(tilesetId)
  local s = profile()
  return s and s.sideDoors and s.sideDoors[tilesetId] or nil
end

-- The tileset's front-only tiles as a set (data/voxel_heights.lua
-- `frontOnly`): the doorways, shop signs and painted lettering that belong
-- on a facade and on no other face of the same building. nil when the
-- tileset names none, which is every indoor one.
function Buildings.frontOnly(tilesetId)
  local hit = frontSets[tilesetId]
  if hit == nil then
    local s = profile()
    local list = s and s.frontOnly and s.frontOnly[tilesetId]
    if list then
      hit = {}
      for _, id in ipairs(list) do hit[id] = true end
    else
      hit = false
    end
    frontSets[tilesetId] = hit
  end
  return hit or nil
end

-- ------------------------------------------------------------------ read --

-- Which sprite pixel a BACK-facing voxel shows, where that is not the one
-- the front shows. The facade extrudes straight through the footprint, so
-- the far wall is the drawing again -- and read from behind it is the
-- drawing mirrored, doorway, shop sign, GYM lettering and all. Those tiles
-- are named per tileset in data/voxel_heights.lua `frontOnly`; every cell
-- wearing one takes the art of an ordinary cell beside it in the same tile
-- row.
--
-- The donor is chosen per RUN of front-only cells, not per cell, so a
-- two-tile doorway comes out as two tiles of the SAME wall rather than
-- borrowing left from one side and right from the other. Between the two
-- neighbours the one whose tile the row uses more often wins, which is
-- what reaches past a gable's sloped corner (a 4x2 house draws its door
-- against the slope: the corner is unique to the row, the wall beside it
-- is not) for the wall the back should actually wear.
--
-- Returns a SPARSE map, sprite index -> sprite index, empty entries meaning
-- "unchanged"; nil when the drawing has no front-only tile at all, which is
-- most of them.
local function backMap(tiles, bw, bh, W, inside, frontOnly)
  if not frontOnly then return nil end
  local donor, any = {}, false
  for r = 1, bh do
    local row = tiles[r]
    local freq = {}
    for c = 1, bw do
      local id = row[c]
      if not frontOnly[id] then freq[id] = (freq[id] or 0) + 1 end
    end
    local c = 1
    while c <= bw do
      if frontOnly[row[c]] then
        local c1 = c
        while c1 < bw and frontOnly[row[c1 + 1]] do c1 = c1 + 1 end
        local l, rt = c - 1, c1 + 1
        local pick = nil
        if l >= 1 and rt <= bw then
          pick = ((freq[row[rt]] or 0) > (freq[row[l]] or 0)) and rt or l
        elseif l >= 1 then
          pick = l
        elseif rt <= bw then
          pick = rt
        end
        -- a row that is front-only end to end has no donor; it keeps its
        -- own art rather than inventing one
        if pick then
          for k = c, c1 do donor[(r - 1) * bw + (k - 1)] = pick - 1 end
          any = true
        end
        c = c1 + 1
      else
        c = c + 1
      end
    end
  end
  if not any then return nil end

  local back = {}
  for cell, dc in pairs(donor) do
    local r, c = math.floor(cell / bw), cell % bw
    for oy = 0, 7 do
      local sy = r * 8 + oy
      for ox = 0, 7 do
        local i = sy * W + c * 8 + ox
        local j = sy * W + dc * 8 + ox
        -- The donor must be DRAWN, or the substitution would hand the wall
        -- a texel from outside the silhouette. One row up is tried first,
        -- because the drawing's last row is the black threshold the
        -- building stands on: the doorway paints it (a door sits on the
        -- ground) and the wall beside it does not, so at the base course
        -- the same row of the donor column is off the shape. The model
        -- lifts that column's foot by exactly one row for the same reason
        -- (see `at`), and lifting the donor with it is what makes the back
        -- wall's bottom course continuous.
        if not inside[j] then j = j - W end
        if j >= 0 and inside[j] then back[i] = j end
      end
    end
  end
  return back
end

-- Composite the template out of the atlas and flood the silhouette in from
-- the border. Returns flat arrays indexed y * W + x.
--
-- `topRows`, when a template carries it, is extra drawing rows composited
-- ABOVE the matched grid: rows of the same drawing that are not on the
-- map this template places on. The Pokemon Tower is the case that needs
-- it -- the drawing straddles the LAVENDER_TOWN / ROUTE_10 boundary, its
-- roof band and top window courses standing in the route's last rows, so
-- no single map's grid holds the whole building. The matcher never sees
-- topRows (placement is still by `tiles` alone); they exist so the MODEL
-- is built from the complete drawing and the tower rises to its real
-- height instead of folding as two half-buildings.
-- Composite one doorway cell PAST the end of the sprite, at indices
-- W*H .. W*H+255, and hand back its base. The side-door pass paints with
-- sprite indices like everything else -- `emit` resolves a voxel's colour
-- through sp.ax/sp.ay and knows nothing about where the index came from --
-- so the door only has to BE in the sprite arrays to travel the rest of
-- the pipeline untouched. Appending rather than drawing into the grid is
-- the point: the drawing itself must not change, or the silhouette flood,
-- the taper and every measured band would be read off art the tileset
-- never placed here.
--
-- Nothing else walks past W*H (measure's shadeTexel scan and the pane
-- flood both stop there), so the block is invisible to measurement and
-- visible only to the code that asks for it by index.
--
-- WHICH OF THE 256 TEXELS ARE THE DOOR. A doorway cell is not a doorway
-- edge to edge: the tileset draws it as a cell OF A FACADE, so its outer
-- ring is the wall beside and above the frame, and its last row is the
-- black threshold the building stands on with the door's own step cut into
-- it. Painting all 16x16 onto a flank would stamp a one-pixel border of
-- front-wall art around every door.
--
-- The front facade tells the ring from the door by flooding: the wall
-- around the door is one region with the whole facade, far too big to be a
-- pane, and only what the black frame SEALS sinks. The same test, bounded
-- to the block: flood the left, right and top edges through their own
-- shade class, and what the flood reaches is context -- left unpainted, so
-- the flank keeps the texel it already had. Not the bottom edge, because
-- the bottom edge is the ground: the step under the door is sealed there
-- on the drawn facade too, and it recesses with the rest of the doorway.
--
-- What is painted then splits the way a facade's does: black is frame and
-- stays flush with the wall, everything it seals sinks a voxel behind it.
local function readDoor(sp, data, perRow, cell)
  local base = sp.W * sp.H
  local black = {}
  for dy = 0, 15 do
    local row = cell[math.floor(dy / 8) + 1]
    for dx = 0, 15 do
      local tile = row[math.floor(dx / 8) + 1]
      local px = (tile % perRow) * 8 + dx % 8
      local py = math.floor(tile / perRow) * 8 + dy % 8
      local k = dy * 16 + dx
      local i = base + k
      sp.ax[i], sp.ay[i] = px, py
      local r, g, b, a = data:getPixel(px, py)
      sp.col[i] = shadeOf(r, g, b, a)
      sp.inside[i] = true
      black[k] = sp.col[i] == BLACK
    end
  end

  local context, stack = {}, {}
  local function seed(dx, dy, cls)
    if dx < 0 or dx > 15 or dy < 0 or dy > 15 then return end
    local k = dy * 16 + dx
    if context[k] or black[k] ~= cls then return end
    context[k] = true
    stack[#stack + 1] = k
  end
  for dy = 0, 15 do
    seed(0, dy, black[dy * 16])
    seed(15, dy, black[dy * 16 + 15])
  end
  for dx = 0, 15 do seed(dx, 0, black[dx]) end
  while #stack > 0 do
    local k = table.remove(stack)
    local dx, dy, cls = k % 16, math.floor(k / 16), black[k]
    seed(dx + 1, dy, cls)
    seed(dx - 1, dy, cls)
    seed(dx, dy + 1, cls)
    seed(dx, dy - 1, cls)
  end

  sp.door = { base = base, black = black, context = context }
end

local function read(t, data, perRow, frontOnly)
  local tiles = t.tiles
  if t.topRows then
    tiles = {}
    for _, row in ipairs(t.topRows) do tiles[#tiles + 1] = row end
    for _, row in ipairs(t.tiles) do tiles[#tiles + 1] = row end
  end
  local bh, bw = #tiles, #t.tiles[1]
  local W, H = bw * 8, bh * 8
  local col, ax, ay = {}, {}, {}
  for sy = 0, H - 1 do
    Budget.tick()
    local row = tiles[math.floor(sy / 8) + 1]
    for sx = 0, W - 1 do
      local tile = row[math.floor(sx / 8) + 1]
      local px = (tile % perRow) * 8 + sx % 8
      local py = math.floor(tile / perRow) * 8 + sy % 8
      local i = sy * W + sx
      ax[i], ay[i] = px, py
      local r, g, b, a = data:getPixel(px, py)
      col[i] = shadeOf(r, g, b, a)
    end
  end

  local outside = {}
  local queue, n = {}, 0
  local function seed(x, y)
    local i = y * W + x
    if not outside[i] and col[i] <= GREY then
      outside[i] = true
      n = n + 1
      queue[n] = i
    end
  end
  -- The flood comes in from the border, which assumes the drawing is
  -- bounded by its own outline on every side. A drawing trimmed flush to
  -- its art -- one whose base course is a row of brick rather than the
  -- black threshold every other building stands on -- names the sides it
  -- runs off in `seal`, and the flood does not seed there. Without it the
  -- flood climbs in through the light mortar and hollows the wall out.
  local seal = t.seal or ""
  local function sealed(side) return string.find(seal, side, 1, true) ~= nil end
  for x = 0, W - 1 do
    if not sealed("n") then seed(x, 0) end
    if not sealed("s") then seed(x, H - 1) end
  end
  for y = 0, H - 1 do
    if not sealed("w") then seed(0, y) end
    if not sealed("e") then seed(W - 1, y) end
  end
  while n > 0 do
    local i = queue[n]
    n = n - 1
    local x, y = i % W, math.floor(i / W)
    if x + 1 < W then seed(x + 1, y) end
    if x > 0 then seed(x - 1, y) end
    if y + 1 < H then seed(x, y + 1) end
    if y > 0 then seed(x, y - 1) end
  end

  local inside = {}
  for i = 0, W * H - 1 do inside[i] = not outside[i] end

  -- `scrub` names pixel rects where the drawing paints an object standing
  -- ON the surface (Red's potted plant on the dining tabletop). The object
  -- keeps its own standee -- the template's `keep` leaves its tiles
  -- unclaimed -- so the band beneath it is the one surface the drawing
  -- implies but never paints clear: every rect pixel takes the field
  -- shade, sourced from the first field texel outside the rects, and the
  -- model's top comes out as the plain surface the object sat on.
  if t.scrub then
    local function inRect(x, y)
      for _, r in ipairs(t.scrub) do
        if x >= r[1] and x <= r[3] and y >= r[2] and y <= r[4] then
          return true
        end
      end
      return false
    end
    local donor = nil
    for i = 0, W * H - 1 do
      if col[i] == GREY and inside[i]
         and not inRect(i % W, math.floor(i / W)) then
        donor = i
        break
      end
    end
    for i = 0, W * H - 1 do
      if inRect(i % W, math.floor(i / W)) then
        col[i] = GREY
        ax[i], ay[i] = ax[donor], ay[donor]
        inside[i] = true
      end
    end
  end
  return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside,
           back = backMap(tiles, bw, bh, W, inside, frontOnly) }
end

-- --------------------------------------------------------------- measure --

local function measure(sp, t)
  local W, H = sp.W, sp.H
  local roofRows = t.roofRows

  -- The drawn taper IS the slope: the first drawn row of a column is how
  -- far the roof has stepped down by the time it reaches that column.
  local top = {}
  for x = 0, W - 1 do
    local r = roofRows
    for y = 0, roofRows - 1 do
      if sp.inside[y * W + x] then r = y break end
    end
    top[x] = r
  end

  -- The row a column's roof SURFACE may sink to. `top[x]` is the
  -- silhouette cap -- the black the drawing closes its shape with -- and
  -- the depth map spends most of a tapered column's depth above it, so
  -- clamping onto `top[x]` paints that one outline pixel the length of
  -- the slope and the courses beat against it. The surface belongs on the
  -- first PAINTED row instead: the same refusal to let the outline stand
  -- as a face that the side faces already make below.
  local surfaceTop = {}
  for x = 0, W - 1 do
    local y = top[x]
    while y < roofRows and sp.inside[y * W + x]
        and sp.col[y * W + x] == BLACK do
      y = y + 1
    end
    if y < roofRows and sp.inside[y * W + x] then
      surfaceTop[x] = y
    else
      surfaceTop[x] = top[x]
    end
  end

  -- The drawing's own ground line: the row after the last drawn one. A
  -- building ends on the black threshold row it stands on (ground == H),
  -- but furniture is drawn standing on open floor -- the lab table's
  -- legs stop two rows short of its grid -- and extruding against H
  -- would float it that far above its own plot.
  local ground = roofRows
  for sy = H - 1, roofRows, -1 do
    local drawn = false
    for sx = 0, W - 1 do
      if sp.inside[sy * W + sx] then drawn = true break end
    end
    if drawn then
      ground = sy + 1
      break
    end
  end

  local wallH = ground - roofRows
  local ytop = wallH - 1 + t.slab

  -- Side faces must not come out as slabs of outline black: where the
  -- drawing's own pixel is the outline, walk inward for the first painted
  -- colour, which is what the flanks of the real thing would show.
  local interior = {}
  for sy = roofRows, H - 1 do
    for sx = 0, W - 1 do
      local i = sy * W + sx
      local src = i
      if sp.inside[i] and sp.col[i] == BLACK then
        local step = sx < W / 2 and 1 or -1
        for d = 1, 3 do
          local nx = sx + step * d
          if nx >= 0 and nx < W then
            local ni = sy * W + nx
            if sp.inside[ni] and sp.col[ni] ~= BLACK then
              src = ni
              break
            end
          end
        end
      end
      interior[i] = src
    end
  end

  -- Panes: the facade's non-black pixels split into regions across the
  -- black frames, and a region small enough to be a window or a doorway
  -- sinks a voxel. Frames stay proud, so the pane behind them reads as
  -- glass set into the wall -- and a nested frame (the door's own little
  -- window) layers for free.
  local recess, seen = {}, {}
  for sy = roofRows, H - 1 do
    for sx = 0, W - 1 do
      local i0 = sy * W + sx
      if not seen[i0] and sp.inside[i0] and sp.col[i0] ~= BLACK then
        local cells, stack = {}, { i0 }
        seen[i0] = true
        local x0, x1, y0, y1 = sx, sx, sy, sy
        local function step(nx, ny)
          if nx < 0 or nx >= W or ny < roofRows or ny >= H then return end
          local ni = ny * W + nx
          if not seen[ni] and sp.inside[ni] and sp.col[ni] ~= BLACK then
            seen[ni] = true
            stack[#stack + 1] = ni
          end
        end
        while #stack > 0 do
          local i = table.remove(stack)
          cells[#cells + 1] = i
          local cx, cy = i % W, math.floor(i / W)
          if cx < x0 then x0 = cx end
          if cx > x1 then x1 = cx end
          if cy < y0 then y0 = cy end
          if cy > y1 then y1 = cy end
          step(cx + 1, cy)
          step(cx - 1, cy)
          step(cx, cy + 1)
          step(cx, cy - 1)
        end
        if x1 - x0 < RECESS_MAX and y1 - y0 < RECESS_MAX then
          for _, i in ipairs(cells) do recess[i] = true end
        end
      end
    end
  end

  -- The pane rule reads a LIGHT region the drawing seals behind a BLACK
  -- frame. A drawing built the other way round -- the healing machine's
  -- dark screens sealed behind their own white bezels -- inverts under
  -- it: every lit edge sinks and the black panes stand proud, a black
  -- lattice a voxel off the face. `panes = false` says the drawing does
  -- not carry the rule's polarity, so the facade stays flush.
  if t.panes == false then recess = {} end

  -- One representative texel per shade, taken from the building's own art:
  -- the roof's fascia and its undersides are geometry the drawing implies
  -- but never paints, and they must still wear its palette (and pick up
  -- whatever SGB recolouring the atlas carries).
  local shadeTexel = {}
  for i = 0, sp.W * sp.H - 1 do
    if sp.inside[i] and not shadeTexel[sp.col[i]] then
      shadeTexel[sp.col[i]] = i
    end
  end
  for s = WHITE, BLACK do
    shadeTexel[s] = shadeTexel[s] or shadeTexel[BLACK] or 0
  end

  -- Depth is the MATCHED footprint, not the sprite height. The two are
  -- the same number for every whole-drawing template (the sprite is
  -- built from `tiles` alone), but a template with `topRows` has a
  -- sprite taller than its footprint -- the tower's 16-row drawing
  -- stands on the 8 rows of it that are actually on the map, and D = H
  -- would have pushed its body 64px south into the town plaza.
  -- `depth` (in tile rows) names the plot when the grid runs PAST it
  -- onto ground the drawing merely stands its legs on: the lab table's
  -- third row is the walkable cell the player faces it from, and the
  -- full-grid depth would stand the model in their path.
  -- `depth` names the plot in TILE ROWS, which is the right grain for a
  -- building. `depthPx` names it in voxels, for an object whose real
  -- depth is not a whole tile row -- the Bike Shop toolbox is a box
  -- standing in the middle of its own cell, not a thing that fills a plot.
  return { top = top, surfaceTop = surfaceTop, ytop = ytop,
           D = t.depthPx or ((t.depth or #t.tiles) * 8),
           ground = ground,
           recess = recess, interior = interior, shadeTexel = shadeTexel }
end

-- ----------------------------------------------------------------- build --

-- A desk with separately-classified objects on it (a template's `parts`
-- list): the methodology's region classification at part granularity.
-- Upright parts anchor their drawn bottom row to the desk's top plane
-- and wear their own drawn tops as lids; flat parts (a keyboard, a
-- sheet of paper) lie one voxel proud at drawn row = depth row -- the
-- same 1:1 the tabletop itself is drawn with, so an object's height ON
-- the drawing is its position ON the desk. The desk is the lab-table
-- slab + base; its lid is the one synthesized surface in the model
-- (the objects cover every drawn pixel of the tabletop), continued
-- from the sibling tables' pattern in the drawing's own shades.
-- tools/building_voxels.py `build_desk_set` is the reference twin.
local function deskSetModel(sp, pr, t)
  local W, H, D = sp.W, sp.H, pr.D
  local ground = pr.ground
  local col, inside = sp.col, sp.inside
  local vox = {}
  local function key(x, y, z) return (y * D + z) * W + x end
  local function put(x, y, z, i) vox[key(x, y, z)] = i end

  -- de-outline walk bounded to the part, so a part's side faces show
  -- its own material and never the neighbour's (the sprite-wide walk
  -- the facade path uses would cross the black seam between units)
  local function interiorAt(sx, sy, lo, hi)
    local i = sy * W + sx
    if col[i] ~= BLACK then return sx end
    local step = sx < math.floor((lo + hi) / 2) and 1 or -1
    for d = 1, 3 do
      local nx = sx + step * d
      if nx >= lo and nx <= hi then
        local ni = sy * W + nx
        if inside[ni] and col[ni] ~= BLACK then return nx end
      end
    end
    return sx
  end

  -- The parts list, shared by every base piece: a desk plane or an
  -- open tray rim alike, `plane` is simply the height they ride.
  local ytop = 0
  local function buildParts(plane)
    for _, p in ipairs(t.parts) do
      Budget.tick()
      local x0 = p.x and p.x[1] or 0
      local x1 = p.x and p.x[2] or (W - 1)
      if p.kind == "flat" then
        -- drawn row = depth row by default; `z` renames the origin when
        -- the flat sits below the desk's own drawn top span (the Center
        -- PC's keyboard). `at` names the sheet's own height when it does
        -- not lie on the desk plane (the healing machine's keyboard is a
        -- shelf mounted on the cabinet's side); `thick` gives it a body
        -- -- layers below the sheet repeating each column's own texel,
        -- the same continuation rule every synthesized surface follows.
        local r0 = p.rows[1]
        local z0 = p.z or r0
        local atY = p.at or plane
        local thick = p.thick or 1
        if atY > ytop then ytop = atY end
        for sy = r0, p.rows[2] do
          local z = z0 + (sy - r0)
          if z >= 0 and z < D then
            for sx = x0, x1 do
              if inside[sy * W + sx] then
                for y = math.max(0, atY - thick + 1), atY do
                  put(sx, y, z, sy * W + sx)
                end
              end
            end
          end
        end
      elseif p.kind == "box" then
        -- A BOX part is a drawn rect standing at its own drawn
        -- elevation -- equipment attached to the machine rather than an
        -- object on the desk plane. The rows are face-on art: the top
        -- row's drawn height IS the box's top (ground - 1 - r0,
        -- measured), and the box runs down to `base` (default the drawn
        -- extent; 0 continues it to the floor, the legs-continue rule).
        -- Height beyond the drawn rows fills the way a roof band does:
        -- rows before `cycle` map 1:1 from the top, rows after it 1:1
        -- from the bottom -- the healing machine hoses' foot lands ON
        -- the floor -- and the cycle window repeats between.
        local r0, r1 = p.rows[1], p.rows[2]
        local c0 = p.cycle and p.cycle[1] or r1
        local c1 = p.cycle and p.cycle[2] or r1
        local pz = p.z or 0
        local pd = p.depth
        local top = pr.ground - 1 - r0
        local bot = p.base or (pr.ground - 1 - r1)
        local nTop, nBot = c0 - r0, r1 - c1
        if top > ytop then ytop = top end
        for y = bot, top do
          local k, j = top - y, y - bot
          local sy
          if k < nTop then
            sy = r0 + k
          elseif j < nBot then
            sy = r1 - j
          else
            sy = c0 + (k - nTop) % (c1 - c0 + 1)
          end
          for sx = x0, x1 do
            local i = sy * W + sx
            if inside[i] then
              local ix = interiorAt(sx, sy, x0, x1)
              for z = pz, pz + pd - 1 do
                if z >= 0 and z < D then
                  local px = (z == pz or z == pz + pd - 1) and sx or ix
                  put(sx, y, z, sy * W + px)
                end
              end
            end
          end
        end
      elseif p.kind == "iso" then
        -- An ISO part is drawn in 2:1 isometric -- a box TURNED 45
        -- degrees to the map, so one rhombus carries its top, its front
        -- and its side at once and no band or facade split can reach
        -- them. Un-projecting it is that projection run backwards: the
        -- box stands as a real diamond in plan and every voxel wears the
        -- texel the drawing paints where that voxel projects TO. The
        -- drawn top lands on the top, the screen on the screen-facing
        -- side and the flank on the flank, and nothing is segmented by
        -- hand -- which is the only way to get this right, because the
        -- three faces meet on a diagonal no rectangle can name.
        --
        -- Everything but the depth centre falls out of the drawn rect,
        -- because the projection fixes it: the half-width is the drawn
        -- rhombus's x radius, HALF that again its z radius (2:1 is what
        -- makes it isometric), the near corner's drawn row is the base
        -- rhombus's front tip, and whatever drawn height is left once
        -- that rhombus is accounted for is the box's own height. Bill's
        -- computer: rx 6, rz 3, base centre row 10, and 6 voxels tall --
        -- which puts its left corner's vertical edge at drawn rows
        -- 4..10, exactly where the drawing paints one.
        --
        -- `plan` is the one thing the drawing CANNOT state: 2:1 is the
        -- projection, not the object, so reading rz as the plan radius
        -- too builds a box half as deep as it is wide -- a slab, not the
        -- cube the drawing depicts. `plan` names the real z radius and
        -- the drawn row is scaled into it, so a cube is `plan = rx` and
        -- the drawing still lands on it pixel for pixel.
        local pr0, pr1 = p.rows[1], p.rows[2]
        local rx = math.floor((x1 - x0 + 1) / 2)
        local rz = math.floor(rx / 2)
        local plan = p.plan or rz
        local oy = pr1 - rz
        local h = oy - rz - pr0
        local ytp = plane + h
        if ytp > ytop then ytop = ytp end
        for sx = x0, x1 do
          -- doubled, so a rect of even width keeps its centre between
          -- two columns instead of limping one to the left
          local dx2 = 2 * sx - (x0 + x1)
          for dz = -plan, plan do
            local z = p.z + dz
            local d2 = math.abs(dx2) * plan + 2 * math.abs(dz) * rx
            if z >= 0 and z < D and d2 <= (2 * rx + 1) * plan then
              -- the plan row scaled back into the drawn rhombus
              local dzs = math.floor((2 * dz * rz + plan) / (2 * plan))
              for y = 0, h do
                local sy = oy + dzs - y
                local i = sy * W + sx
                if sy >= pr0 and sy <= pr1 and inside[i] then
                  put(sx, plane + y, z, i)
                end
              end
            end
          end
        end
      elseif p.kind == "plan" then
        -- A PLAN part is a slab whose plan IS the drawn top view: the
        -- band's silhouette becomes the footprint pixel for pixel
        -- (drawn row = depth row, the same 1:1 every tabletop is drawn
        -- with), so an octagonal top stands as an octagon rather than
        -- the box no rectangular band can escape. The top layer wears
        -- the band itself, outline and all; the rim layers below wear
        -- the drawn fascia rows folded down the edge (x clamped into
        -- the drawn fascia's span), and the slab's unseen interior the
        -- field's dark texel.
        local r0, r1 = p.rows[1], p.rows[2]
        local f0, f1 = p.fascia[1], p.fascia[2]
        local fx0, fx1 = p.fasciaX[1], p.fasciaX[2]
        local rise = p.rise or 0
        local h = (f1 - f0 + 1) + 1
        if rise + h > ytop then ytop = rise + h end
        local function drawn(sx, z)
          return sx >= x0 and sx <= x1 and z >= 0 and z <= r1 - r0
                 and inside[(r0 + z) * W + sx]
        end
        for z = 0, r1 - r0 do
          if z >= 0 and z < D then
            local sy = r0 + z
            for sx = x0, x1 do
              if inside[sy * W + sx] then
                put(sx, rise + h - 1, z, sy * W + sx)
                local edge = not (drawn(sx - 1, z) and drawn(sx + 1, z)
                                  and drawn(sx, z - 1) and drawn(sx, z + 1))
                for y = rise, rise + h - 2 do
                  if edge then
                    local fsx = math.max(fx0, math.min(fx1, sx))
                    put(sx, y, z, (f0 + (rise + h - 2 - y)) * W + fsx)
                  else
                    put(sx, y, z, pr.shadeTexel[DARK])
                  end
                end
              end
            end
          end
        end
      elseif p.kind == "disc" then
        -- A DISC part is ROUND IN PLAN -- the pedestal column and base
        -- the projection can only draw from the front. Centre and
        -- radius are measured off the drawn widths (a flattened arc is
        -- a horizontal circle seen from above); the circular footprint
        -- is synthesized like any continued geometry, and every voxel
        -- still wears the drawing: the side folds the drawn face-on
        -- rows around the hull (x clamped into the drawn span, rows
        -- repeating up the height), and `cap` lays the drawn top-view
        -- rows over the top layer's interior, drawn north rows to the
        -- plan's north. `cx2`/`cz2` are DOUBLED plan centres, so an
        -- even diameter keeps its centre between two voxels instead of
        -- limping one off.
        local r, rise, h = p.r, p.rise or 0, p.h
        local s0, s1 = p.side.rows[1], p.side.rows[2]
        local sa0, sa1 = p.side.x[1], p.side.x[2]
        local sn = s1 - s0 + 1
        if rise + h > ytop then ytop = rise + h end
        local function inDisc(x, z)
          local dx = 2 * x + 1 - p.cx2
          local dz = 2 * z + 1 - p.cz2
          return dx * dx + dz * dz <= 4 * r * r
        end
        local zlo = math.floor((p.cz2 - 2 * r) / 2)
        for x = math.floor((p.cx2 - 2 * r) / 2),
                math.floor((p.cx2 + 2 * r) / 2) do
          for z = math.max(0, zlo),
                  math.min(D - 1, math.floor((p.cz2 + 2 * r) / 2)) do
            if inDisc(x, z) then
              local edge = not (inDisc(x - 1, z) and inDisc(x + 1, z)
                                and inDisc(x, z - 1) and inDisc(x, z + 1))
              for y = rise, rise + h - 1 do
                local sx, sy
                if p.cap and y == rise + h - 1 and not edge then
                  local c0, c1 = p.cap.rows[1], p.cap.rows[2]
                  sy = math.min(c1, c0 + math.floor((z - zlo)
                                                    * (c1 - c0 + 1)
                                                    / (2 * r)))
                  sx = math.max(p.cap.x[1], math.min(p.cap.x[2], x))
                else
                  sy = s0 + (rise + h - 1 - y) % sn
                  sx = math.max(sa0, math.min(sa1, x))
                end
                put(x, y, z, sy * W + sx)
              end
            end
          end
        end
      else
        local tr0, tr1 = p.top[1], p.top[2]
        local fr0, fr1 = p.facade[1], p.facade[2]
        local pd = p.depth
        -- `rise` lifts a part off the desk's top plane and `z` names its
        -- back-most depth row (the field a flat part already carries). An
        -- object STANDING on a desk needs neither: it starts on the plane
        -- at the plot's back. The healing machine's console needs both --
        -- it stands in the FRONT map row of a grid whose back row is the
        -- wall band it leans against, and its screen head is MOUNTED on
        -- the console's front two voxels above the body's top. Both come
        -- off the drawing, not off taste.
        local base = plane + (p.rise or 0)
        local pz = p.z or 0
        local ytp = base + (fr1 - fr0)
        if ytp > ytop then ytop = ytp end
        -- `inset` sinks an authored pane one voxel: the pane rule
        -- applied by hand, for a part whose screen IS sealed behind its
        -- own black frame while the template's `panes = false` (set for
        -- the polarity-inverted panel elsewhere in the same drawing)
        -- blocks the global pass. Same mechanism as a recess: the front
        -- voxel is simply not placed.
        local ins = p.inset
        for sx = x0, x1 do
          -- the lid: the part's drawn top laid across its depth from the
          -- back, last row continuing forward; the front lid row is the
          -- facade's own top row -- the drawn front-top edge.  `stretch`
          -- maps the drawn band over the whole depth instead, the tray's
          -- rule: for a part authored DEEPER than its drawing (the house
          -- stool grown past its drawn seat), clamping would print the
          -- last row as a long smear off the back band's edge.
          for z = pz, pz + pd - 1 do
            local front = z == pz + pd - 1
            local sy
            if front then
              sy = fr0
            elseif p.stretch then
              sy = math.min(tr0 + math.floor((z - pz) * (tr1 - tr0 + 1)
                                             / (pd - 1)), tr1)
            else
              sy = math.min(tr0 + z - pz, tr1)
            end
            while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end
            local ok = sy <= tr1 or (front and inside[fr0 * W + sx])
            if ok and z >= 0 and z < D then
              put(sx, ytp, z, (front and fr0 or sy) * W + sx)
            end
          end
          -- the body: facade rows anchored to the part's own base
          for sy = fr0 + 1, fr1 do
            local y = base + (fr1 - sy)
            local i = sy * W + sx
            if inside[i] then
              local ix = interiorAt(sx, sy, x0, x1)
              for z = pz, pz + pd - 1 do
                if z >= 0 and z < D then
                  if z == pz + pd - 1 then
                    local sunk = ins and sx >= ins.x[1] and sx <= ins.x[2]
                                 and sy >= ins.rows[1] and sy <= ins.rows[2]
                    if not sunk and not pr.recess[i] then put(sx, y, z, i) end
                  elseif z == pz then
                    put(sx, y, z, i)
                  else
                    put(sx, y, z, sy * W + ix)
                  end
                end
              end
            end
          end
        end
      end
    end
  end

  -- A TRAY is an open container -- the drawing looks down INTO it, so its
  -- top-view band is not a lid but the inside of the box, and the model
  -- has to be hollow. Bands, all measured 1:1 like any other band table:
  -- `top` is the opening (drawn row -> depth row), `front` the near wall
  -- seen face-on (drawn row -> elevation), `x` the box's outer span and
  -- `inner` the opening's, so the difference between them is the wall.
  -- Four walls stand to the rim, the floor slab lies `floor` voxels thick
  -- under the opening, and the cavity between them is left as AIR -- which
  -- is the whole point, and what an extruded facade can never be. Parts (a
  -- standing lid) then ride the rim like any object on a desk's plane.
  if t.tray then
    local tr = t.tray
    local top0 = tr.top[1]
    local fr0, fr1 = tr.front[1], tr.front[2]
    local bx0, bx1 = tr.x[1], tr.x[2]
    local ix0, ix1 = tr.inner[1], tr.inner[2]
    local floor = tr.floor or 0
    local plane = fr1 - fr0 + 1            -- the rim: the wall's height
    -- Which drawn row lies at depth z. The far rim is the band's first
    -- row and the near rim the front wall's own, and the drawn inside
    -- STRETCHES over whatever depth is between them: a box deeper than
    -- its drawing has rows to spare is the ordinary case once the plot
    -- stops being the grid, and the alternative -- running out of rows
    -- and repeating the last one -- would print the wrench twice.
    local lo, hi = top0 + 1, tr.top[2] - 1     -- the drawn inside
    local span = math.max(1, D - 3)            -- interior depth rows - 1
    local function trayRow(z)
      if z == 0 then return top0 end
      if z == D - 1 then return fr0 end
      return lo + math.floor((z - 1) * (hi - lo) / span)
    end
    for sx = bx0, bx1 do
      Budget.tick()
      for z = 0, D - 1 do
        local hollow = sx >= ix0 and sx <= ix1 and z > 0 and z < D - 1
        for y = 0, (hollow and floor or plane - 1) do
          if hollow or y == plane - 1 then
            -- the opening seen from above: the tray's own floor and
            -- whatever lies in it -- and the rim is the same band where
            -- the wall meets it
            local i = trayRow(z) * W + sx
            if inside[i] then put(sx, y, z, i) end
          else
            -- the wall below the rim: the front band folded up it, the
            -- drawn face on the front and back layers and the de-outlined
            -- interior between, exactly as a facade extrudes.
            --
            -- NO recess pass here, and it must stay that way: a pane sinks
            -- by DELETING its front voxel so the one behind becomes the
            -- pane, and a container's wall is one voxel thick -- there is
            -- nothing behind it, so the front panel simply opened a hole
            -- straight into the box and you could see the wrench through it.
            local sy = fr1 - y
            local i = sy * W + sx
            if inside[i] then
              local px = (z == 0 or z == D - 1) and sx
                         or interiorAt(sx, sy, bx0, bx1)
              put(sx, y, z, sy * W + px)
            end
          end
        end
      end
    end
    if plane > ytop then ytop = plane end
    buildParts(plane)
    return { at = function(x, y, z)
               if x < 0 or x >= W or y < 0 or z < 0 or z >= D then
                 return nil
               end
               return vox[key(x, y, z)]
             end,
             W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
  end

  -- No base piece at all: the drawing IS its parts (the house stool -- a
  -- seat and its legs, nothing under them but floor). The plane the parts
  -- anchor to is the ground itself.
  if not t.desk then
    buildParts(0)
    return { at = function(x, y, z)
               if x < 0 or x >= W or y < 0 or z < 0 or z >= D then
                 return nil
               end
               return vox[key(x, y, z)]
             end,
             W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
  end

  -- The desk's top plane. Usually the drawing states it: the fascia and
  -- base rows it paints below the objects ARE the front face, and their
  -- row count is the height. Bill's desk paints neither inside its grid
  -- -- its apron is drawn into the WALKABLE cell in front, and that cell
  -- is left out on purpose so the chair standing there keeps its own
  -- tiles -- so `plane` names the height directly and the body below the
  -- lid is synthesized: the band table's own rim treatment, a shaded box
  -- closed by the outline where it meets the floor, in the drawing's
  -- shades via shadeTexel.
  local f0, f1 = t.desk.fascia[1], t.desk.fascia[2]
  local b0, b1 = t.desk.base[1], t.desk.base[2]
  local plane = (b1 - b0 + 1) + (f1 - f0 + 1)

  -- The desk's own PLOT, when the grid holds more than the desk. Bill's
  -- grid runs on into the walkable cell, because the drawing puts the
  -- desk's apron AND the chair pushed up to it in the same tiles -- so
  -- the desk box has to stop at its own cell (`depth`) and stand on its
  -- own ground line rather than the grid's, which the chair's feet set
  -- eight rows lower. The base band's last row IS that ground line by
  -- definition, and for every desk drawn inside its own grid it is the
  -- measured one to the row (lab table, lab computers, Center PC, the
  -- Bike Shop toolbox), so this changes nothing for them.
  -- ...and in voxels (`depthPx`) plus a back origin (`z`) when the desk
  -- is shallower than a tile row and leans against something: the
  -- healing machine's cabinet is 10 deep -- its drawn top band's 9 rows
  -- plus the front edge -- standing against the wall band, so its box
  -- runs z 16..25 of a 32-deep plot.
  local deskD = t.desk.depthPx or (t.desk.depth and t.desk.depth * 8) or D
  local dz0 = t.desk.z or 0
  local dz1 = dz0 + deskD - 1
  local deskG = b1 + 1
  -- ...and the desk's COLUMNS (`x`), when the grid is wider than the
  -- desk: the healing machine's grid carries its flanking hoses and
  -- keyboard, and the cabinet is only the middle 16 columns.
  local dx0 = t.desk.x and t.desk.x[1] or 0
  local dx1 = t.desk.x and t.desk.x[2] or W - 1

  -- The WALL element: the band the machine backs onto, whose tiles this
  -- grid claims. The drawing shows it only as the stripe background
  -- around the tower (the same standing as the potted plants' floor),
  -- so the block cycles the drawing's own stripe unit -- real pixels of
  -- column `x`, rows `cycle` -- at wall-band height over the back plot,
  -- exactly what the neighbouring cells' `wall` pins render.
  if t.wall then
    local wl = t.wall
    local c0, c1 = wl.cycle[1], wl.cycle[2]
    local cn = c1 - c0 + 1
    local wx = wl.x or 0
    for y = 0, wl.h - 1 do
      Budget.tick()
      local sy = c0 + (wl.h - 1 - y) % cn
      for sx = 0, W - 1 do
        for z = 0, wl.depthPx - 1 do
          put(sx, y, z, sy * W + wx)
        end
      end
    end
  end

  -- the base band, extruded exactly like every lab table's
  for sy = b0, b1 do
    Budget.tick()
    local y = deskG - 1 - sy
    for sx = dx0, dx1 do
      if inside[sy * W + sx] then
        local ix = interiorAt(sx, sy, dx0, dx1)
        for z = dz0, dz1 do
          local px = (z == dz0 or z == dz1) and sx or ix
          put(sx, y, z, sy * W + px)
        end
      end
    end
  end
  for i in pairs(pr.recess) do
    local sy = math.floor(i / W)
    local sx = i % W
    if sy >= b0 and sy <= b1 and sx >= dx0 and sx <= dx1 then
      vox[key(sx, deskG - 1 - sy, dz1)] = nil
    end
  end

  -- the slab: fascia rows wrap every side
  for sy = f0, f1 do
    Budget.tick()
    local y = plane - 1 - (sy - f0)
    for sx = dx0, dx1 do
      for z = dz0, dz1 do put(sx, y, z, sy * W + sx) end
    end
  end

  if t.desk.top then
    -- The lid wears the desk's own drawn top band -- the drawing DOES
    -- paint this tabletop (the healing machine's white top face with
    -- its lit west and shaded east strips), so nothing is synthesized
    -- where it is visible: band rows map back-to-front, the first
    -- fascia row is the drawn front-top edge, same rule as an upright
    -- part's lid. Where a part's drawing occludes the band (the monitor
    -- standing on it), the lid continues the nearest strip BESIDE the
    -- part -- still the drawing's own pixels, the same sibling-pattern
    -- rule every synthesized lid follows.
    local tr0, tr1 = t.desk.top[1], t.desk.top[2]
    for z = dz0, dz1 do
      Budget.tick()
      local sy = z == dz1 and f0 or math.min(tr0 + (z - dz0), tr1)
      for sx = dx0, dx1 do
        local px = sx
        for _, p in ipairs(t.parts) do
          local px0, px1 = p.x[1], p.x[2]
          local r0, r1
          if p.kind == "flat" or p.kind == "iso" or p.kind == "box" then
            r0, r1 = p.rows[1], p.rows[2]
          else
            r0, r1 = p.top[1], p.facade[2]
          end
          if sx >= px0 and sx <= px1 and sy >= r0 and sy <= r1 then
            px = (sx - px0 < px1 - sx) and (px0 - 1) or (px1 + 1)
            px = math.max(dx0, math.min(dx1, px))
            break
          end
        end
        put(sx, plane - 1, z, sy * W + px)
      end
    end
  else
    -- the lid continues the sibling tables' top -- black rim, white
    -- highlight courses along the north and west, grey field
    local field = t.desk.lid == "white" and WHITE or GREY
    for sx = dx0, dx1 do
      for z = dz0, dz1 do
        local shade = field
        if sx == dx0 or sx == dx1 or z == dz0 or z == dz1 then
          shade = BLACK
        elseif sx == dx0 + 1 or z == dz0 + 1 then
          shade = WHITE
        end
        put(sx, plane - 1, z, pr.shadeTexel[shade])
      end
    end
  end

  if plane > ytop then ytop = plane end
  buildParts(plane)

  return { at = function(x, y, z)
             if x < 0 or x >= W or y < 0 or z < 0 or z >= D then return nil end
             return vox[key(x, y, z)]
           end,
           W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
end

-- ------- the doorway a gate house is entered by from a side the drawing
-- never shows it on (data/voxel_heights.lua `sideDoors`, and `sideDoorsAt`
-- below for how the placements are found).
--
-- One cell of the tileset's own doorway art, standing on the ground of the
-- face the player walks into, and hung by the SAME rule the drawn facade
-- hangs its own door by: the art's black frame stays flush with the wall
-- and everything it seals sinks a voxel behind it (`measure`'s pane pass,
-- applied here by hand because the art is not in the drawing to be flooded
-- with it). So a side door and a front door are the same depth of the same
-- opening, and the jamb faces the recess exposes come out of the mesher for
-- free, wearing the frame's own texels.
--
-- ORIENTATION IS NOT FREE. A flank quad carries one texel and the mesher
-- picks it per voxel, so which art column lands at which world coordinate
-- is decided HERE and nowhere else -- and a face is read from outside, so
-- the art's own left-to-right runs with the viewer's, not with the world's:
-- facing east at a west wall, south is to your right (+z); facing west at
-- an east wall, north is (-z); facing south at a north wall, west is (-x).
-- Two of the three are mirrored against the axis, which is the same reason
-- `backMap` exists -- a wall seen from behind IS the drawing mirrored.
local DOOR = 16

local function sideDoors(at, sp, doors, W)
  if not (doors and #doors > 0 and sp.door) then return at end
  local art = sp.door

  -- The face's OUTER surface, walked in from the box edge until the wall
  -- answers. A drawing inset from its own grid (B03's outer columns are
  -- terrain, not building) stands its flank a column or two in, and a door
  -- pinned to the box edge would hang in the air beside it.
  local function faceX(from, step, off)
    for k = 0, W - 1 do
      local x = from + step * k
      for y = 0, DOOR - 1 do
        for z = off, off + DOOR - 1 do
          if at(x, y, z) then return x end
        end
      end
    end
    return nil
  end

  local list = {}
  for _, d in ipairs(doors) do
    local face = 0                       -- north: the facade's own z origin
    if d.side == "w" then face = faceX(0, 1, d.at)
    elseif d.side == "e" then face = faceX(W - 1, -1, d.at) end
    if face then
      list[#list + 1] = { side = d.side, off = d.at, face = face }
    end
  end
  if #list == 0 then return at end

  -- art column at (x, z) for door `e`, or nil when the voxel is not in it
  local function column(e, x, z)
    if e.side == "n" then
      if (z == 0 or z == 1) and x >= e.off and x < e.off + DOOR then
        return e.off + DOOR - 1 - x, z == 0
      end
    elseif z >= e.off and z < e.off + DOOR then
      if e.side == "w" then
        if x == e.face then return z - e.off, true end
        if x == e.face + 1 then return z - e.off, false end
      else
        if x == e.face then return e.off + DOOR - 1 - z, true end
        if x == e.face - 1 then return e.off + DOOR - 1 - z, false end
      end
    end
    return nil
  end

  return function(x, y, z)
    local v = at(x, y, z)
    -- no wall here is the end of it: a door is hung ON the building, and
    -- nothing about it may add geometry the drawing does not stand up
    if v == nil or y >= DOOR then return v end
    for _, e in ipairs(list) do
      local c, outer = column(e, x, z)
      if c then
        -- art row 0 is the door's head, so the ground row is its last
        local k = (DOOR - 1 - y) * DOOR + c
        -- the art's own ring: wall, not door. The flank keeps its texel.
        if art.context[k] then return v end
        if art.black[k] then
          -- the frame, flush with the wall; behind it the wall stands on
          if outer then return art.base + k end
          return v
        end
        -- and what the frame seals sinks: the face voxel goes, the one
        -- behind it wears the art. (Written long: `outer and nil or i`
        -- returns i for BOTH, nil being false to `and`.)
        if outer then return nil end
        return art.base + k
      end
    end
    return v
  end
end

-- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite
-- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
-- order -- roof first, so it overwrites the walls it intersects, and walls
-- are trimmed to its underside so nothing pokes through the surface. A
-- gate's side doors are hung on the finished lookup, last of all, because
-- they answer to the FACE rather than to any band of the drawing.
local function model(sp, pr, t, doors)
  if t.parts then return deskSetModel(sp, pr, t) end
  local W, H, D = sp.W, sp.H, pr.D
  local slab, roofRows = t.slab, t.roofRows
  local top, ytop, ground = pr.top, pr.ytop, pr.ground
  local surfaceTop = pr.surfaceTop

  -- The roof's drawn span. A sprite inset from its box (B03) leaves outer
  -- columns undrawn in the roof band; they carry no roof at all, and the
  -- rim treatment belongs to the outermost drawn columns instead of the
  -- box edge.
  local x0d, x1d
  for x = 0, W - 1 do
    if top[x] < roofRows then
      x0d = x0d or x
      x1d = x
    end
  end
  local ledge0, ledge1 = nil, nil
  if t.ledge then ledge0, ledge1 = t.ledge[1], t.ledge[2] end

  local rz0, rz1 = 0, D - 1 + (t.frontEave or 0)
  local back, front = t.roofBack, t.roofFront
  local cyc0, cyc1 = t.roofCycle[1], t.roofCycle[2]
  local cycN = cyc1 - cyc0 + 1

  -- Which drawn row lies at depth z. The drawing looks at the roof from
  -- the north, so its top rows ARE the far edge and its bottom rows the
  -- eave over the facade. The band is shallower than the building, so the
  -- rims map one row per voxel and the middle cycles a run whose period is
  -- the course rhythm -- picked up where the north rim left off, which
  -- continues both the course lines and the roof texture seamlessly.
  local roofSy = {}
  for z = rz0, rz1 do
    local df, db = z - rz0, rz1 - z          -- from the north / south edge
    if df < back then
      roofSy[z] = df
    elseif db < front then
      roofSy[z] = roofRows - 1 - db
    else
      roofSy[z] = cyc0 + (df - cyc0) % cycN
    end
  end

  local T = {}
  for x = 0, W - 1 do T[x] = ytop - top[x] end

  -- the back layer's texel: the drawing again, minus what only the front
  -- may wear (see backMap)
  local back = sp.back
  local function backOf(i) return (back and back[i]) or i end

  local function at(x, y, z)
    if x < 0 or x >= W then return nil end
    local tx = T[x]

    -- roof: a solid of constant thickness following the elevation profile
    if top[x] < roofRows
        and y > tx - slab and y <= tx and z >= rz0 and z <= rz1 then
      if y == tx and x > x0d and x < x1d and z > rz0 and z < rz1 then
        -- the surface itself. Lifting the row into the column's first
        -- PAINTED row keeps the flank battens running down the slope
        -- instead of falling off the silhouette -- and off its cap, which
        -- is outline black and belongs to the rim, not to the surface.
        local sy = roofSy[z]
        if sy < surfaceTop[x] then sy = surfaceTop[x] end
        return sy * W + x
      end
      -- The rim reproduces the eave the drawing itself paints under the
      -- roof: a black outline, a shaded fascia, closed by the outline
      -- again. (A GREY fascia band -- what the first cut had -- comes out
      -- WHITE once the atlas is recoloured and turns every sloped end
      -- into a black-and-white zip.) Under the surface it is all shadow.
      local outer = x == x0d or x == x1d or z == rz0 or z == rz1
      if not outer then return pr.shadeTexel[DARK] end
      if y == tx or y == tx - slab + 1 then return pr.shadeTexel[BLACK] end
      return pr.shadeTexel[DARK]
    end

    -- trimmed: under the slope. A column with no roof over it has no
    -- underside to trim to, and must not be cut away by a profile the
    -- drawing never set.
    if top[x] < roofRows and y > tx - slab then return nil end

    -- the awning: the band juts two voxels past the walls, front and back
    if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then
      local sy = ground - 1 - y
      if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
        -- z < 0 is the awning's NORTH end: same substitution the wall
        -- behind it makes, so a band that carries a sign does not carry
        -- it round the back
        local i = sy * W + x
        return z < 0 and backOf(i) or i
      end
      return nil
    end

    -- the facade, extruded straight back over the footprint. Rows map
    -- against the measured ground line, not the grid's last row: the two
    -- differ only for furniture standing on open floor (see measure).
    if z < 0 or z >= D then return nil end
    local sy = ground - 1 - y
    local i = sy * W + x
    if y == 0 and not sp.inside[i] and sy > 0 and sp.inside[i - W] then
      -- the drawing's last row is the ground the building stands on, so
      -- its base course is one row up; without this the walls float a
      -- voxel over their own plot
      sy, i = sy - 1, i - W
    end
    if not sp.inside[i] then return nil end
    if z == D - 1 then
      if pr.recess[i] then return nil end
      return i
    end
    if z == 0 then return backOf(i) end
    return pr.interior[i]
  end

  at = sideDoors(at, sp, doors, W)

  return { at = at, W = W, ytop = ytop,
           zmin = ledge0 and -2 or 0,
           zmax = math.max(rz1, ledge0 and (D + 1) or 0) }
end

-- ------------------------------------------------------------------ emit --

-- Cull to the shell and merge. A run of faces collapses into one quad when
-- its texels are the SAME (a flat-coloured strip, which is most of a side
-- face) or ADJACENT IN THE ATLAS along the run (the drawing continuing
-- across the face, which is most of a front face or a roof top). Both keep
-- every texel exactly where the sprite put it.
local function emit(m, sp, atlasW, atlasH)
  local W = m.W
  local quads = { voxels = 0, shell = 0 }
  local cell = {}                        -- (y, z, x) -> sprite pixel index

  local zmin, zmax, ytop = m.zmin, m.zmax, m.ytop
  local zn = zmax - zmin + 1
  local function ci(x, y, z)
    if x < 0 or x >= W or y < 0 or y > ytop or z < zmin or z > zmax then
      return nil
    end
    return cell[(y * zn + (z - zmin)) * W + x]
  end
  for y = 0, ytop do
    Budget.tick()
    for z = zmin, zmax do
      local base = (y * zn + (z - zmin)) * W
      for x = 0, W - 1 do
        local v = m.at(x, y, z)
        cell[base + x] = v
        if v then quads.voxels = quads.voxels + 1 end
      end
    end
  end
  -- the shell: what survives hidden-face culling. Counted here rather than
  -- derived from the quads because it is the number
  -- tools/building_voxels.py checks this build against.
  for y = 0, ytop do
    Budget.tick()
    for z = zmin, zmax do
      for x = 0, W - 1 do
        if ci(x, y, z) and not (ci(x + 1, y, z) and ci(x - 1, y, z)
            and ci(x, y + 1, z) and ci(x, y - 1, z)
            and ci(x, y, z + 1) and ci(x, y, z - 1)) then
          quads.shell = quads.shell + 1
        end
      end
    end
  end

  -- u/v of a run: `n` texels starting at sprite pixel `i`, stepping along
  -- the atlas when the run is a strip and standing still when it is flat.
  local function uvOf(i, strip, n)
    local x0 = sp.ax[i]
    local y0 = sp.ay[i]
    local x1 = strip and (x0 + n) or (x0 + 1)
    return (x0 + 0.05) / atlasW, (x1 - 0.05) / atlasW,
           (y0 + 0.05) / atlasH, (y0 + 1 - 0.05) / atlasH
  end

  local function put(c1, c2, c3, c4, uv, shade)
    quads[#quads + 1] = { c1, c2, c3, c4, uv = uv, shade = shade }
  end

  -- How far a run of exposed faces reaches from `x`, and whether it is a
  -- strip (texels marching along the atlas) or flat (one texel repeated).
  local function runX(y, z, dx, dy, dz, x)
    local i0 = ci(x, y, z)
    local strip, n = nil, 1
    local cap = runCap(x)
    while n < cap do
      local nx = x + n
      local i = ci(nx, y, z)
      if not i or ci(nx + dx, y + dy, z + dz) then break end
      local prev = ci(nx - 1, y, z)
      if sp.ay[i] ~= sp.ay[prev] then break end
      local d = sp.ax[i] - sp.ax[prev]
      if d == 1 then
        if strip == false then break end
        strip = true
      elseif d == 0 then
        if strip == true then break end
        strip = false
      else
        break
      end
      n = n + 1
    end
    return i0, strip == true, n
  end

  -- ---- faces along +-Z (the facade, the roof's rims): merge along x ----
  for _, d in ipairs({ 1, -1 }) do
    local shade = d == 1 and SHADE.south or SHADE.north
    for y = 0, ytop do
      Budget.tick()
      for z = zmin, zmax do
        local x = 0
        while x < W do
          if ci(x, y, z) and not ci(x, y, z + d) then
            local i, strip, n = runX(y, z, 0, 0, d, x)
            local u0, u1, v0, v1 = uvOf(i, strip, n)
            local zf = d == 1 and (z + 1) or z
            if d == 1 then
              put({ x, y, zf }, { x + n, y, zf },
                  { x + n, y + 1, zf }, { x, y + 1, zf },
                  { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, shade)
            else
              put({ x + n, y, zf }, { x, y, zf },
                  { x, y + 1, zf }, { x + n, y + 1, zf },
                  { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } }, shade)
            end
            x = x + n
          else
            x = x + 1
          end
        end
      end
    end
  end

  -- ---- faces along +-Y (roof surfaces, undersides): merge along x ----
  for _, d in ipairs({ 1, -1 }) do
    local shade = d == 1 and SHADE.top or SHADE.bottom
    for y = 0, ytop do
      Budget.tick()
      -- the underside of the bottom layer is the ground it stands on
      if not (d == -1 and y == 0) then
        for z = zmin, zmax do
          local x = 0
          while x < W do
            if ci(x, y, z) and not ci(x, y + d, z) then
              local i, strip, n = runX(y, z, 0, d, 0, x)
              local u0, u1, v0, v1 = uvOf(i, strip, n)
              local yf = d == 1 and (y + 1) or y
              if d == 1 then
                put({ x, yf, z }, { x + n, yf, z },
                    { x + n, yf, z + 1 }, { x, yf, z + 1 },
                    { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } }, shade)
              else
                put({ x, yf, z + 1 }, { x + n, yf, z + 1 },
                    { x + n, yf, z }, { x, yf, z },
                    { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, shade)
              end
              x = x + n
            else
              x = x + 1
            end
          end
        end
      end
    end
  end

  -- ---- faces along +-X (the flanks): merge along z, one texel each ----
  for _, d in ipairs({ 1, -1 }) do
    for y = 0, ytop do
      for x = 0, W - 1 do
        local z = zmin
        while z <= zmax do
          local i = ci(x, y, z)
          if i and not ci(x + d, y, z) then
            local n, cap = 1, runCap(z)
            while n < cap and z + n <= zmax do
              local j = ci(x, y, z + n)
              if j ~= i or ci(x + d, y, z + n) then break end
              n = n + 1
            end
            local u0, u1, v0, v1 = uvOf(i, false, n)
            local xf = d == 1 and (x + 1) or x
            if d == 1 then
              put({ xf, y, z + n }, { xf, y, z },
                  { xf, y + 1, z }, { xf, y + 1, z + n },
                  { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
                  SHADE.side)
            else
              put({ xf, y, z }, { xf, y, z + n },
                  { xf, y + 1, z + n }, { xf, y + 1, z },
                  { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
                  SHADE.side)
            end
            z = z + n
          else
            z = z + 1
          end
        end
      end
    end
  end

  return quads
end

-- ------------------------------------------------------------- placement --

-- Does the template's tile grid sit at (tx, ty)?
local function matches(S, t, tx, ty)
  local tiles = t.tiles
  for r = 1, #tiles do
    local row = tiles[r]
    for c = 1, #row do
      if S.tileAt[keyOf(tx + c - 1, ty + r - 1)] ~= row[c] then
        return false
      end
    end
  end
  return true
end

-- ------- which of a placement's faces a gate is entered by
--
-- Read off the MAP, not authored: a gate entrance is a warp that lands in a
-- gate house, and the face is whichever one of this placement the warp cell
-- stands against. Thirty-odd doors fall out of two lines of geometry, and
-- none of them can drift out of step with a map edit the way a hand list
-- would. Three tests, each of them load-bearing:
--
--   the destination is a GATE tileset  -- what makes a building a gate house
--     rather than a house with a back door. GATE and FOREST_GATE both, so
--     the Viridian Forest pair count; the Safari rest houses are on GATE
--     too and are excluded by the next test, their warps being drawn doors
--     already.
--   the cell is not already a door tile -- a south entrance IS drawn, as a
--     doorway block in the facade, and lib/Structures.lua folds it up into
--     the front face. Adding a second one there would fight it.
--   the cell is WALKABLE -- the ROM gives an unreachable twin warp to
--     several gates (a fence cell beside the real opening on Route 7 west
--     and Route 16 east, a tree beside Route 6's), and a door on the wall
--     behind a fence is a door into nothing. The reachable cells are the
--     entrance, and two of them side by side are the gate's real two-cell
--     opening, which comes out as the double door it always was.
--
-- The south face is skipped whether or not it is drawn: it is the one face
-- the drawing states in full, so anything it needs it already has.
local function sideDoorsAt(map, tileset, tx, ty, bw, bh)
  -- through the module, NOT a global: `Game` is a local everywhere in the
  -- engine (`local Game = require("src.core.Game")` in a dozen files) and
  -- reading `_G.Game` came back nil every time -- which fails silently and
  -- exactly like the feature being off, because a nil map table is also
  -- what a headless build legitimately has.
  local ok, G = pcall(require, "src.core.Game")
  local defs = ok and G and G.data and G.data.maps
  local warps = map.def and map.def.warps
  if not (defs and warps and warps[1]) then return nil end
  if not Buildings.sideDoorCell(tileset.id) then return nil end

  local out = nil
  for _, w in ipairs(warps) do
    local dest = defs[w.destMap]
    if dest and dest.tileset and dest.tileset:find("GATE", 1, true)
       and map:isWalkableCell(w.x, w.y)
       and not map:isDoorTileCell(w.x, w.y) then
      -- the cell in the model's own pixels: a cell is two tiles, a tile
      -- eight pixels, and the placement's origin is (tx, ty) in tiles
      local lx, lz = w.x * 16 - tx * 8, w.y * 16 - ty * 8
      local side = nil
      if lz == -16 and lx >= 0 and lx < bw * 8 then side = "n"
      elseif lx == -16 and lz >= 0 and lz < bh * 8 then side = "w"
      elseif lx == bw * 8 and lz >= 0 and lz < bh * 8 then side = "e" end
      if side then
        out = out or {}
        out[#out + 1] = { side = side, at = side == "n" and lx or lz }
      end
    end
  end
  if out then
    table.sort(out, function(a, b)
      if a.side ~= b.side then return a.side < b.side end
      return a.at < b.at
    end)
  end
  return out
end

-- The model cache key's door half. A template's doors belong to the
-- PLACEMENT -- the same 6x4 block is the gate on four routes and the warps
-- sit at different rows of it on each -- so two placements of one drawing
-- are two models, and only placements that agree share one.
local function doorKey(doors)
  if not doors then return "" end
  local parts = {}
  for i, d in ipairs(doors) do parts[i] = d.side .. d.at end
  return "#" .. table.concat(parts, ",")
end

-- Find every placement of every template for this map's tileset, build one
-- model per template, and stamp it. Returns nothing; the quads land in
-- S.objectQuads and the tiles are claimed so the volume path never boxes a
-- building this module has already modelled.
function Buildings.build(S, map, data, perRow)
  if not data then return end
  local tileset = map.tileset
  local s = profile()
  local list = s and s.buildings and s.buildings[tileset.id]
  if not list then return end

  local atlasW = tileset.imageWidth or 128
  local atlasH = tileset.imageHeight or 48
  local tw, th = map.def.width * 4, map.def.height * 4
  local quads = S.objectQuads

  for index, t in ipairs(list) do
    if type(t.tiles) == "table" and #t.tiles > 0 then
      local bh, bw = #t.tiles, #t.tiles[1]
      local first = t.tiles[1][1]
      -- the model this placement stamps. Not hoisted out of the loops any
      -- more: a template's doors belong to the placement, so two hits of
      -- one drawing on the same map can be two models (see doorKey).
      local built = nil
      for ty = 0, th - bh do
        Budget.tick()
        for tx = 0, tw - bw do
          -- A placement never stamps into cells another template already
          -- claimed. Templates are matched independently, and one
          -- drawing can satisfy two grids: the Pokemon Tower's upper
          -- twelve rows on ROUTE_10 are a standard 6-cell block tile for
          -- tile, so `gabled_block_6x6` matched there and stood a whole
          -- second building behind the tower. First claim wins, so the
          -- list order below is the priority order -- the tower's own
          -- templates come first precisely so they take those cells.
          local free = S.tileAt[keyOf(tx, ty)] == first
          if free then
            for r = 0, bh - 1 do
              for c = 0, bw - 1 do
                if S.skip[keyOf(tx + c, ty + r)] then
                  free = false
                  break
                end
              end
              if not free then break end
            end
          end
          if free and matches(S, t, tx, ty) then
            do
              -- keyed per PLACEMENT once a gate's doors are in play (see
              -- doorKey): the drawing is shared, the openings are not
              local doors = not t.claimOnly
                            and sideDoorsAt(map, tileset, tx, ty, bw, bh)
                            or nil
              local key = tileset.id .. ":" .. index .. doorKey(doors)
              if not models[key] then
                if t.claimOnly then
                  -- claim the cells, stamp nothing: the drawing here is
                  -- the off-map half of a building another map models in
                  -- full (the tower's roof rows on ROUTE_10 -- Lavender's
                  -- placement composites them via topRows). Left to the
                  -- detector they stood as a second half-building.
                  models[key] = {}
                else
                  local sp = read(t, data, perRow,
                                  Buildings.frontOnly(tileset.id))
                  if doors then
                    readDoor(sp, data, perRow,
                             Buildings.sideDoorCell(tileset.id))
                  end
                  local pr = measure(sp, t)
                  models[key] = emit(model(sp, pr, t, doors), sp,
                                     atlasW, atlasH)
                end
              end
              built = models[key]
            end
            Buildings.stamp(S, map, built, tx, ty, bw, bh, t)
          end
        end
      end
    end
  end
end

-- One placement: claim its tiles (so the detector leaves them alone and
-- the mesher paints ground under them) and copy the model into place.
--
-- Two template fields alter what a claim means, for a drawing that
-- carries a STANDEE on its surface (Red's potted plant on the dining
-- table). `keep` names tile ids the stamp must NOT claim: their authored
-- pins stay live, so the standee scan still stands the object exactly as
-- it always did. `support` is the model's top plane in voxels: the claim
-- shape carries it as its height, which is what tells that scan the
-- standee's shelf -- a plain claim stays at h = 0, and Structures treats
-- a building claim with height as a full model (skip, never a second
-- box; see its support branches).
function Buildings.stamp(S, map, quads, tx, ty, bw, bh, t)
  local shape = { class = "building", h = (t and t.support) or 0,
                  art = "building", flat = false, authored = true }
  local keep = nil
  if t and t.keep then
    keep = {}
    for _, id in ipairs(t.keep) do keep[id] = true end
  end

  -- the ground the building stands on: the commonest flat tile around its
  -- feet, so a house on a path keeps its path
  local votes, best, bestN = {}, nil, 0
  local function vote(x, y)
    local k = keyOf(x, y)
    local ns = S.shapeAt[k]
    if ns and ns.flat and ns.class ~= "void" then
      local tile = S.tileAt[k]
      votes[tile] = (votes[tile] or 0) + 1
      if votes[tile] > bestN then best, bestN = tile, votes[tile] end
    end
  end
  for c = 0, bw - 1 do
    vote(tx + c, ty - 1)
    vote(tx + c, ty + bh)
  end
  for r = 0, bh - 1 do
    vote(tx - 1, ty + r)
    vote(tx + bw, ty + r)
  end

  for r = 0, bh - 1 do
    for c = 0, bw - 1 do
      local k = keyOf(tx + c, ty + r)
      if keep and keep[S.tileAt[k]] then
        -- unclaimed by request: the tile keeps its pin (the plant's
        -- cutout pool) and the standee scan finds it there. Only the
        -- ground is set now, so the scan's own claim of these tiles has
        -- the building's floor to paint when no flat tile touches a
        -- cluster ringed by its own furniture.
        S.ground[k] = best or false
      else
        S.shapeAt[k] = shape
        S.skip[k] = true
        S.ground[k] = best or false
      end
    end
  end

  local mx, mz = tx * 8, ty * 8
  local out = S.objectQuads
  for _, q in ipairs(quads) do
    out[#out + 1] = {
      { q[1][1] + mx, q[1][2], q[1][3] + mz },
      { q[2][1] + mx, q[2][2], q[2][3] + mz },
      { q[3][1] + mx, q[3][2], q[3][3] + mz },
      { q[4][1] + mx, q[4][2], q[4][3] + mz },
      uv = q.uv, shade = q.shade,
      -- placements only ever scan the BODY, so a building is always this
      -- map's own structure: the mesher's edge keep-rules must not eat
      -- the parts that poke past the boundary (an edge-row house's eave
      -- juts frontEave voxels into the neighbour's airspace, and the
      -- neighbour-body mask read that overhang as a ring scrap -- which
      -- opened the roof rim into the sky from across the seam)
      own = true,
    }
  end
end

-- What the models built so far cost, keyed "<tileset>:<index>": the voxel
-- and shell counts tools/building_voxels.py checks this implementation
-- against (Stage 5 of the methodology), and the quad count that ships.
function Buildings.stats()
  local out = {}
  for key, quads in pairs(models) do
    out[key] = { voxels = quads.voxels, shell = quads.shell,
                 quads = #quads }
  end
  return out
end

-- Drop the prebuilt models (hot reload, or a mod shadowing the profile).
function Buildings.invalidate()
  spec = nil
  models = {}
  frontSets = {}
end

return Buildings