Files
DramaticShapeVoxelMod/lib/Structures.lua
T
Code-Grub 20f9e19bf9 stop a flat top stamping its rim down the plateau
A cliff mound is drawn as a rim over a body: its top edge, then the same
rock the whole way down. The top face cycles the first two drawn rows to
fill its depth, so it laid that rim again every second tile. The mound the
Diglett's Cave mouth is cut into came out with three rim lines across it
instead of one along its north edge.

Where the drawing says the body is all one tile, lay the rim once and hold
the body after it. Art that genuinely repeats keeps cycling: the Safari
Zone's fence alternates two tiles the whole way down, and there the repeat
is what the drawing says.

Answered per column and per region, because each catches what the other
misses. The columns carrying a mound's cave mouth end in the mouth's own
tiles, so per column alone they kept cycling while their neighbours held,
leaving rim stubs above the doorway. A region vote alone silences a real
rim-over-body column standing in a region of repeating art, of which the
Safari Zone has three. A column holds if either says so.

Geometry is untouched: the silhouette is pixel for pixel what it was, and
only the texel a top face wears changes. Of 3088 flat-topped runs, the
1336 rim-over-body ones change and nothing else does.

tests/flat_top_test.lua walks every map and fails if any rim-over-body run
revisits an earlier drawn row.
2026-08-06 13:04:12 -04:00

3786 lines
155 KiB
Lua

-- Voxel world mode: detect the map's structures and pick a 3D model for
-- each -- the 3dSen idea applied to a tile map. 3dSen turns flat NES
-- scenes into 3D by classifying every graphic into a geometry archetype
-- (floor, wall, box, voxelized sprite) and building real geometry that
-- keeps the original art as its texture; this module does the same with
-- the map's tile layer as the scene description:
--
-- 1. Flood-fill every connected region of solid (upright, unauthored)
-- tiles -- a house with its mailbox, the potted plant, a fence row,
-- a stretch of border forest.
--
-- 2. Decide which pixels of the region's art are BACKGROUND. Tileset
-- art carries no alpha and white is a paint color (window frames,
-- wall stripes), so whiteness alone says nothing. The map does: the
-- background is the white that CONNECTS TO WALKABLE GROUND in the
-- assembled scene. Seeding a flood from the surrounding ground
-- eats the air around a fence post or a plant's leaves but cannot
-- reach an interior wall's white stripes sealed behind its dark
-- trim -- exactly the distinction a human reads.
--
-- 3. Tiles whose art turned out mostly background are SPRITE-LIKE;
-- their connected clusters become per-pixel voxel OBJECTS at the
-- art's real drawn height (a 2-row plant is a 16px silhouette, a
-- fence a row of true posts with air between), thin voxel depth,
-- standing on synthesized ground. This splits mixed regions: the
-- mailbox voxelizes even where it touches the house.
--
-- 4. Everything else becomes a VOLUME: each column rises to the height
-- the structure is actually DRAWN. A column's run gives its extent,
-- repetition caps it -- the border forest repeats a 2-row canopy
-- for forty rows and must be rows of 16px trees, not a monolith --
-- and columns answer to their region: the column above a doorway
-- repeats internally but adopts its 48px house. The south face
-- folds the artwork up (ChunkMesher's band rule).
--
-- data/voxel_heights.lua is the PROFILE over this: a tile authored there
-- (ledges, or a mod pinning a shape) bypasses detection entirely, the way
-- a 3dSen game profile pins a pattern to a geometry type.
--
-- Everything here is derived per map and cached; pixel access (object
-- voxelization, void detection) degrades gracefully headless -- regions
-- simply stay volumes and the geometry tests keep passing.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Assets = require("src.render.Assets")
local Map = require("src.world.Map")
local Buildings = V.require("Buildings")
local TileShape = V.require("TileShape")
local Budget = V.require("BuildBudget")
local Structures = {}
-- must match ChunkMesher's ring (3 border blocks, in tiles)
local RING = 12
-- how far past the map body cells still get the hull. A route's ring is
-- nearly as big as its body; modelling all of it costs hundreds of
-- thousands of quads of border trees nobody walks near. Beyond this,
-- pinned cells simply are not claimed and fall through to the mesher's
-- plain box -- cheap distant scenery. (Declared up here rather than
-- beside buildCylinders because forMap's grid resolve reads it too.)
local ROUND_RING = 4
-- object-mode gates
local OBJECT_MAX_ROWS = 6 -- a prop is at most 48px of drawing
local OBJECT_MAX_QUADS = 4096 -- safety cap per cluster
local TILE_BG_RATIO = 0.20 -- art background for "sprite-like"
local CLUSTER_MIN_BG = 0.05 -- a silhouette must actually exist
local OBJECT_DEPTH = 6 -- voxel thickness of a detected prop
-- thickness of profile-pinned standees per class: a TV is a deliberate
-- object and reads better with body; `prop` doubles as the THIN pool
-- (plants, stools -- mostly silhouette); `cutout` is paper: one voxel,
-- pure profile; `post` matches the 6px the detector gives the fence
-- rows it finds on its own, so pinned and detected fences look alike;
-- `signpost` is a plate on a stick -- 2 voxels, the thinnest that still
-- shows an edge; `bike` is the same 2 for the same reason from the other
-- direction -- a bicycle drawn side-on is a LINE drawing whose negative
-- space is the drawing, and at the 5 voxels `prop` gives, the side faces
-- of neighbouring strokes close every gap in it off-axis
local PINNED_DEPTH = { billboard = 10, prop = 5, stool = 10, cutout = 1,
console = 10, post = 6, signpost = 2, bike = 2 }
local MAX_ROWS = 6 -- volume height cap: 48px
local cache = {}
-- ---------------------------------------------------------------- pixels --
local atlasData = {}
local function pixels(tileset)
local path = tileset.image
if atlasData[path] == nil then
local ok, data = pcall(Assets.imageData, path)
atlasData[path] = (ok and data and data.getPixel) and data or false
end
return atlasData[path] or nil
end
-- tiles whose art is entirely black or transparent (interior darkness):
-- these never extrude, whatever class they resolved to
local function voidTiles(tileset)
local data = pixels(tileset)
if not data then return nil end
local perRow = tileset.tilesPerRow or 16
local iw, ih = data:getDimensions()
local set = {}
for t = 0, (iw / 8) * (ih / 8) - 1 do
local ox = (t % perRow) * 8
local oy = math.floor(t / perRow) * 8
local void = true
for py = 0, 7 do
for px = 0, 7 do
local r, g, b, a = data:getPixel(ox + px, oy + py)
if a > 0 and math.max(r, g, b) > 0.17 then
void = false
break
end
end
if not void then break end
end
if void then set[t] = true end
end
return set
end
-- ----------------------------------------------------------------- build --
local DIRS4 = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
local function keyOf(tx, ty)
return (ty + 64) * 4096 + (tx + 64)
end
function Structures.forMap(map)
local S = cache[map.id]
if S then return S end
local tileset = map.tileset
local shapes = TileShape.forMap(map)
local void = voidTiles(tileset)
local perRow = tileset.tilesPerRow or 16
local def = map.def
local tw, th = def.width * 4, def.height * 4
local x0, x1 = -RING, tw + RING - 1
local y0, y1 = -RING, th + RING - 1
-- resolve the whole grid once: shape + tile per key. Ring positions use
-- the same border override the 2D renderer draws with
-- (TileRenderer.borderBlockFor: outdoor maps ring with the solid tree
-- wall, NOT their own borderBlock) -- a route's borderBlock is the GRASS
-- block, and meshing that grew a 12-tile apron of tall grass past every
-- route edge, which leaked into the neighbouring town's plaza.
-- BLACK void fill is not a block at all: borderBlockFor answers `false`,
-- and there is simply nothing out there to build. tileLookup then returns
-- nil past the body and the ring keys are never written, which the whole
-- file already copes with -- every neighbour query reaches one step
-- outside the analysed range and reads nil for its trouble, so an absent
-- cell is the shape "nothing" has always had here. (It used to add 1 to
-- that `false`, which threw, failed the mesh build for every map on the
-- route, and dropped the mode to the flat 2D path entirely.)
local TileRenderer = require("src.render.TileRenderer")
local borderId = TileRenderer.borderBlockFor(map)
local borderBlk = borderId and tileset.blocks[borderId + 1] or nil
-- TREES fill stops at ROUND_RING instead of running the full RING.
-- Only that far out does a tree cell get carved into a hull; past it
-- the cells fall through to the mesher's plain box, and a slab of
-- flat-topped boxes beside the modelled wall reads as a painted-on
-- plateau -- the wall looking like it was cut off with scissors. So
-- the far ring is simply not built: beyond ROUND_RING tileLookup
-- answers nil, which is the same "nothing out there" BLACK already
-- produces and every pass below already copes with. The cut lands on
-- the carve boundary exactly -- the 2x2-cell canopy scan starts at
-- floor(-RING/2) and RING, ROUND_RING and the body are all multiples
-- of 4 tiles, so no group is left half-resolved at the edge.
--
-- WATER and the other tilesets' own borders keep the full ring: a flat
-- sheet of water is what water looks like from above anyway, and an
-- interior's border is black already.
local hullRingOnly = borderBlk and def.tileset == "OVERWORLD"
and (TileRenderer.voidFill or "trees") == "trees"
local tw2, th2 = tw, th
local function tileLookup(tx, ty)
if tx >= 0 and ty >= 0 and tx < tw2 and ty < th2 then
return map:tileAt(tx, ty)
end
if not borderBlk then return nil end
if hullRingOnly and (tx < -ROUND_RING or ty < -ROUND_RING
or tx >= tw2 + ROUND_RING
or ty >= th2 + ROUND_RING) then
return nil
end
return borderBlk[(ty % 4) * 4 + (tx % 4) + 1] or 0
end
local shapeAt, tileAt = {}, {}
for ty = y0, y1 do
for tx = x0, x1 do
Budget.tick()
local tile = tileLookup(tx, ty)
if tile then
local k = keyOf(tx, ty)
local s = TileShape.at(map, shapes, tile, tx, ty)
if s and void and void[tile] and not s.authored then
s = shapes.classes.void
end
shapeAt[k], tileAt[k] = s, tile
end
end
end
-- ---- buildings: whole sprites voxelized band by band ----
--
-- Before anything else looks at this grid. A profiled building is a
-- drawing whose bands depict DIFFERENT 3D surfaces (roof from above,
-- facade face-on, ends sloped), and the passes below -- the door fold,
-- the region flood, the volume builder -- all assume one drawing is one
-- upright thing. Modelling the building first and claiming its tiles
-- keeps every one of them off it.
--
-- (grassQuads live apart from objectQuads: grass renders as its own mesh
-- AFTER the characters -- see VoxelScene -- so the southern tuft row
-- still overdraws a walker's feet even though characters stamp over
-- terrain.)
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
hideBareRing = hullRingOnly or nil,
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} }
Buildings.build(S, map, pixels(tileset), perRow)
-- Fold doors into their buildings. A door cell is WALKABLE (the player
-- steps onto it to warp), so it resolves to ground and punches a hole in
-- the facade: the door lies flat, the rows above it recess, and -- worse
-- -- the hole lets the background flood into the building's interior
-- whites, shredding it into misdetected sprite clusters. Visually the
-- door is part of the facade, so mark the door cell's tiles structural:
-- the fold then shows the door art standing at ground level in the
-- building's front face. Door graphics only (the tileset's doorTiles);
-- interior stair/mat warps stay flat.
--
-- A PROFILE PIN WINS over the fold. The fold is detection, and rule 1
-- of the resolution order is that an authored tile bypasses detection
-- -- but this used to overwrite shapeAt unconditionally, so a pin on
-- any tile the tileset also lists in doorTiles was dead on arrival.
-- Celadon Mansion is the case that found it: all four of its
-- staircases are door tiles, so `stair_e` / `stair_down_w` pins there
-- silently did nothing and the flights stayed painted on the floor.
for cy = math.floor(y0 / 2), math.floor(y1 / 2) do
for cx = math.floor(x0 / 2), math.floor(x1 / 2) do
if map.doorTiles[map:cellTile(cx, cy)] then
local northK = keyOf(cx * 2, cy * 2 - 1)
local ns = shapeAt[northK]
if ns and ns.art == "upright" then
for dy = 0, 1 do
for dx = 0, 1 do
local dk = keyOf(cx * 2 + dx, cy * 2 + dy)
local ds = shapeAt[dk]
if not (ds and ds.authored) then
shapeAt[dk] = shapes.classes.wall
-- remembered for buildVolume: a folded doorway column
-- answers to its REGION for height and top, not to its
-- own drawn extent (see the door adoption there)
S.doorFold[dk] = true
end
end
end
end
end
end
end
-- a structure cell: solid art the detector may model (authored tiles are
-- profile-pinned and keep their authored shape)
local function structural(k)
local s = shapeAt[k]
return s and s.art == "upright" and not s.authored
end
-- ---- cylinders: profile-pinned round graphics, one per 16x16 cell ----
-- the flat ground tiles this map actually places, for the hull's
-- ground matching: the ball's own drawn background picks its floor
local groundTiles = {}
do
local seenG = {}
for k, s in pairs(shapeAt) do
if s and s.flat and s.class == "ground" then
local t = tileAt[k]
if t and not seenG[t] then
seenG[t] = true
groundTiles[#groundTiles + 1] = t
end
end
end
end
Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
-- ---- stairs: profile-pinned cells that render as real steps ----
Structures.buildStairs(S, map, x0, x1, y0, y1)
-- ---- bookcases: pinned shelves collapsed to one cell of depth ----
-- The atlas comes along so the shelf front can carry its own measured
-- relief: the panes it seals behind its black frames sink a voxel.
Structures.buildBookcases(S, map, x0, x1, y0, y1, pixels(tileset), perRow)
-- ---- figures: a person drawn INTO furniture, lifted off it ----
-- Before the region flood and the volume pass, so everything after this
-- reads the tiles the profile says are there once the figure is gone.
-- (Its own tiles are authored furniture or walkable floor either way, so
-- no pass below would have claimed them -- but the repaint is what those
-- passes should see, and this needs no pixel access to do it.)
Structures.buildFigures(S, map, x0, x1, y0, y1)
-- ---- mounted: a thing drawn INTO a wall band, stood proud of it ----
-- Here for the same reason and with the same guarantee as the figures
-- above: the repaint hands every pass below the plain panel the profile
-- says is behind the object, so the wall band it was painted into keeps
-- resolving as the wall it is -- without a second copy of the drawing
-- flat on its face.
Structures.buildMounted(S, map, x0, x1, y0, y1)
-- ---- flood-fill regions of structural tiles ----
local seen = {}
local regions = {}
for ty = y0, y1 do
for tx = x0, x1 do
local k = keyOf(tx, ty)
if structural(k) and not seen[k] then
local region = { tiles = {}, minX = tx, maxX = tx,
minY = ty, maxY = ty }
local queue = { { tx, ty } }
seen[k] = true
while #queue > 0 do
Budget.tick()
local c = table.remove(queue)
local cx, cy = c[1], c[2]
region.tiles[#region.tiles + 1] = c
region.minX = math.min(region.minX, cx)
region.maxX = math.max(region.maxX, cx)
region.minY = math.min(region.minY, cy)
region.maxY = math.max(region.maxY, cy)
for _, d in ipairs(DIRS4) do
local nx, ny = cx + d[1], cy + d[2]
if nx >= x0 and nx <= x1 and ny >= y0 and ny <= y1 then
local nk = keyOf(nx, ny)
if structural(nk) and not seen[nk] then
seen[nk] = true
queue[#queue + 1] = { nx, ny }
end
end
end
end
regions[#regions + 1] = region
end
end
end
-- ---- model each region: carve out per-pixel objects, volume the rest --
local data = pixels(tileset)
for _, region in ipairs(regions) do
local leftover = region.tiles
if data then
leftover = Structures.extractObjects(S, map, region, data, perRow)
end
if #leftover > 0 then
Structures.buildVolume(S, map, leftover)
end
end
-- ---- profile-pinned billboards (signs): forced per-pixel slabs ----
if data then
local seenB = {}
for ty = y0, y1 do
for tx = x0, x1 do
local k = keyOf(tx, ty)
local s = shapeAt[k]
if s and s.art == "billboard" and not seenB[k] then
local reg = { tiles = {}, minX = tx, maxX = tx,
minY = ty, maxY = ty }
local queue = { { tx, ty } }
seenB[k] = true
while #queue > 0 do
local c = table.remove(queue)
reg.tiles[#reg.tiles + 1] = c
reg.minX = math.min(reg.minX, c[1])
reg.maxX = math.max(reg.maxX, c[1])
reg.minY = math.min(reg.minY, c[2])
reg.maxY = math.max(reg.maxY, c[2])
for _, d in ipairs(DIRS4) do
local nk = keyOf(c[1] + d[1], c[2] + d[2])
local ns = shapeAt[nk]
-- same CLASS, not just billboard art: `billboard` and
-- `prop` are two pools precisely so touching drawings (a TV
-- behind its console) become two standing objects instead
-- of one stacked cutout
if ns and ns.art == "billboard" and ns.class == s.class
and not seenB[nk] then
seenB[nk] = true
queue[#queue + 1] = { c[1] + d[1], c[2] + d[2] }
end
end
end
Structures.extractObjects(S, map, reg, data, perRow, true)
end
end
end
-- ---- profile-pinned fence posts: per-CELL standee slabs ----
-- A fence line repeats one drawing for a dozen cells, and its art
-- touches across cell seams. Pooled like a billboard the whole line
-- would stand as ONE drawing-tall tower at one depth (the detector's
-- vertical-repetition guard exists precisely to refuse that, which
-- is why undetected fence columns fell to the volume path as boxes).
-- Each CELL extracts alone instead: its posts stand in their own row
-- band and the fence marches north cell by cell.
local postCells = {}
for ty = y0, y1 do
for tx = x0, x1 do
local s = shapeAt[keyOf(tx, ty)]
if s and s.art == "post" then
local ck = keyOf(math.floor(tx / 2), math.floor(ty / 2))
postCells[ck] = postCells[ck] or {}
local list = postCells[ck]
list[#list + 1] = { tx, ty }
end
end
end
for _, tiles in pairs(postCells) do
local reg = { tiles = tiles,
minX = tiles[1][1], maxX = tiles[1][1],
minY = tiles[1][2], maxY = tiles[1][2] }
for _, c in ipairs(tiles) do
reg.minX = math.min(reg.minX, c[1])
reg.maxX = math.max(reg.maxX, c[1])
reg.minY = math.min(reg.minY, c[2])
reg.maxY = math.max(reg.maxY, c[2])
end
Structures.extractObjects(S, map, reg, data, perRow, "opaque")
end
-- ---- profile-pinned relief props: top-down drawings that extrude ----
local seenR = {}
for ty = y0, y1 do
for tx = x0, x1 do
local k = keyOf(tx, ty)
local s = shapeAt[k]
if s and s.art == "relief" and not seenR[k] then
local reg = { tiles = {}, minX = tx, maxX = tx,
minY = ty, maxY = ty }
local queue = { { tx, ty } }
seenR[k] = true
while #queue > 0 do
local c = table.remove(queue)
reg.tiles[#reg.tiles + 1] = c
reg.minX = math.min(reg.minX, c[1])
reg.maxX = math.max(reg.maxX, c[1])
reg.minY = math.min(reg.minY, c[2])
reg.maxY = math.max(reg.maxY, c[2])
for _, d in ipairs(DIRS4) do
local nk = keyOf(c[1] + d[1], c[2] + d[2])
local ns = shapeAt[nk]
if ns and ns.art == "relief" and ns.class == s.class
and not seenR[nk] then
seenR[nk] = true
queue[#queue + 1] = { c[1] + d[1], c[2] + d[2] }
end
end
end
for _, c in ipairs(reg.tiles) do
local ck = keyOf(c[1], c[2])
S.skip[ck] = true
S.ground[ck] = false
end
Structures.buildRelief(S, map, reg, data, perRow, s.h or 5)
end
end
end
-- ---- tall grass: two standing tuft rows per tile. BODY only: the 2D
-- renderer never draws a neighbour's ring, and standing scenery past a
-- map's edge would poke into the map next door ----
Structures.buildGrass(S, map, 0, tw - 1, 0, th - 1, data)
-- ---- flowers: the animated meadow tile stands as a 1px cutout ----
Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
end
-- ---- authored ground under pinned props ----
-- The profile can name the tile a pinned prop stands on (a tileset
-- entry's prop_ground: prop tile id -> ground tile id), overriding
-- the neighbour vote. The cuttable bush stands on the plain grass
-- Cut itself leaves behind, not on whatever path its neighbours
-- happen to vote in.
do
local okP, prof = pcall(V.data, "voxel_heights")
local entry = okP and type(prof) == "table" and prof.tilesets
and prof.tilesets[tileset.id]
local pg = entry and entry.prop_ground
if type(pg) == "table" then
for k, skipped in pairs(S.skip) do
if skipped then
local g = pg[S.tileAt[k]]
if g then S.ground[k] = g end
end
end
end
end
-- unresolved claimed ground (a hull with no art match, headless
-- cylinders): no flat neighbour to vote with, so fall back to the
-- map's commonest ground tile
local votes, best, bestN = {}, nil, 0
for k, s in pairs(shapeAt) do
if s and s.flat and s.class == "ground" then
local t = tileAt[k]
votes[t] = (votes[t] or 0) + 1
if votes[t] > bestN then best, bestN = t, votes[t] end
end
end
for k, g in pairs(S.ground) do
if g == false then S.ground[k] = best end
end
cache[map.id] = S
return S
end
-- ---- round scenery: outline-hulled voxel balls ----
-- Cells the profile pins as round (tree canopies -- the class keeps its
-- historical `cylinder` name in the data file) render as a VOXEL HULL cut
-- from the drawing itself. The first shipped attempt was a lathe -- the
-- per-row silhouette width revolved into a 12-segment column with the art
-- wrapped by sin(angle) -- and it read exactly like what it was: the
-- sprite pasted on a cylinder, with the wrap smearing the pixels into
-- vertical stripes. This replaces it with real voxels.
--
-- Segmentation first, silhouette-width second: the tree cell's art is a
-- ball drawn over background grass, and the background's mid greens pass
-- any brightness test (they inflated every lathe row to full width). The
-- ball's own DARKEST pixels are what bound it, so the mask is "the
-- darkest-shade outline plus everything it encloses": flood from the cell
-- border through every non-black pixel; what the flood cannot reach is
-- the tree, and the cast shadow under the canopy (dark but not enclosed)
-- floods away with the grass. Art with no closed black outline -- the
-- border tree wall is a dither of black and canopy with no drawn ring --
-- encloses nothing; there the flood passes only through the LIGHT shades
-- (the methodology doc's rule: black and dark together form the
-- boundary), and the dither mass itself becomes the mask, checker holes
-- and all, because a 4-connected flood cannot thread a diagonal checker.
--
-- Volume: each mask row is a disc. The row's span gives a center and
-- half-width, and every mask pixel's column runs that circle's chord in
-- z, quantized to whole voxels -- the front view IS the sprite, the plan
-- view is the sprite's own width profile turned in depth, and both step
-- pixel by pixel. Rows below the mask (the drawn shadow) repeat the
-- bottom row's discs down to the ground so the canopy stands on a short
-- dark foot instead of floating.
--
-- Skin: front and back faces carry the drawing per-pixel (the back reads
-- mirrored, sprite-pure); side and step faces take their column's own
-- texel, which puts the drawn outline exactly on the silhouette's rim;
-- and a fully exposed cap keeps its outline only on the rim cells while
-- the interior samples the canopy a couple of rows deeper -- painting the
-- whole cap with the outline row blacked out every dome on the first
-- attempt (the lathe hit the same bug with its top discs).
--
-- Tree walls repeat the same four tiles for hundreds of cells, so the
-- hull is built once per distinct art signature and stamped per cell.
local ROUND_SHADE = { front = 1.0, back = 0.68, side = 0.78,
top = 1.0, bottom = 0.55 }
-- The potted plant's ORGANIC HALF: the leaf crown (16 rows), then the
-- trunk, its root flare and the strands draping over the pot's rim (8
-- more) -- all of it stands as a slab this many voxels deep instead of
-- revolving. `depth` 5 is the thin standee pool's depth, what every other
-- interior plant already uses.
--
-- `rows` = 24 puts the slab/revolve boundary AT THE VESSEL'S RIM ROW, and
-- that placement is what makes the pot read as a pot. The first cut put
-- it at the cell seam (16), which let the root and drape rows revolve:
-- their drawn spans are 8-12 wide, so they stacked 8-12-deep discs on top
-- of the rim and the whole base read as one bulbous onion instead of a
-- flat-mouthed planter with a trunk standing out of it. Only rows 24-31
-- -- black rim edge, gold band, body, foot, the drawn flowerpot profile
-- -- are the vessel, and only they revolve.
local PLANTER_SPRAY = { rows = 24, depth = 5 }
-- `spray`, when given, caps the chord over the canvas's top `rows` rows to
-- `depth` voxels instead of revolving them.
--
-- Revolving a row turns its DRAWN WIDTH into depth, which only means
-- something when the drawing states a width to turn -- the pot's rows do
-- (a 3px stem opening to a 12px belly and closing to a 6px foot, an urn's
-- profile), and a tree canopy's do (the ball's outline is drawn). A leaf
-- crown's do NOT: the leaves are a spray that runs off all four sides of
-- its tile, so every row measures the full canvas and the revolve can only
-- produce a solid cylinder -- the "hedge column" a plant must never become,
-- with one row of texels smeared down its whole top face. Where the drawing
-- states no profile, the honest reading is the one the thin standee pools
-- exist for: the foliage stands as a per-pixel slab and keeps the airy
-- silhouette that makes it read as leaves.
local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows,
NYin, spray, baseRows, bodyRows, wellRows,
taperVox)
-- The canvas is NX wide and NX DEEP (a hull is round in plan, so its
-- depth is its width) by NY tall. NX = 16 is one cell, 32 a 2x2-cell
-- group; NY defaults to NX -- a ball -- and NY = 2 * NX is a drawing
-- STACKED two cells high on one cell of plot (the potted plant).
local NX = N or 16
local NY = NYin or NX
local N2 = NX / 2
local perRow = map.tileset.tilesPerRow or 16
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
-- cell-space art access (NX x NY, row 0 = top), anchored at cell (cx, cy)
local function tileOf(px, py)
return S.tileAt[keyOf(cx * 2 + math.floor(px / 8),
cy * 2 + math.floor(py / 8))]
end
local function texel(px, py)
local tile = tileOf(px, py)
return (tile % perRow) * 8 + px % 8,
math.floor(tile / perRow) * 8 + py % 8
end
-- shade class of every canvas pixel, indexed py * NX + px
local cls = {}
for py = 0, NY - 1 do
for px = 0, NX - 1 do
local ax, ay = texel(px, py)
local r, g, b, a = data:getPixel(ax, ay)
cls[py * NX + px] = a == 0 and "off"
or Structures.shadeClass(math.min(r, g, b))
end
end
-- 4-connected flood from a row band's border through `passable` classes
local function floodOutside(passable, y0, y1)
local out, stack = {}, {}
local function seed(i)
if not out[i] and passable[cls[i]] then
out[i] = true
stack[#stack + 1] = i
end
end
for px = 0, NX - 1 do
seed(y0 * NX + px); seed(y1 * NX + px)
end
for py = y0, y1 do
seed(py * NX); seed(py * NX + NX - 1)
end
while #stack > 0 do
local i = table.remove(stack)
local px, py = i % NX, math.floor(i / NX)
if px > 0 then seed(i - 1) end
if px < NX - 1 then seed(i + 1) end
if py > y0 then seed(i - NX) end
if py < y1 then seed(i + NX) end
end
return out
end
-- The mask -- darkest-pixel outline plus its enclosure, with the dither
-- rule as fallback -- computed per CELL BAND of NX rows.
--
-- A square canvas is ONE band, so this is exactly the whole-canvas rule
-- it replaces. A STACKED canvas needs it per band because its two halves
-- want opposite answers: the potted plant's leaf crown is a black-outlined
-- dither drawn over floor (outline enclosure keeps it), while its pot is a
-- solid DARK body whose base runs flush to the band's bottom edge (the
-- enclosure flood walks in through dark and guts it, and the fallback --
-- which the band's own `enclosed` count asks for -- keeps it). Measured on
-- the Center plant: one flood over both bands keeps 53% of the drawing and
-- leaves the pot a hollow black frame; per band keeps 68% and both read.
local mask = {}
for band = 0, NY / NX - 1 do
local y0, y1 = band * NX, band * NX + NX - 1
local out = floodOutside({ off = true, dark = true,
light = true, white = true }, y0, y1)
local enclosed = 0
for i = y0 * NX, (y1 + 1) * NX - 1 do
if not out[i] then
mask[i] = true
if cls[i] ~= "black" then enclosed = enclosed + 1 end
end
end
if enclosed < NX * NX / 8 then
out = floodOutside({ off = true, light = true, white = true }, y0, y1)
for i = y0 * NX, (y1 + 1) * NX - 1 do
mask[i] = (not out[i] and cls[i] ~= "off") or nil
end
end
end
local any = nil
for i = 0, NX * NY - 1 do any = any or mask[i] end
if not any then return {} end
-- a CAPPED hull (the stump): the top capRows rows of the mask are the
-- drawn cut face -- a surface seen at an angle, not body. Strip them
-- from the mask and remember their art span; the top-face quads below
-- project that ellipse across the round cap.
local capY0, capY1 = nil, nil
if capRows and capRows > 0 then
local top = nil
for iy = 0, NY - 1 do
for ix = 0, NX - 1 do
if mask[iy * NX + ix] then top = iy break end
end
if top then break end
end
if top then
capY0 = top
capY1 = math.min(top + capRows - 1, NY - 2)
for iy = capY0, capY1 do
for ix = 0, NX - 1 do mask[iy * NX + ix] = nil end
end
any = nil
for i = 0, NX * NY - 1 do any = any or mask[i] end
if not any then return {} end
end
end
-- a FLAT-BASED hull (the can): the bottom baseRows rows of the mask are
-- the BASE circle's front arc -- the drawing's mirror of the cut face
-- above, ground contact seen from above rather than body. A can is only
-- round in the horizontal plane, so the drop those rows make toward the
-- middle is DEPTH, not a narrowing of the plan: left as body they revolve
-- into ever smaller discs and the can ends up balanced on a stem three
-- voxels wide (which is exactly what the first build did). Strip them and
-- the foot rule below runs the last body row's full disc straight to the
-- floor; the rows keep their own texels there, so the front view is still
-- the drawing, base rim and all.
local baseArt = nil
if baseRows and baseRows > 0 then
local bot = nil
for iy = NY - 1, 0, -1 do
for ix = 0, NX - 1 do
if mask[iy * NX + ix] then bot = iy break end
end
if bot then break end
end
if bot then
baseArt = {}
for iy = math.max(bot - baseRows + 1, (capY1 or -1) + 2), bot do
for ix = 0, NX - 1 do
local i = iy * NX + ix
if mask[i] then baseArt[i] = true end
mask[i] = nil
end
end
any = nil
for i = 0, NX * NY - 1 do any = any or mask[i] end
if not any then return {} end
end
end
-- The can's HEIGHT, and the one place this file departs from the drawing
-- on purpose. Strictly un-projected, the drawing states a squat drum: cut
-- the mouth ellipse off the top and the base circle off the bottom and
-- barely two rows of straight side are left between them, because the GB
-- artist spent most of a 16px cell on the opening. A real bin is TALLER
-- than it is wide, and the flat game reads as one because the drawing is
-- 14px tall next to a 16px player -- so the height is authored (can_height
-- voxels) rather than measured, and the surviving body band is repeated
-- upward to fill it, bottom row first, which continues the drawn rib
-- rhythm instead of inventing a texel. Everything else still comes off
-- the pixels.
local artRow = {}
if bodyRows and bodyRows > 0 then
local body = {}
for iy = 0, NY - 1 do
for ix = 0, NX - 1 do
if mask[iy * NX + ix] then body[#body + 1] = iy break end
end
end
local nb = #body
if nb > 0 then
local top = body[1]
for iy = top - 1, math.max(NY - bodyRows, 0), -1 do
-- the LOWEST surviving body row, repeated: it is the widest and
-- plainest reading of the material (outline, shaded flank, lit
-- face) and stacks into a clean metal cylinder. Cycling the whole
-- surviving band instead stacks the drawn rim arcs into a barcode
-- of hoops, which is detail the drawing never states about the
-- side of the can.
local from = body[nb]
artRow[iy] = from
for ix = 0, NX - 1 do
mask[iy * NX + ix] = mask[from * NX + ix]
end
end
end
end
-- the ground the ball stands on: the drawing's own background names
-- it. Score every flat ground tile the map places against the cell's
-- unmasked light pixels and keep the closest -- mid-forest trees have
-- no flat neighbour to vote with, and the commonest-ground fallback
-- paints pale path under trees whose art sits on grass. Dark unmasked
-- pixels (the drawn cast shadow) stay out of the score: no ground
-- tile carries a shadow, and their darks would drag every match.
local bg = nil
if groundTiles and #groundTiles > 0 then
local bestScore = nil
for _, t in ipairs(groundTiles) do
local ox = (t % perRow) * 8
local oy = math.floor(t / perRow) * 8
local score, n = 0, 0
for py = 0, NY - 1 do
for px = 0, NX - 1 do
local i = py * NX + px
local c = cls[i]
-- a stripped base row is the OBJECT's own rim, not background:
-- scoring its whites against the floor tiles matches paper-white
-- ground under a can whose art stands on the gym's grey
if not mask[i] and not (baseArt and baseArt[i])
and (c == "light" or c == "white") then
local ax, ay = texel(px, py)
local r1, g1, b1 = data:getPixel(ax, ay)
local r2, g2, b2 = data:getPixel(ox + px % 8, oy + py % 8)
local dr, dg, db = r1 - r2, g1 - g2, b1 - b2
score = score + dr * dr + dg * dg + db * db
n = n + 1
end
end
end
if n > 0 then
score = score / n
if not bestScore or score < bestScore then bestScore, bg = score, t end
end
end
end
-- discs: per mask pixel a z chord [z0, z1), from its row's span circle.
-- z2/z3 is an optional SECOND chord for the same pixel, which only the
-- can's hollow mouth uses: a ring in plan needs a front wall and a back
-- wall at the same column, and one interval cannot say that.
local z0, z1, z2, z3, src, srcX = {}, {}, {}, {}, {}, {}
local loRow, hiRow = {}, {}
local yBot = nil
for iy = 0, NY - 1 do
local lo, hi = nil, nil
for ix = 0, NX - 1 do
if mask[iy * NX + ix] then
lo = lo or ix
hi = ix
end
end
if lo then
loRow[iy], hiRow[iy] = lo, hi
yBot = iy
local c = (lo + hi + 1) / 2
local hw = (hi - lo + 1) / 2
for ix = lo, hi do
local i = iy * NX + ix
if mask[i] then
local dx = ix + 0.5 - c
local n = 1
if hw * hw > dx * dx then
n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx)
+ 0.5))
end
if spray and iy < spray.rows then n = math.min(n, spray.depth) end
z0[i] = math.floor(N2 - n / 2 + 0.5)
z1[i] = z0[i] + n
-- a row the can's body band was repeated into wears the row it
-- was copied from, never a texel of its own
src[i] = artRow[iy] or iy
end
end
end
end
-- Spray-gap BACKING: the drawing's own gap pixels, one voxel deep at
-- the slab's mid-plane. The flat crown is full of floor showing
-- between leaves; carved as an open slab those gaps became TUNNELS --
-- the Center couch, the man sitting on it and the void wall all read
-- as pink/orange/black confetti INSIDE the foliage, and the sparse
-- bottom rows (lone drawn leaf tips) floated as disconnected specks
-- against them. The drawing itself backs every gap with its own
-- pixels, so the hull does the same: each in-span gap below drawn
-- foliage takes ITS OWN texel as a plate recessed behind the leaf
-- relief. Coverage is monotone down a column, so the first backed
-- cell always sits directly under a leaf chord -- and every chord
-- spans the mid-plane, so no plate ever caps the crown's top: columns
-- open to the sky stay open and the silhouette keeps its notches.
if spray then
for iy = 1, math.min(spray.rows, NY) - 1 do
if loRow[iy] then
for ix = loRow[iy], hiRow[iy] do
local i = iy * NX + ix
if not z0[i] then
local covered = false
for iy2 = 0, iy - 1 do
if mask[iy2 * NX + ix] then covered = true break end
end
if covered then
z0[i], z1[i], src[i] = N2, N2 + 1, iy
end
end
end
end
end
end
-- foot: rows under the mask repeat the bottom row's discs, wearing the
-- bottom row's (outline-dark) pixels -- except where a stripped base row
-- DREW something at that pixel, which keeps its own texel, so a can's
-- drawn base rim lands on the model's base instead of being painted over
-- by the body band above it
for iy = yBot + 1, NY - 1 do
loRow[iy], hiRow[iy] = loRow[yBot], hiRow[yBot]
for ix = loRow[yBot], hiRow[yBot] do
local b = yBot * NX + ix
if z0[b] then
local i = iy * NX + ix
z0[i], z1[i] = z0[b], z1[b]
src[i] = (baseArt and baseArt[i]) and iy or yBot
end
end
end
-- the TAPER: a bin is a truncated cone, not a tube -- wide at the rim,
-- drawn in a couple of voxels toward the base. The drawing agrees as far
-- as it can (its own base arc pulls in to 9px from the 11px flanks), but
-- it cannot state the whole run, so taperVox is the diameter the base
-- loses and the rows in between interpolate. Every row keeps its plan
-- ROUND: narrow the span, then re-cut the chords from the narrowed span,
-- or the model comes out a cylinder with its corners shaved.
local stepped = {}
if taperVox and taperVox > 0 then
local yTopRow = nil
for iy = 0, NY - 1 do
if loRow[iy] then yTopRow = iy break end
end
local span = NY - 1 - (yTopRow or 0)
if yTopRow and span > 0 then
for iy = yTopRow, NY - 1 do
local inset = math.floor(taperVox / 2 * (iy - yTopRow) / span + 0.5)
if inset > 0 and loRow[iy] then
local lo = loRow[iy] + inset
local hi = hiRow[iy] - inset
if hi - lo < 1 then
lo = math.floor((loRow[iy] + hiRow[iy]) / 2)
hi = lo + 1
end
for ix = loRow[iy], hiRow[iy] do
if ix < lo or ix > hi then
local i = iy * NX + ix
z0[i], z1[i], z2[i], z3[i] = nil, nil, nil, nil
end
end
-- squeeze the row's ART into the narrowed span rather than
-- clipping its ends off: the drawn outline is the last column
-- either side, and dropping it leaves the taper's new edge
-- wearing an interior texel -- a white chip down the rim
for ix = lo, hi do
srcX[iy * NX + ix] = loRow[iy]
+ math.floor((ix - lo) * (hiRow[iy] - loRow[iy])
/ (hi - lo) + 0.5)
end
loRow[iy], hiRow[iy] = lo, hi
stepped[iy] = true
local c = (lo + hi + 1) / 2
local hw = (hi - lo + 1) / 2
for ix = lo, hi do
local i = iy * NX + ix
if z0[i] then
local dx = ix + 0.5 - c
local n = 1
if hw * hw > dx * dx then
n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx)
+ 0.5))
end
z0[i] = math.floor(N2 - n / 2 + 0.5)
z1[i] = z0[i] + n
end
end
end
end
end
end
-- the MOUTH: a bin is open, and a solid top wearing the drawn opening
-- only paints one. Hollow the top wellRows voxel rows -- every chord
-- long enough to hold two walls plus a gap keeps a wall at each end and
-- loses its middle, which is a ring in plan, so the model has a real rim
-- to look into. The short chords at the left and right of the row ARE
-- the ring's sides and stay solid on their own.
local wellTop = nil
if wellRows and wellRows > 0 then
for iy = 0, NY - 1 do
if loRow[iy] then wellTop = iy break end
end
local wall = 2
for iy = wellTop or 0, math.min((wellTop or 0) + wellRows - 1, NY - 1) do
if loRow[iy] then
for ix = loRow[iy], hiRow[iy] do
local i = iy * NX + ix
if z0[i] and z1[i] - z0[i] > wall * 2 then
z2[i], z3[i] = z1[i] - wall, z1[i]
z1[i] = z0[i] + wall
end
end
end
end
end
-- the round cap's top row and z extent, for the stump's ring
-- projection below
local capTopRow, capZ0, capZ1 = nil, nil, nil
if capY0 then
for iy = 0, NY - 1 do
if loRow[iy] then capTopRow = iy break end
end
if capTopRow then
for ix = loRow[capTopRow], hiRow[capTopRow] do
local i = capTopRow * NX + ix
if z0[i] then
-- the OUTER extent, so a hollowed row still projects the mouth
-- across the whole opening and not just its front wall
local back = z3[i] or z1[i]
capZ0 = math.min(capZ0 or z0[i], z0[i])
capZ1 = math.max(capZ1 or back, back)
end
end
end
end
-- the art row the mouth projection puts at depth iz -- the drawn
-- opening's north arc at the far side of the hull, its south arc at the
-- near one. The top-face pass below reads the same mapping; this is the
-- vertical faces inside the well asking it the same question.
local function mouthRow(iz)
if not (capY0 and capZ0 and capZ1) then return 0 end
local t = capZ1 - 1 > capZ0 and (iz - capZ0) / (capZ1 - 1 - capZ0) or 0
t = math.max(0, math.min(1, t))
return capY0 + math.floor(t * (capY1 - capY0) + 0.5)
end
local function solidAt(ix, iy, iz)
if ix < 0 or ix > NX - 1 or iy < 0 or iy > NY - 1 then return false end
local i = iy * NX + ix
if z0[i] == nil then return false end
if iz >= z0[i] and iz < z1[i] then return true end
return z2[i] ~= nil and iz >= z2[i] and iz < z3[i]
end
-- cap interiors sample the canopy a couple of rows below the rim,
-- skipping outline-dark pixels
local function deepTexel(ix, iy)
for iy2 = iy + 2, math.min(NY - 1, iy + 4) do
local i = iy2 * NX + ix
if mask[i] and cls[i] ~= "black" then return texel(ix, iy2) end
end
return texel(ix, iy)
end
-- side walls read as material, not outline: walk inward past black
-- pixels (the building extruder's de-outline rule). The silhouette's
-- edge columns are all outline, and without this every flank of the
-- ball paints solid black the moment the camera turns. The foot rows
-- stay dark on purpose: their whole source row is outline-black.
local function sideTexel(ix, iy)
-- A foot row's SIDE keeps the last body row's material even where its
-- FRONT wears a stripped base row (the can). The drawn base rim is
-- front-face art; walking the de-outline inside a row that is no longer
-- in the mask breaks at once and hands back the silhouette's own
-- outline, which painted every flank of the can solid black.
local r = (yBot and iy > yBot) and yBot or src[iy * NX + ix]
-- the walk runs in ART columns, so a tapered row starts from the drawn
-- pixel its squeezed span put here rather than from the model column
local a = srcX[iy * NX + ix] or ix
local dir = ix + ix < loRow[iy] + hiRow[iy] and 1 or -1
for step = 0, 3 do
local x2 = a + dir * step
local i2 = r * NX + x2
if x2 < 0 or x2 > NX - 1 or not mask[i2] then break end
if cls[i2] ~= "black" then return texel(x2, r) end
end
return texel(a, r)
end
local quads = {}
for iy = 0, NY - 1 do
if loRow[iy] then
local yB, yT = NY - 1 - iy, NY - iy
-- front and back: the drawing per-pixel, columns merged where they
-- share a chord plane; a run never crosses the 8px atlas tile seam
-- (its u range must interpolate inside one tile)
local ix = loRow[iy]
while ix <= hiRow[iy] do
local i = iy * NX + ix
if z0[i] then
local ix2 = ix
while ix2 + 1 <= hiRow[iy] do
local j = iy * NX + ix2 + 1
-- src too: a can's foot row draws part of its span from the
-- stripped base rim and the rest from the body band above it,
-- so a run must not straddle two source rows (the u range is
-- interpolated from one row's texels)
if z0[j] == z0[i] and z1[j] == z1[i] and src[j] == src[i]
and z2[j] == z2[i] and z3[j] == z3[i]
and math.floor((ix2 + 1) / 8) == math.floor(ix / 8) then
ix2 = ix2 + 1
else
break
end
end
local x0, x1 = ix - N2, ix2 - N2 + 1
-- one facing pair per chord, each face given the art row it
-- should wear. A hollowed mouth row has two chords, and the two
-- faces that look into the well take the drawn OPENING (via the
-- same projection the rim does) rather than the body band: the
-- drawing paints its mouth dark, and an inside-out white wall
-- across the opening is the one thing that stops a bin reading
-- as a bin.
local function facing(za, zb, rowF, rowB)
local zF, zB = zb - N2, za - N2
local function pair(z, row, shade, back)
local ax0, ay = texel(srcX[i] or ix, row)
local ax1 = (texel(srcX[iy * NX + ix2] or ix2, row))
local u0, u1 = (ax0 + 0.05) / atlasW, (ax1 + 0.95) / atlasW
local v0, v1 = (ay + 0.05) / atlasH, (ay + 0.95) / atlasH
if back then
quads[#quads + 1] = {
{ x1, yB, z }, { x0, yB, z }, { x0, yT, z }, { x1, yT, z },
uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } },
shade = shade,
}
else
quads[#quads + 1] = {
{ x0, yB, z }, { x1, yB, z }, { x1, yT, z }, { x0, yT, z },
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
shade = shade,
}
end
end
pair(zF, rowF, ROUND_SHADE.front, false)
pair(zB, rowB, ROUND_SHADE.back, true)
end
local body = src[i]
if z2[i] then
-- z grows toward the viewer: the low chord is the can's FAR
-- wall, so its +z face is the inside you look across, and the
-- near chord's -z face is the inside of the wall facing you
facing(z0[i], z1[i], mouthRow(z1[i]), body)
facing(z2[i], z3[i], body, mouthRow(z2[i] - 1))
else
facing(z0[i], z1[i], body, body)
end
ix = ix2 + 1
else
ix = ix + 1
end
end
-- sides, steps, undersides: constant-texel quads over the z runs a
-- neighbour doesn't cover
for ix = loRow[iy], hiRow[iy] do
local i = iy * NX + ix
if z0[i] then
local ax, ay = texel(srcX[i] or ix, src[i])
local u, v = (ax + 0.5) / atlasW, (ay + 0.5) / atlasH
local x0, x1 = ix - N2, ix - N2 + 1
-- exposed z pieces against one neighbouring column, over each of
-- the pixel's chords (a hollowed mouth row has two)
local function chordPieces(nx, ny, emit, zLo, zHi)
local iz = zLo
while iz < zHi do
if not solidAt(nx, ny, iz) then
local iz2 = iz
while iz2 + 1 < zHi and not solidAt(nx, ny, iz2 + 1) do
iz2 = iz2 + 1
end
emit(iz - N2, iz2 - N2 + 1, iz, iz2)
iz = iz2 + 1
else
iz = iz + 1
end
end
end
local function pieces(nx, ny, emit)
chordPieces(nx, ny, emit, z0[i], z1[i])
if z2[i] then chordPieces(nx, ny, emit, z2[i], z3[i]) end
end
local sax, say = sideTexel(ix, iy)
local su, sv = (sax + 0.5) / atlasW, (say + 0.5) / atlasH
pieces(ix - 1, iy, function(zA, zB)
quads[#quads + 1] = {
{ x0, yB, zA }, { x0, yB, zB }, { x0, yT, zB }, { x0, yT, zA },
u = su, v = sv, shade = ROUND_SHADE.side,
}
end)
pieces(ix + 1, iy, function(zA, zB)
quads[#quads + 1] = {
{ x1, yB, zB }, { x1, yB, zA }, { x1, yT, zA }, { x1, yT, zB },
u = su, v = sv, shade = ROUND_SHADE.side,
}
end)
pieces(ix, iy - 1, function(zA, zB, izA, izB)
local function top(za, zb, tu, tv)
quads[#quads + 1] = {
{ x0, yT, za }, { x1, yT, za }, { x1, yT, zb }, { x0, yT, zb },
u = tu, v = tv, shade = ROUND_SHADE.top,
}
end
-- the whole hollowed band takes the projection, not just its
-- top row: the rim ring gets the mouth's outer arcs and the
-- floor of the well gets its middle, so looking in reads as
-- one opening rather than a lid with a hole punched in it
if capTopRow and capZ1
and iy >= capTopRow and iy <= capTopRow + (wellRows or 0) then
-- the CUT FACE (a capped hull's top): project the drawn
-- ellipse across the round cap voxel row by voxel row --
-- its top arc at the cap's north rim, its bottom arc at
-- the south, the perspective the 2D art already implies
for iz = izA, izB do
local t = capZ1 - 1 > capZ0
and (iz - capZ0) / (capZ1 - 1 - capZ0) or 0
local ry = capY0 + math.floor(t * (capY1 - capY0) + 0.5)
local cax, cay = texel(srcX[i] or ix, ry)
top(iz - N2, iz - N2 + 1,
(cax + 0.5) / atlasW, (cay + 0.5) / atlasH)
end
elseif izA == z0[i] and izB == z1[i] - 1 and izB - izA >= 2 then
-- the dome cap: outline on the rim cells, canopy inside
local du, dv = deepTexel(ix, iy)
top(zA, zA + 1, u, v)
top(zA + 1, zB - 1, (du + 0.5) / atlasW, (dv + 0.5) / atlasH)
top(zB - 1, zB, u, v)
elseif stepped[iy] then
-- a taper STEP: the chord narrowing leaves a ring facing up
-- at the front of the can, and wearing the lit body band it
-- reads as a bright chip taken out of the wall. The drawing's
-- own rim column is black, so the step wears that and the
-- taper reads as a hoop line -- which is how the reference
-- object is banded anyway.
local rx = srcX[iy * NX + loRow[iy]] or loRow[iy]
local rax, ray = texel(rx, src[i])
top(zA, zB, (rax + 0.5) / atlasW, (ray + 0.5) / atlasH)
else
top(zA, zB, u, v)
end
end)
if iy < NY - 1 then
pieces(ix, iy + 1, function(zA, zB)
quads[#quads + 1] = {
{ x0, yB, zB }, { x1, yB, zB }, { x1, yB, zA }, { x0, yB, zA },
u = u, v = v, shade = ROUND_SHADE.bottom,
}
end)
end
end
end
end
end
return quads, bg
end
-- Hull templates dedupe GLOBALLY per (tileset, four tiles, ground set):
-- the same four-tile tree repeats for hundreds of cells on a map and
-- across every route of its tileset, so the carve runs once per distinct
-- drawing per session. What a map keeps is a STAMP LIST -- (template,
-- cell offset) pairs the mesher expands while packing vertices -- rather
-- than materialized per-cell quad tables, which retained ~500 quads x
-- hundreds of tree cells x six Lua tables each PER MAP (the multi-GB
-- heap growth on a cross-region trek).
local roundCache = {}
function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
local data = pixels(map.tileset)
local tw, th = map.def.width * 4, map.def.height * 4
-- ground-set fingerprint: the template's art-matched floor depends on
-- which ground tiles this map places, so maps sharing a tileset but
-- not a palette of floors carve separately
local gsig
do
local g = {}
for i, t in ipairs(groundTiles or {}) do g[i] = t end
table.sort(g)
gsig = table.concat(g, ",")
end
local tsid = tostring(map.tileset.id or map.tileset.image or "?")
-- the stump class's drawn-ellipse height, hand-authored per tileset
-- (the profile's stump_cap, in art rows), and the can class's three: the
-- mouth ellipse over the top (can_cap) and the base ellipse under the
-- bottom (can_base), both in art rows, plus the authored can_height in
-- voxels the body band is repeated up to
local stumpCap, canCap, canBase, canHeight, canWell, canTaper
= 6, 9, 4, 9, 5, 4
do
local okP, prof = pcall(V.data, "voxel_heights")
local entry = okP and type(prof) == "table" and prof.tilesets
and prof.tilesets[map.tileset.id]
if entry and type(entry.stump_cap) == "number" then
stumpCap = entry.stump_cap
end
if entry and type(entry.can_cap) == "number" then
canCap = entry.can_cap
end
if entry and type(entry.can_base) == "number" then
canBase = entry.can_base
end
if entry and type(entry.can_height) == "number" then
canHeight = entry.can_height
end
if entry and type(entry.can_well) == "number" then
canWell = entry.can_well
end
if entry and type(entry.can_taper) == "number" then
canTaper = entry.can_taper
end
end
-- cells consumed by a 2x2 `canopy` group; the scan runs north to
-- south, west to east, so an anchor always claims its partners
-- before they are visited
local grouped = {}
for cy = math.floor(y0 / 2), math.floor(y1 / 2) do
for cx = math.floor(x0 / 2), math.floor(x1 / 2) do
Budget.tick()
local ckey = cy * 8192 + cx
local k = keyOf(cx * 2, cy * 2)
local s = (not grouped[ckey]) and S.shapeAt[k] or nil
local near = cx * 2 >= -ROUND_RING and cx * 2 < tw + ROUND_RING
and cy * 2 >= -ROUND_RING and cy * 2 < th + ROUND_RING
if s and s.art == "canopy" and near then
-- ONE 32px hull over the 2x2-cell drawing. The partner cells
-- must be round-pinned too, or the drawing is partial (a map
-- edit, a mod's stray anchor tile) and the anchor is left
-- alone rather than carved into a half-empty giant.
local whole = true
for _, d in ipairs({ { 1, 0 }, { 0, 1 }, { 1, 1 } }) do
local ps = S.shapeAt[keyOf((cx + d[1]) * 2, (cy + d[2]) * 2)]
if not (ps and (ps.art == "cylinder" or ps.art == "canopy")) then
whole = false
end
end
if whole then
local ground = false
if data then
local ids = {}
for dy = 0, 3 do
for dx = 0, 3 do
ids[#ids + 1] = S.tileAt[keyOf(cx * 2 + dx, cy * 2 + dy)]
end
end
local sig = tsid .. "|g32|" .. gsig .. "|"
.. table.concat(ids, ":")
local tpl = roundCache[sig]
if not tpl then
local tq, tbg = roundTemplate(S, map, data, cx, cy,
groundTiles, 32)
tpl = { quads = tq, bg = tbg }
roundCache[sig] = tpl
end
ground = tpl.bg or false
S.roundStamps[#S.roundStamps + 1] =
{ quads = tpl.quads, mx = cx * 16 + 16, mz = cy * 16 + 16,
r = 16 }
end
for dy = 0, 3 do
for dx = 0, 3 do
local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
S.skip[tk] = true
S.ground[tk] = ground
end
end
grouped[ckey + 1] = true
grouped[ckey + 8192] = true
grouped[ckey + 8193] = true
end
elseif s and s.art == "planter" and near then
-- ONE 16x32x16 hull over a drawing stacked TWO CELLS HIGH on one
-- cell of plot: the Pokemon Centers' potted plants (a leaf crown
-- over a flared pot, 78 placements across 13 maps).
--
-- The anchor is the NORTH cell -- the crown, where the canvas
-- starts -- but the hull stands in the SOUTH cell, because that is
-- where the pot is drawn and an object's ground contact is its
-- plot. The crown is therefore HEIGHT, not depth: the north cell
-- is claimed and left as floor for the crown to overhang, which is
-- what un-projecting the 3/4 view means here. Pinning only one of
-- the two cells leaves the drawing partial (a map edit, a mod's
-- stray tile), so the anchor is left alone rather than carved into
-- half a plant.
local below = S.shapeAt[keyOf(cx * 2, (cy + 1) * 2)]
if below and below.art == "planter" then
local ground = false
if data then
local ids = {}
for dy = 0, 3 do
for dx = 0, 1 do
ids[#ids + 1] = S.tileAt[keyOf(cx * 2 + dx, cy * 2 + dy)]
end
end
local sig = tsid .. "|p32|" .. gsig .. "|"
.. table.concat(ids, ":")
local tpl = roundCache[sig]
if not tpl then
local tq, tbg = roundTemplate(S, map, data, cx, cy,
groundTiles, 16, nil, 32,
PLANTER_SPRAY)
tpl = { quads = tq, bg = tbg }
roundCache[sig] = tpl
end
ground = tpl.bg or false
S.roundStamps[#S.roundStamps + 1] =
{ quads = tpl.quads, mx = cx * 16 + 8,
mz = (cy + 1) * 16 + 8 }
end
for dy = 0, 3 do
for dx = 0, 1 do
local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
S.skip[tk] = true
S.ground[tk] = ground
end
end
grouped[ckey + 8192] = true
end
elseif s and s.art == "cylinder" and near then
-- a `stump`-class cell is the same hull with a cut face: its
-- top capRows of drawing project onto the round top. A `can`-class
-- cell is that hull cut at BOTH ends -- lid on top, base circle on
-- the floor -- which is what a drum standing on a floor is.
local cap = (s.class == "stump" and stumpCap)
or (s.class == "can" and canCap) or nil
local base = s.class == "can" and canBase or nil
local tall = s.class == "can" and canHeight or nil
local well = s.class == "can" and canWell or nil
local taper = s.class == "can" and canTaper or nil
local ground = false
if data then
local sig = tsid .. (cap and ("|c" .. cap) or "")
.. (base and ("|b" .. base) or "")
.. (tall and ("|h" .. tall) or "")
.. (well and ("|w" .. well) or "")
.. (taper and ("|t" .. taper) or "") .. "|"
.. gsig .. "|" .. table.concat({
S.tileAt[k], S.tileAt[keyOf(cx * 2 + 1, cy * 2)],
S.tileAt[keyOf(cx * 2, cy * 2 + 1)],
S.tileAt[keyOf(cx * 2 + 1, cy * 2 + 1)] }, ":")
local tpl = roundCache[sig]
if not tpl then
local tq, tbg = roundTemplate(S, map, data, cx, cy,
groundTiles, 16, cap, nil, nil,
base, tall, well, taper)
tpl = { quads = tq, bg = tbg }
roundCache[sig] = tpl
end
ground = tpl.bg or false
S.roundStamps[#S.roundStamps + 1] =
{ quads = tpl.quads, mx = cx * 16 + 8, mz = cy * 16 + 8 }
end
-- headless (no pixels): no hull, but still claim the tiles so
-- the volume path never boxes a pinned cell. Ground is the
-- template's own art-matched tile; `false` (no match, headless)
-- falls to the commonest-ground pass below.
for dy = 0, 1 do
for dx = 0, 1 do
local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
S.skip[tk] = true
S.ground[tk] = ground
end
end
end
end
end
end
-- ---- relief props: top-down drawings lying on their surface ----
-- A cell pinned `relief` is a prop DRAWN FROM ABOVE (a game console on
-- the floor): standing it up would be wrong, and a solid box would carry
-- the drawn floor around it. The drawing is segmented like any forced
-- prop (black outline; the shades touching the cluster's edge are the
-- background) and the object pixels extrude straight up a few voxels,
-- art on the top face -- a piece of the drawing pushed out of the
-- ground. The floor the flood removed is repainted by the claimed
-- tiles' common-ground fill.
local RELIEF_SHADE = { top = 1.0, south = 0.9, north = 0.62, side = 0.75 }
function Structures.buildRelief(S, map, region, data, perRow, h)
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local bw = (region.maxX - region.minX + 1) * 8
local bh = (region.maxY - region.minY + 1) * 8
local member = {}
for _, c in ipairs(region.tiles) do member[keyOf(c[1], c[2])] = true end
local cls, srcU, srcV = {}, {}, {}
for py = 0, bh - 1 do
for px = 0, bw - 1 do
local i = py * bw + px
local k = keyOf(region.minX + math.floor(px / 8),
region.minY + math.floor(py / 8))
if member[k] then
local tile = S.tileAt[k]
local ax = (tile % perRow) * 8 + px % 8
local ay = math.floor(tile / perRow) * 8 + py % 8
srcU[i], srcV[i] = ax, ay
local r, g, b, a = data:getPixel(ax, ay)
cls[i] = a == 0 and "off" or Structures.shadeClass(math.min(r, g, b))
end
end
end
local bg = {}
for py = 0, bh - 1 do
for px = 0, bw - 1 do
if px == 0 or px == bw - 1 or py == 0 or py == bh - 1 then
local c = cls[py * bw + px]
if c and c ~= "black" and c ~= "off" then bg[c] = true end
end
end
end
local flooded, queue = {}, {}
local function seed(i)
local c = cls[i]
if c and c ~= "black" and (c == "off" or bg[c]) and not flooded[i] then
flooded[i] = true
queue[#queue + 1] = i
end
end
for px = 0, bw - 1 do
seed(px)
seed((bh - 1) * bw + px)
end
for py = 0, bh - 1 do
seed(py * bw)
seed(py * bw + bw - 1)
end
while #queue > 0 do
local i = table.remove(queue)
local px, py = i % bw, math.floor(i / bw)
for _, d in ipairs(DIRS4) do
local nx, ny = px + d[1], py + d[2]
if nx >= 0 and nx < bw and ny >= 0 and ny < bh then
seed(ny * bw + nx)
end
end
end
local function on(px, py)
if px < 0 or px >= bw or py < 0 or py >= bh then return false end
local i = py * bw + px
return cls[i] ~= nil and cls[i] ~= "off" and not flooded[i]
end
local quads = S.objectQuads
local wx0, wz0 = region.minX * 8, region.minY * 8
for py = 0, bh - 1 do
for px = 0, bw - 1 do
if on(px, py) then
local i = py * bw + px
local u = (srcU[i] + 0.5) / atlasW
local v = (srcV[i] + 0.5) / atlasH
local x, z = wx0 + px, wz0 + py
local function quad(c1, c2, c3, c4, shade)
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
end
quad({ x, h, z }, { x + 1, h, z }, { x + 1, h, z + 1 },
{ x, h, z + 1 }, RELIEF_SHADE.top)
if not on(px, py + 1) then
quad({ x, 0, z + 1 }, { x + 1, 0, z + 1 }, { x + 1, h, z + 1 },
{ x, h, z + 1 }, RELIEF_SHADE.south)
end
if not on(px, py - 1) then
quad({ x + 1, 0, z }, { x, 0, z }, { x, h, z },
{ x + 1, h, z }, RELIEF_SHADE.north)
end
if not on(px - 1, py) then
quad({ x, 0, z }, { x, 0, z + 1 }, { x, h, z + 1 },
{ x, h, z }, RELIEF_SHADE.side)
end
if not on(px + 1, py) then
quad({ x + 1, 0, z + 1 }, { x + 1, 0, z }, { x + 1, h, z },
{ x + 1, h, z + 1 }, RELIEF_SHADE.side)
end
end
end
end
end
-- ---- bookcases: free-standing shelves collapsed to true depth ----
-- A drawn bookcase is TALL, not deep: the graphic spans two cell rows
-- because the shelf is 32px high, while the object stands one cell
-- (16px) deep. Columns of tiles pinned `bookcase` collapse in ranks
-- (at most four drawn rows each, measured from the south): every rank
-- raises one box over its front two tile rows -- the drawing folded up
-- its south face band by band -- and its back rows become hidden floor.
-- When the row just above a rank is undetected structure (a shared trim
-- tile the profile cannot pin), the rank adopts it as its CAP: one more
-- band of height and the art its top face wears.
local BOOK_SHADE = { south = 1.0, north = 0.68, flank = 0.8, top = 0.85,
-- a pane's reveal: the one-voxel side of the frame
-- standing proud of it. The sill catches the light
-- the top face does; the lintel is in shadow.
sill = 0.85, lintel = 0.5 }
-- A pane is a shelf opening, a glass door or an inset panel: a non-black
-- region the drawing SEALS OFF behind its own black frame. Anything
-- wider or taller than this is a band of the front itself -- a trim
-- course, a plinth -- and stays flush. The same number and the same
-- rule lib/Buildings.lua measures a facade's panes with, so a shelf the
-- band pipeline models and a shelf this class collapses carry the same
-- relief.
local BOOK_RECESS_MAX = 24
-- The panes of a BANK of ranks -- every rank of the same height standing
-- side by side -- as a mask over the bank's south face, plus the atlas
-- pixel each face texel comes from. Measured over the whole bank rather
-- than per column, because a door panel drawn across two tiles is one
-- region and not two halves, and because the size test that keeps a
-- broad course flush has to see the course's real width.
--
-- `fx` runs across the bank and `fy` DOWN from its top, so the grid
-- reads like the drawing: the rank folds its tiles up band by band, the
-- southmost row lowest, and fy = 0 is the topmost drawn row.
local function bookcasePanes(map, data, perRow, run, i, j)
if not data then return nil end
local bands = run[i].bands
local size = run[i].front - run[i].top + 1
local W, H = (j - i + 1) * 8, bands * 8
local light, srcU, srcV = {}, {}, {}
for fy = 0, H - 1 do
local band = bands - 1 - math.floor(fy / 8)
local row = fy % 8
for fx = 0, W - 1 do
local col = run[i + math.floor(fx / 8)]
local tile = band < size and map:tileAt(col.tx, col.front - band)
or col.cap
if tile then
local k = fy * W + fx
local ax = (tile % perRow) * 8 + fx % 8
local ay = math.floor(tile / perRow) * 8 + row
srcU[k], srcV[k] = ax, ay
local r, g, b, a = data:getPixel(ax, ay)
light[k] = a ~= 0
and Structures.shadeClass(math.min(r, g, b)) ~= "black"
end
end
end
-- The drawing's non-black regions, split across its black frames. A
-- region that reaches the face's own border is not sealed by anything
-- -- it is a course of the front running edge to edge, the way a
-- masonry band or a wall of siding does -- and it stays flush. That
-- test is what keeps this rule to shelves: `bookcase` also collapses
-- the League's gate walls and the terraces, and their courses run off
-- the drawing, so nothing there sinks.
local pane, seen = {}, {}
for k0 = 0, W * H - 1 do
if light[k0] and not seen[k0] then
local cells, stack = {}, { k0 }
seen[k0] = true
local ax0, ax1 = k0 % W, k0 % W
local ay0, ay1 = math.floor(k0 / W), math.floor(k0 / W)
local edge = false
while #stack > 0 do
local k = table.remove(stack)
cells[#cells + 1] = k
local cx, cy = k % W, math.floor(k / W)
if cx < ax0 then ax0 = cx end
if cx > ax1 then ax1 = cx end
if cy < ay0 then ay0 = cy end
if cy > ay1 then ay1 = cy end
if cx == 0 or cx == W - 1 or cy == 0 or cy == H - 1 then
edge = true
end
for _, d in ipairs(DIRS4) do
local nx, ny = cx + d[1], cy + d[2]
if nx >= 0 and nx < W and ny >= 0 and ny < H then
local nk = ny * W + nx
if light[nk] and not seen[nk] then
seen[nk] = true
stack[#stack + 1] = nk
end
end
end
end
if not edge and ax1 - ax0 < BOOK_RECESS_MAX
and ay1 - ay0 < BOOK_RECESS_MAX then
for _, k in ipairs(cells) do pane[k] = true end
end
end
end
return pane, srcU, srcV, W, H
end
local function bookcaseRank(S, map, perRow, run, i, j, k, pane, srcU, srcV,
bankW, bankH)
local r = run[k]
local tx, northTy, frontTy, capTile = r.tx, r.top, r.front, r.cap
local quads = S.objectQuads
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local function uvRect(tile)
local ax = (tile % perRow) * 8
local ay = math.floor(tile / perRow) * 8
return (ax + 0.5) / atlasW, (ax + 7.5) / atlasW,
(ay + 0.5) / atlasH, (ay + 7.5) / atlasH
end
local size = frontTy - northTy + 1
local bands = r.bands
local h = bands * 8
local depth = math.min(2, size) * 8
local x0, x1 = tx * 8, tx * 8 + 8
local z1 = frontTy * 8 + 8
local z0 = z1 - depth
local fx0 = (k - i) * 8 -- this rank's columns within the bank
-- does the neighbouring column continue this shelf? (flanks only cap
-- the ends of a run of bookcases standing side by side)
local function joined(nx)
local ns = S.shapeAt[keyOf(nx, frontTy)]
return ns ~= nil and ns.art == "bookcase"
end
local function sunk(fx, fy)
if not pane or fx < 0 or fx >= bankW or fy < 0 or fy >= bankH then
return false
end
return pane[fy * bankW + fx] == true
end
for band = 0, bands - 1 do
local tile = band < size and map:tileAt(tx, frontTy - band) or capTile
local u0, u1, v0, v1 = uvRect(tile)
local y0, y1 = band * 8, band * 8 + 8
local fyTop = (bands - 1 - band) * 8
-- The south face: the drawing folded upright. A band with no pane
-- in it is the single quad it has always been; a band that seals
-- one splits into per-row runs of texels, and the pane's run sinks
-- a voxel behind the frame that stays proud around it.
local relief = false
if pane then
for row = 0, 7 do
for c = 0, 7 do
if sunk(fx0 + c, fyTop + row) then relief = true break end
end
if relief then break end
end
end
if not relief then
quads[#quads + 1] = { { x0, y0, z1 }, { x1, y0, z1 },
{ x1, y1, z1 }, { x0, y1, z1 },
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
shade = BOOK_SHADE.south }
else
local ax = (tile % perRow) * 8
local ay = math.floor(tile / perRow) * 8
for row = 0, 7 do
local fy = fyTop + row
local wy = y0 + 7 - row -- the drawing's row 0 is the top
local c = 0
while c < 8 do
local s = sunk(fx0 + c, fy)
local n = 1
while c + n < 8 and sunk(fx0 + c + n, fy) == s do n = n + 1 end
local pz = s and z1 - 1 or z1
local qu0 = (ax + c + 0.05) / atlasW
local qu1 = (ax + c + n - 0.05) / atlasW
local qv0 = (ay + row + 0.05) / atlasH
local qv1 = (ay + row + 1 - 0.05) / atlasH
quads[#quads + 1] = { { x0 + c, wy, pz }, { x0 + c + n, wy, pz },
{ x0 + c + n, wy + 1, pz }, { x0 + c, wy + 1, pz },
uv = { { qu0, qv1 }, { qu1, qv1 }, { qu1, qv0 }, { qu0, qv0 } },
shade = BOOK_SHADE.south }
c = c + n
end
end
-- the reveals: where a sunk texel meets a proud one, the frame's
-- own one-voxel side shows. It wears the PROUD neighbour's texel,
-- because that is the block it belongs to. A pane running off the
-- bank, or off the top or bottom of the rank, needs none: the
-- flank and top faces already close it.
for row = 0, 7 do
local fy = fyTop + row
local wy = y0 + 7 - row
for c = 0, 7 do
if sunk(fx0 + c, fy) then
local X = x0 + c
local function reveal(nfx, nfy, verts, shade)
if nfx < 0 or nfx >= bankW or nfy < 0 or nfy >= bankH then
return
end
if sunk(nfx, nfy) then return end
local nk = nfy * bankW + nfx
if not srcU[nk] then return end
quads[#quads + 1] = { verts[1], verts[2], verts[3], verts[4],
u = (srcU[nk] + 0.5) / atlasW, v = (srcV[nk] + 0.5) / atlasH,
shade = shade }
end
reveal(fx0 + c - 1, fy, {
{ X, wy, z1 }, { X, wy, z1 - 1 },
{ X, wy + 1, z1 - 1 }, { X, wy + 1, z1 } }, BOOK_SHADE.flank)
reveal(fx0 + c + 1, fy, {
{ X + 1, wy, z1 - 1 }, { X + 1, wy, z1 },
{ X + 1, wy + 1, z1 }, { X + 1, wy + 1, z1 - 1 } },
BOOK_SHADE.flank)
reveal(fx0 + c, fy + 1, {
{ X, wy, z1 - 1 }, { X + 1, wy, z1 - 1 },
{ X + 1, wy, z1 }, { X, wy, z1 } }, BOOK_SHADE.sill)
reveal(fx0 + c, fy - 1, {
{ X, wy + 1, z1 }, { X + 1, wy + 1, z1 },
{ X + 1, wy + 1, z1 - 1 }, { X, wy + 1, z1 - 1 } },
BOOK_SHADE.lintel)
end
end
end
end
quads[#quads + 1] = { { x1, y0, z0 }, { x0, y0, z0 },
{ x0, y1, z0 }, { x1, y1, z0 },
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
shade = BOOK_SHADE.north }
if not joined(tx - 1) then
quads[#quads + 1] = { { x0, y0, z0 }, { x0, y0, z1 },
{ x0, y1, z1 }, { x0, y1, z0 },
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
shade = BOOK_SHADE.flank }
end
if not joined(tx + 1) then
quads[#quads + 1] = { { x1, y0, z1 }, { x1, y0, z0 },
{ x1, y1, z0 }, { x1, y1, z1 },
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
shade = BOOK_SHADE.flank }
end
end
local topTile = capTile or map:tileAt(tx, northTy)
local u0, u1, v0, v1 = uvRect(topTile)
for seg = 0, depth / 8 - 1 do
local sz0 = z0 + seg * 8
quads[#quads + 1] = { { x0, h, sz0 }, { x1, h, sz0 },
{ x1, h, sz0 + 8 }, { x0, h, sz0 + 8 },
uv = { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
shade = BOOK_SHADE.top }
end
end
function Structures.buildBookcases(S, map, x0, x1, y0, y1, data, perRow)
perRow = perRow or map.tileset.tilesPerRow or 16
-- What to do with the rows a rank VACATES (see TileShape.bookcaseBackfill).
-- Read once: it is a property of the tileset, not of the column.
local backfill = TileShape.bookcaseBackfill(map.tileset.id)
-- the front's measured relief: on for a shelf, off for the tilesets
-- that borrow the collapse for masonry or machinery
if not TileShape.bookcaseRelief(map.tileset.id) then data = nil end
-- Ranks are collected here and emitted after the sweep: a rank's panes
-- are measured over the whole BANK it stands in (see bookcasePanes),
-- and the bank is only known once every column has been read. Nothing
-- below this loop mutates what the sweep reads, so deferring is free.
local order, banks = {}, {}
for tx = x0, x1 do
local ty = y1
while ty >= y0 do
local s = S.shapeAt[keyOf(tx, ty)]
if s and s.art == "bookcase" then
-- the contiguous pinned run above this front row
local north = ty
while north > y0 do
local ns = S.shapeAt[keyOf(tx, north - 1)]
if ns and ns.art == "bookcase" then north = north - 1 else break end
end
-- ranks of at most four drawn rows, southmost first
local front = ty
while front >= north do
local top = math.max(north, front - 3)
-- adopt the trim row just above as the cap: either undetected
-- structure the profile could not pin, or a row pinned `table`
-- because the same trim tiles cap other furniture too
local capTile = nil
if top == north then
local ck = keyOf(tx, north - 1)
local cs = S.shapeAt[ck]
if cs and not cs.flat and not S.skip[ck] and not S.runs[ck]
and (not cs.authored or cs.class == "table") then
capTile = S.tileAt[ck]
end
end
-- The box is one cell deep, so it covers only the run's southmost
-- rows; everything north of that is vacated. By default a vacated
-- row is skipped and painted with synthesized ground -- right for a
-- shelf standing in a room. `bookcase_backfill = "above"` hands it
-- the cell above the run instead, shape and art, so a wall cut into
-- a terrace has more terrace behind it rather than a trench.
local covered = math.min(2, front - top + 1)
local srcK = keyOf(tx, top - 1)
local src = backfill == "above" and S.shapeAt[srcK] or nil
for cy = top, front do
local tk = keyOf(tx, cy)
if src and cy <= front - covered then
S.shapeAt[tk] = src
S.tileAt[tk] = S.tileAt[srcK]
else
S.skip[tk] = true
S.ground[tk] = false
end
end
-- ranks of the same height standing side by side are one bank
local bands = (front - top + 1) + (capTile and 1 or 0)
local key = top .. ":" .. front .. ":" .. bands
local bank = banks[key]
if not bank then
bank = {}
banks[key] = bank
order[#order + 1] = key
end
bank[#bank + 1] = { tx = tx, top = top, front = front,
cap = capTile, bands = bands }
front = top - 1
end
ty = north - 1
else
ty = ty - 1
end
end
end
-- tx ascends in the sweep above, so each bank's columns are already in
-- order; split them into the contiguous runs that actually touch
for _, key in ipairs(order) do
local run = banks[key]
local i = 1
while i <= #run do
local j = i
while j < #run and run[j + 1].tx == run[j].tx + 1 do j = j + 1 end
local pane, srcU, srcV, bankW, bankH =
bookcasePanes(map, data, perRow, run, i, j)
for k = i, j do
bookcaseRank(S, map, perRow, run, i, j, k,
pane, srcU, srcV, bankW, bankH)
end
i = j + 1
end
end
end
-- ---- stairs: pinned cells that render as real steps ----
-- A cell the profile pins stair_e / stair_w (art "stair") becomes a
-- flight of STAIR_STEPS boxes rising evenly across the cell toward the
-- named side, each the full cell deep. stair_down_e / stair_down_w is
-- the same flight EXCAVATED: the cell opens into a stairwell and the
-- steps descend below floor level toward the named side -- the shape a
-- staircase leading down a floor actually has. The 2D staircase is
-- drawn from the side, so vertical faces (step fronts and stairwell
-- walls) wear the matching slice of that drawing -- the railing's
-- diagonal lands along the stepped silhouette -- while treads sample the
-- art band drawn at their own height.
local STAIR_STEPS = 4
local STAIR_SHADE = { south = 1.0, north = 0.68, tread = 1.0,
riser = 0.82, cap = 0.78,
wellN = 0.9, wellS = 0.55, wellEnd = 0.15,
wellTread = 0.8 }
local function stairCell(S, map, data, cx, cy, s)
local perRow = map.tileset.tilesPerRow or 16
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local quads = S.objectQuads
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
local east = s.class == "stair_e" or s.class == "stair_down_e"
local mx, mz = cx * 16, cy * 16
local h = s.h or 16
local rise = h / STAIR_STEPS
local runW = 16 / STAIR_STEPS
local z0, z1 = mz, mz + 16
-- cell-space art coords (16x16, row 0 the top) -> atlas uv; callers keep
-- a quad's range inside one 8px tile so it never samples across a seam
local function uv(px, py)
px = math.max(0.05, math.min(15.95, px))
py = math.max(0.05, math.min(15.95, py))
local tile = S.tileAt[keyOf(cx * 2 + (px >= 8 and 1 or 0),
cy * 2 + (py >= 8 and 1 or 0))]
return ((tile % perRow) * 8 + px % 8) / atlasW,
(math.floor(tile / perRow) * 8 + py % 8) / atlasH
end
-- corners run bottom-left, bottom-right, top-right, top-left as seen
-- from outside (the mesher's side convention); art rect in cell space
local function face(c1, c2, c3, c4, ax0, ay0, ax1, ay1, shade)
local u0, v0 = uv(ax0, ay0)
local u1, v1 = uv(ax1, ay1)
quads[#quads + 1] = { c1, c2, c3, c4,
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
shade = shade }
end
-- a vertical face spanning heights [fy0, fy1] wearing art rows
-- [ay0, ay1], emitted per 8-row art band so no quad crosses the seam
local function banded(z, ax0, ax1, fy0, fy1, ay0, ay1, shade, flip)
local scale = (fy1 - fy0) / math.max(ay1 - ay0, 0.001)
for _, band in ipairs({ { ay0, math.min(8, ay1) },
{ math.max(ay0, 8), ay1 } }) do
local a0, a1 = band[1], band[2]
if a1 > a0 then
local by1 = fy1 - (a0 - ay0) * scale
local by0 = fy1 - (a1 - ay0) * scale
local xa, xb = mx + ax0, mx + ax1
if flip then
face({ xb, by0, z }, { xa, by0, z }, { xa, by1, z },
{ xb, by1, z }, ax0, a0, ax1, a1, shade)
else
face({ xa, by0, z }, { xb, by0, z }, { xb, by1, z },
{ xa, by1, z }, ax0, a0, ax1, a1, shade)
end
end
end
end
for i = 0, STAIR_STEPS - 1 do
local sx0 = east and (i * runW) or (16 - (i + 1) * runW)
local sx1 = sx0 + runW
local x0, x1 = mx + sx0, mx + sx1
if down then
-- stairwell: tread i sits (i+1) rises below the floor; the walls
-- above it are the excavation, wearing the drawing at its depth
local yTop = -(i + 1) * rise
local dep = (i + 1) * rise
face({ x0, yTop, z0 }, { x1, yTop, z0 },
{ x1, yTop, z1 }, { x0, yTop, z1 },
sx0, dep - 1.4, sx1, dep, STAIR_SHADE.wellTread)
-- stairwell walls above this tread: north wall faces the camera
banded(z0, sx0, sx1, yTop, 0, 0, dep, STAIR_SHADE.wellN)
banded(z1, sx0, sx1, yTop, 0, 0, dep, STAIR_SHADE.wellS, true)
-- riser dropping to this tread from the shallower step
local rx = east and x0 or x1
local ry1 = -i * rise
local rax = east and (sx0 + 0.1) or (sx1 - 1.3)
if east then
face({ rx, yTop, z0 }, { rx, yTop, z1 },
{ rx, ry1, z1 }, { rx, ry1, z0 },
rax, i * rise, rax + 1.2, dep, STAIR_SHADE.riser)
else
face({ rx, yTop, z1 }, { rx, yTop, z0 },
{ rx, ry1, z0 }, { rx, ry1, z1 },
rax, i * rise, rax + 1.2, dep, STAIR_SHADE.riser)
end
-- the deep end: a dark opening under the wall the flight leaves by
if i == STAIR_STEPS - 1 then
local px = east and (mx + 16) or mx
local cax = east and 14.7 or 0.1
if east then
face({ px, -h, z1 }, { px, -h, z0 }, { px, 0, z0 }, { px, 0, z1 },
cax, 0, cax + 1.2, 16, STAIR_SHADE.wellEnd)
else
face({ px, -h, z0 }, { px, -h, z1 }, { px, 0, z1 }, { px, 0, z0 },
cax, 0, cax + 1.2, 16, STAIR_SHADE.wellEnd)
end
end
else
-- rising flight
local yTop = (i + 1) * rise
local py0 = 16 - yTop
-- south + north faces: the drawn flight sliced at this step's column
banded(z1, sx0, sx1, 0, yTop, py0, 16, STAIR_SHADE.south)
banded(z0, sx0, sx1, 0, yTop, py0, 16, STAIR_SHADE.north, true)
-- tread: the step's top, wearing the art band drawn at its height
face({ x0, yTop, z0 }, { x1, yTop, z0 },
{ x1, yTop, z1 }, { x0, yTop, z1 },
sx0, py0, sx1, py0 + 1.4, STAIR_SHADE.tread)
-- riser: the vertical strip exposed above the previous step
local rx = east and x0 or x1
local ry0 = i * rise
local rax = east and (sx0 + 0.1) or (sx1 - 1.3)
if east then
face({ rx, ry0, z0 }, { rx, ry0, z1 },
{ rx, yTop, z1 }, { rx, yTop, z0 },
rax, 16 - yTop, rax + 1.2, 16 - ry0, STAIR_SHADE.riser)
else
face({ rx, ry0, z1 }, { rx, ry0, z0 },
{ rx, yTop, z0 }, { rx, yTop, z1 },
rax, 16 - yTop, rax + 1.2, 16 - ry0, STAIR_SHADE.riser)
end
-- cap the tall end of the flight so it never shows a hole
if i == STAIR_STEPS - 1 then
local px = east and (mx + 16) or mx
local cax = east and 14.7 or 0.1
if east then
face({ px, 0, z1 }, { px, 0, z0 }, { px, h, z0 }, { px, h, z1 },
cax, 0, cax + 1.2, 16, STAIR_SHADE.cap)
else
face({ px, 0, z0 }, { px, 0, z1 }, { px, h, z1 }, { px, h, z0 },
cax, 0, cax + 1.2, 16, STAIR_SHADE.cap)
end
end
end
end
end
function Structures.buildStairs(S, map, x0, x1, y0, y1)
local data = pixels(map.tileset)
for cy = math.floor(y0 / 2), math.floor(y1 / 2) do
for cx = math.floor(x0 / 2), math.floor(x1 / 2) do
local s = S.shapeAt[keyOf(cx * 2, cy * 2)]
if s and s.art == "stair" then
-- claim the cell whichever way the quads go: the mesher must not
-- box or floor it. A rising flight stands on the map's common
-- floor; a stairwell IS the hole, so nothing is painted under it
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
for dy = 0, 1 do
for dx = 0, 1 do
local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
S.skip[tk] = true
if not down then S.ground[tk] = false end
end
end
if data then stairCell(S, map, data, cx, cy, s) end
end
end
end
end
-- ---- volume mode: per-column runs with real drawn heights ----
-- `tiles` is a list of {tx, ty} forming one region (or what is left of one
-- after object extraction); runs are column-local, heights are measured
-- per column and reconciled per region.
function Structures.buildVolume(S, map, tiles)
local cols = {}
for _, c in ipairs(tiles) do
cols[c[1]] = cols[c[1]] or {}
cols[c[1]][c[2]] = true
end
local runs = {}
local heightVotes = {}
local repeatVotes = {}
for tx, ys in pairs(cols) do
-- visit each contiguous vertical run in this column
local sorted = {}
for y in pairs(ys) do sorted[#sorted + 1] = y end
table.sort(sorted)
local i = 1
while i <= #sorted do
local north = sorted[i]
local front = north
while i + 1 <= #sorted and sorted[i + 1] == front + 1 do
i = i + 1
front = sorted[i]
end
i = i + 1
local extent = front - north + 1
-- the column's own reading: its extent, unless its tile sequence
-- repeats -- then the repeat period is the drawn unit. Both readings
-- cap at MAX_ROWS (a long-period repeat is still not one column of
-- drawing).
local unit, repeatRead = math.min(extent, MAX_ROWS), false
if extent > 1 then
local t0 = map:tileAt(tx, front)
for k = 1, extent - 1 do
if map:tileAt(tx, front - k) == t0 then
unit = math.min(math.max(k, 2), MAX_ROWS)
repeatRead = true
break
end
end
-- A one-row TRIM at the column's foot hides a repeat from the
-- scan above, which anchors at the front tile: a cliff plateau
-- ends its south edge in a rounded corner tile, the corner
-- never recurs, and the column read its whole capped extent --
-- a 48px fin (or a whole tent of them) sticking out of a 16px
-- mesa on Routes 3 and 4. When the two rows directly above the
-- front are IDENTICAL, the column is that repeat wearing a trim
-- foot: one course plus the trim is its drawn unit. Doorway
-- columns are untouched -- their run answers to the region
-- (see below) before the unit matters.
if not repeatRead and extent > 2
and map:tileAt(tx, front - 1) == map:tileAt(tx, front - 2) then
unit = 2
repeatRead = true
end
end
local isDoor = false
for ty = north, front do
if S.doorFold[keyOf(tx, ty)] then
isDoor = true
break
end
end
local run = { front = front, north = north, extent = extent,
unit = unit, fromRepeat = repeatRead, door = isDoor }
runs[#runs + 1] = { tx = tx, run = run }
local h = unit * 8
heightVotes[h] = (heightVotes[h] or 0) + 1
if repeatRead then repeatVotes[h] = (repeatVotes[h] or 0) + 1 end
end
end
-- region consensus: the dominant height. A column whose reading came
-- from a repeat adopts it when taller (the column above a doorway
-- repeats internally but belongs to a 48px house); a column that read
-- its full extent keeps it (an attached low wing stays low).
local modeH, modeN = 16, 0
for h, n in pairs(heightVotes) do
if n > modeN or (n == modeN and h > modeH) then modeH, modeN = h, n end
end
-- whether the region's dominant columns are flat repeats (a cliff
-- mound's plateau) rather than drawn facades (a house's front)
local modeRepeat = (repeatVotes[modeH] or 0) * 2 > modeN
-- Whether this REGION's tops are a rim over a uniform body -- what every
-- cliff mound is drawn as: a top edge, then the same rock the whole way
-- down. The top face may then lay that rim once along its north edge and
-- hold the body after it, instead of cycling the rim back every second
-- tile and striping a plateau with edges it should not have.
--
-- Answered per column AND per region, because each catches what the
-- other misses. A mound is one structure many columns wide, and the
-- columns carrying its cave mouth read differently from their neighbours
-- (their drawing ends in the mouth's own tiles): per column alone, those
-- kept cycling while the rest held, leaving rim stubs above the doorway.
-- But a region vote alone silences a genuine rim-over-body column that
-- happens to stand in a region of repeating art -- three of them in the
-- Safari Zone. A column holds if EITHER says so.
--
-- Art that genuinely repeats is not uniform and keeps cycling: the
-- Safari Zone's fence alternates two tiles the whole way down, and there
-- the repeat IS what the drawing says.
local uniformVotes, uniformTotal = 0, 0
for _, r in ipairs(runs) do
local run = r.run
if run.extent > 2 then
uniformTotal = uniformTotal + 1
local body = map:tileAt(r.tx, run.north + 1)
local uniform = true
for d = 2, run.extent - 1 do
if map:tileAt(r.tx, run.north + d) ~= body then
uniform = false
break
end
end
run.ownUniform = uniform
if uniform then uniformVotes = uniformVotes + 1 end
end
end
local regionUniform = uniformTotal > 0 and uniformVotes * 2 > uniformTotal
for _, r in ipairs(runs) do
local run = r.run
local h = run.unit * 8
local adopted = false
local flatDoor = false
if run.door then
-- A folded doorway column answers to its region ENTIRELY. Its own
-- reading spans the door plus everything drawn above it -- a
-- house's full height when the door is a house's, but a 32px
-- tower over a 16px plateau when the door is a cave mouth cut
-- into a cliff mound (Diglett's Cave: the entrance jumped a block
-- above the mound around it). Height and top both come from the
-- region: the mode height, roofed like a facade when the mode
-- columns are drawn facades, flat when they are flat repeats.
h = modeH
adopted = not modeRepeat
flatDoor = modeRepeat
elseif run.fromRepeat and modeH > h then
h = modeH
adopted = true
end
-- Outdoors, a structure's top rows are its ROOF: the drawn height
-- splits into a vertical facade and a slope rising north to the drawn
-- peak (the mesher builds it; hips close the exposed flanks). Repeat
-- patterns (a border wall) stay flat-topped -- unless they adopted
-- their region's height, which means they are part of a building (the
-- column above a doorway) and roof with it. Total height is always
-- the drawn height: facade + rise = extent rows * 8.
--
-- But only PITCHED roofs slope. Gen 1 draws two kinds: a pitched roof
-- has distinct ridge and eaves rows (the houses' stripes), while a
-- flat ROOFTOP (the lab, the mart) repeats one texture tile over the
-- whole roof area -- and a rooftop tilted into a 48px ramp reads
-- wrong instantly. Distinct top rows -> slope; repeated -> level top.
local roofRows = 0
if S.outdoor and (not run.fromRepeat or adopted) and h >= 16
and not flatDoor then
roofRows = math.min(2, math.floor(h / 8) - 1)
if roofRows > 0 and map:tileAt(r.tx, run.north)
== map:tileAt(r.tx, run.north + 1) then
roofRows = 0
end
end
run.roofRows = roofRows
run.rise = roofRows * 8
run.peak = h
run.h = h - run.rise -- facade height: what sides build to
run.topUniform = run.ownUniform or regionUniform
for ty = run.north, run.front do
S.runs[keyOf(r.tx, ty)] = run
end
end
end
-- ---- object mode: per-pixel voxelization of drawn props ----
local OBJ_SHADE = { front = 1.0, back = 0.68, side = 0.78,
top = 1.0, bottom = 0.55 }
-- The four GB shades, by a pixel's darkest channel. Force-mode
-- segmentation reasons in these: black is always outline/object, the
-- other three are background only where they touch the cluster's edge.
function Structures.shadeClass(v)
if v <= 0.25 then return "black" end
if v <= 0.55 then return "dark" end
if v <= 0.85 then return "light" end
return "white"
end
-- Analyze one region's art against its surroundings, voxelize the
-- sprite-like clusters, and return the tiles that remain for volume mode.
-- `force` (profile-pinned billboards) voxelizes every tile of the region
-- unconditionally -- the pin IS the classification. `force = "opaque"`
-- (the `post` pool) keeps the decree -- every tile is a prop, aprons
-- seed the flood, validation is skipped -- but classifies pixels the way
-- the DETECTOR does (everything non-white is solid) instead of by
-- outline shade: a fence's mid browns are its body, and the outline
-- rule would strip the posts to black skeletons.
function Structures.extractObjects(S, map, region, data, perRow, force)
local bw = (region.maxX - region.minX + 1) * 8
local bh = (region.maxY - region.minY + 1) * 8
local member = {}
for _, c in ipairs(region.tiles) do member[keyOf(c[1], c[2])] = true end
-- Image over the region bbox plus a 1px ground apron. Pixel states:
-- solid opaque member art (non-white, or white that survives)
-- cand member white: background candidate, the flood decides
-- air ground the flood may travel: INSIDE the bbox (the gaps
-- between fence posts), or the SOUTH apron row. This is the
-- direction the viewer reads background from -- a prop's
-- white meets the ground at its feet. OUTDOORS the other
-- aprons are barriers on purpose: a building's roof stripes
-- touch the grass BEHIND it, and a flood allowed to walk
-- around the sides would pour in from the north and shred the
-- roof into misdetected sprite clusters (it did). INDOORS all
-- four aprons seed: furniture backs onto walls and bottom-row
-- props meet the void ring, so the south row alone often
-- cannot reach the background at all -- and there are no
-- roofs inside to protect.
-- barrier everything else
local W, H = bw + 2, bh + 2
local state = {}
local srcU, srcV = {}, {}
for iy = 0, H - 1 do
for ix = 0, W - 1 do
Budget.tick()
local i = iy * W + ix
local px, py = ix - 1, iy - 1
local tx = region.minX + math.floor(px / 8)
local ty = region.minY + math.floor(py / 8)
local k = keyOf(tx, ty)
local inside = px >= 0 and px < bw and py >= 0 and py < bh
-- a forced (pinned) prop floods from every apron even when the
-- neighbours are solid: the pin itself declares the art a prop
-- whose whites are background -- a monitor pinned atop its desk has
-- no flat neighbour anywhere to seed from
local apron = iy == H - 1
or ((force or not S.outdoor)
and (iy == 0 or ix == 0 or ix == W - 1))
if inside and member[k] then
local tile = S.tileAt[k]
local ax = (tile % perRow) * 8 + px % 8
local ay = math.floor(tile / perRow) * 8 + py % 8
srcU[i], srcV[i] = ax, ay
local r, g, b, a = data:getPixel(ax, ay)
if a == 0 then
state[i] = "cand"
elseif force and force ~= "opaque" then
state[i] = Structures.shadeClass(math.min(r, g, b))
else
state[i] = math.min(r, g, b) > 0.83 and "cand" or "solid"
end
elseif inside or apron then
if force then
-- a pinned prop's surroundings are background BY DECREE -- the
-- pin declares the drawing a prop even when every neighbour is
-- solid furniture (a vase boxed in by its table). Ring pixels
-- seed the flood outright; interior non-member pixels ("iair")
-- seed it too but never drain paint whites -- only a white run
-- reaching the RING is background white.
state[i] = inside and "iair" or "air"
else
local s = S.shapeAt[k]
state[i] = (s and s.flat and s.class ~= "void") and "air"
or "barrier"
end
else
state[i] = "barrier"
end
end
end
-- Forced (pinned) props are segmented the way the art is authored:
-- objects wear a BLACK OUTLINE, and the background is whatever shades
-- actually touch the cluster's edge -- the white floor around a TV,
-- the grey tabletop around a vase. Only those shades flood; the
-- outline, its interior, the drawing's paint whites and anything they
-- enclose all survive as the object.
--
-- The `cutout` pool is STRICTER, per the pure-profile contract: mid
-- shades are always background (a drawn cast shadow must not ring the
-- object in brown), and whites flood only along white runs from the
-- edge -- a background white sheet drains away, but paint whites the
-- flood could only reach through grey are the object.
if force and force ~= "opaque" then
local strict = false
do
local fs = S.shapeAt[keyOf(region.tiles[1][1], region.tiles[1][2])]
strict = fs ~= nil and fs.class == "cutout"
end
-- The rim vote reads the shades on the DRAWING'S OWN bounding box, so a
-- prop whose body reaches its own edge votes itself out. The Center's
-- potted plants are the case: the pot's olive base is drawn flush on the
-- bottom row of the block, so "dark" came back as background and every
-- dark pixel in the whole plant drained with it -- the pots rendered as
-- hollow black frames while the 2D art has solid olive bodies.
--
-- Where the vote misreads the art, the profile can name the background
-- shades outright (a tileset entry's prop_bg). Keyed BY TILE rather than
-- per tileset, because the answer is per drawing: the healing consoles'
-- screens really do stand on a dark wall band and really do need dark
-- voted out, and the PC really does need light kept.
local bg = {}
do
local named = TileShape.propBg(map.tileset.id)
if named then
for _, c in ipairs(region.tiles) do
local rule = named[S.tileAt[keyOf(c[1], c[2])]]
if rule then
for shadeName in pairs(rule) do bg[shadeName] = true end
break
end
end
end
end
if not next(bg) then
for iy = 0, H - 1 do
for ix = 0, W - 1 do
local px, py = ix - 1, iy - 1
local edge = px == 0 or px == bw - 1 or py == 0 or py == bh - 1
local st = state[iy * W + ix]
if edge and (st == "dark" or st == "light" or st == "white") then
bg[st] = true
end
end
end
if not (bg.dark or bg.light or bg.white) then bg.white = true end
end
for i, st in pairs(state) do
if strict then
if st == "dark" or st == "light" then
state[i] = "cand"
elseif st == "white" then
state[i] = "wcand"
elseif st == "black" then
state[i] = "solid"
end
elseif st == "dark" or st == "light" or st == "white" then
state[i] = bg[st] and "cand" or "solid"
elseif st == "black" then
state[i] = "solid"
end
end
end
-- flood background in from the ground at the structure's feet
local flooded = {}
local queue = {}
for i, st in pairs(state) do
if st == "air" or st == "iair" then
flooded[i] = true
queue[#queue + 1] = i
end
end
while #queue > 0 do
Budget.tick()
local i = table.remove(queue)
local ix, iy = i % W, math.floor(i / W)
for _, d in ipairs(DIRS4) do
local nx, ny = ix + d[1], iy + d[2]
if nx >= 0 and nx < W and ny >= 0 and ny < H then
local ni = ny * W + nx
if not flooded[ni] then
local ns = state[ni]
-- "wcand" (a strict cutout's white) drains only along a white
-- run that reaches the RING: entered from the outer apron or
-- from another flooded white, never through grey or through
-- interior air
if ns == "cand" or ns == "air" or ns == "iair"
or (ns == "wcand"
and (state[i] == "air" or state[i] == "wcand")) then
flooded[ni] = true
queue[#queue + 1] = ni
end
end
end
end
end
-- per-tile background ratio -> sprite-like tiles (a pinned billboard is
-- sprite-like by decree)
local sprite = {}
for _, c in ipairs(region.tiles) do
Budget.tick()
if force then
sprite[keyOf(c[1], c[2])] = true
else
local bx = (c[1] - region.minX) * 8
local by = (c[2] - region.minY) * 8
local bg = 0
for py = 0, 7 do
for px = 0, 7 do
if flooded[(by + py + 1) * W + (bx + px + 1)] then bg = bg + 1 end
end
end
if bg / 64 >= TILE_BG_RATIO then sprite[keyOf(c[1], c[2])] = true end
end
end
-- cluster sprite-like tiles; validate each cluster as one prop
local leftover, claimed = {}, {}
local clusterSeen = {}
for _, c in ipairs(region.tiles) do
local k = keyOf(c[1], c[2])
if sprite[k] and not clusterSeen[k] then
local cluster = { tiles = {}, minX = c[1], maxX = c[1],
minY = c[2], maxY = c[2] }
local queue2 = { c }
clusterSeen[k] = true
while #queue2 > 0 do
local cc = table.remove(queue2)
cluster.tiles[#cluster.tiles + 1] = cc
cluster.minX = math.min(cluster.minX, cc[1])
cluster.maxX = math.max(cluster.maxX, cc[1])
cluster.minY = math.min(cluster.minY, cc[2])
cluster.maxY = math.max(cluster.maxY, cc[2])
for _, d in ipairs(DIRS4) do
local nk = keyOf(cc[1] + d[1], cc[2] + d[2])
if sprite[nk] and not clusterSeen[nk] then
clusterSeen[nk] = true
queue2[#queue2 + 1] = { cc[1] + d[1], cc[2] + d[2] }
end
end
end
if Structures.buildObject(S, map, region, cluster,
state, flooded, srcU, srcV, W, force) then
for _, cc in ipairs(cluster.tiles) do
claimed[keyOf(cc[1], cc[2])] = true
end
end
end
end
for _, c in ipairs(region.tiles) do
if not claimed[keyOf(c[1], c[2])] then leftover[#leftover + 1] = c end
end
return leftover
end
-- One sprite-like cluster -> a per-pixel voxel prism, or false when it
-- fails validation (too tall, vertically repeating, too big) and should
-- stay part of the volume.
function Structures.buildObject(S, map, region, cluster,
state, flooded, srcU, srcV, W, force)
local rows = cluster.maxY - cluster.minY + 1
if not force then
if rows > OBJECT_MAX_ROWS then return false end
-- a prop stands ON the ground: somewhere the cluster must meet flat
-- ground to its south. A cluster carved out of a structure's middle
-- (roof rows whose whites leaked) fails this and stays in the volume.
-- Indoors any side will do -- furniture backs onto walls and bottom-row
-- props meet the void ring, so south alone is too strict.
local dirs = S.outdoor and { { 0, 1 } } or DIRS4
local touchesGround = false
for _, c in ipairs(cluster.tiles) do
for _, d in ipairs(dirs) do
local ss = S.shapeAt[keyOf(c[1] + d[1], c[2] + d[2])]
if ss and ss.flat and ss.class ~= "void" then
touchesGround = true
break
end
end
if touchesGround then break end
end
if not touchesGround then return false end
-- a vertically repeating cluster (tree wall edge) is scenery, not a
-- prop
local cols = {}
for _, c in ipairs(cluster.tiles) do
cols[c[1]] = cols[c[1]] or {}
cols[c[1]][c[2]] = true
end
for tx, ys in pairs(cols) do
local front = nil
for y in pairs(ys) do front = math.max(front or y, y) end
local extent = 0
while ys[front - extent] do extent = extent + 1 end
if extent > 1 then
local t0 = map:tileAt(tx, front)
for k = 1, extent - 1 do
if map:tileAt(tx, front - k) == t0 then return false end
end
end
end
end
local memberC = {}
for _, c in ipairs(cluster.tiles) do memberC[keyOf(c[1], c[2])] = true end
-- solid pixels of this cluster (art minus flooded background)
local solidPx, count, bgCount = {}, 0, 0
local bw = (cluster.maxX - cluster.minX + 1) * 8
local bh = (cluster.maxY - cluster.minY + 1) * 8
for _, c in ipairs(cluster.tiles) do
local rx = (c[1] - region.minX) * 8
local ry = (c[2] - region.minY) * 8
for py = 0, 7 do
Budget.tick()
for px = 0, 7 do
local i = (ry + py + 1) * W + (rx + px + 1)
local on = state[i] ~= nil and state[i] ~= "air"
and state[i] ~= "iair" and state[i] ~= "barrier"
and not flooded[i]
if on then
local lx = (c[1] - cluster.minX) * 8 + px
local ly = (c[2] - cluster.minY) * 8 + py
solidPx[ly * bw + lx] = i
count = count + 1
else
bgCount = bgCount + 1
end
end
end
end
if count == 0 or count > OBJECT_MAX_QUADS then return false end
if not force and bgCount / (count + bgCount) < CLUSTER_MIN_BG then
return false
end
-- geometry: each solid pixel is one voxel column deep enough to read as
-- a body, standing at the cluster's south row, base on the ground plane
local depth = OBJECT_DEPTH
if force then
local cs = S.shapeAt[keyOf(cluster.tiles[1][1], cluster.tiles[1][2])]
depth = (cs and PINNED_DEPTH[cs.class]) or PINNED_DEPTH.billboard
end
local wx0 = cluster.minX * 8
-- A pinned prop drawn directly above an authored box stands ON it -- a
-- monitor on its desk, a flower pot on the table. The prism rises from
-- the box's top with its feet on the box's north row, and the claimed
-- tiles keep rendering as that box (wearing its plain art) instead of
-- punching a floor-level hole through it.
--
-- Only when the prop's OWN CELL IS BLOCKED, though. "Is something
-- drawn above me?" is not the same question as "am I standing on it":
-- a chair drawn against the north side of a table is above the table's
-- trim row too, and it was being lifted onto the tabletop -- three
-- chairs standing on the furniture in Cinnabar's trade room and
-- Fuchsia's meeting room, with the claimed cells re-tiled as tabletop
-- so the table marched two rows north with them. The world already
-- knows which is which: a thing that sits ON furniture occupies a
-- blocked cell (you cannot walk through the gym statue, Red's plant,
-- the PC), while a seat you walk up to is in a walkable one.
--
-- FENCE POSTS (the `post` pool, force == "opaque") never take the lift
-- at all. A post stands in the ground by definition -- it is not a
-- thing set down on top of something -- and its cell is blocked like
-- any other post, so the test above cannot tell it apart. Lavender
-- Town is where it showed: pinning the cliff's slope chain gave the
-- posts along the cliff edge an authored 16px box to their south, and
-- they were hoisted to stand on the clifftop instead of the path.
local baseY, support = 0, nil
if force and force ~= "opaque" then
local bs = S.shapeAt[keyOf(cluster.minX, cluster.maxY + 1)]
local blocked = not map:isWalkableCell(math.floor(cluster.minX / 2),
math.floor(cluster.maxY / 2))
-- `bookcase` supports as well as `upright`. A prop drawn above an
-- authored box stands ON it whatever art the box renders with, and a
-- stacked box is still a box: the Plateau's gate pilasters carry a
-- statue on 48 of their tops, and collapsing the pilaster to a stacked
-- run made every one of them fail this test and drop to ground level.
-- A `building` claim supports too, when it carries a height: a
-- Buildings template that names `support` is furniture modelled in
-- full with a standee left standing on it (Red's dining table under
-- its potted plant), and the height it states is the model's top
-- plane. A plain claim stays at h = 0 and supports nothing.
if blocked and bs and bs.authored and (bs.h or 0) > 0
and (bs.art == "upright" or bs.art == "bookcase"
or bs.class == "building") then
baseY, support = bs.h, bs
end
end
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local quads = S.objectQuads
local function at(lx, ly)
if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return nil end
return solidPx[ly * bw + lx]
end
-- Connected components: one cluster can hold several OBJECTS -- two
-- stools stacked in adjacent cells, a loose leaf beside a vase. Each
-- component stands on its own feet (base on the ground or the support
-- box, never floating at its bbox height) in the depth band of the
-- tile row its lowest pixel is drawn in, so stacked drawings become
-- separate standees in their own cells instead of one tower.
-- 8-connectivity keeps diagonal strokes whole.
local comp, comps = {}, {}
for ly = 0, bh - 1 do
Budget.tick()
for lx = 0, bw - 1 do
local idx = ly * bw + lx
if solidPx[idx] and not comp[idx] then
local c = { lowY = ly, n = 0 }
comps[#comps + 1] = c
local stack = { idx }
comp[idx] = c
while #stack > 0 do
local p = table.remove(stack)
local px, py = p % bw, math.floor(p / bw)
c.n = c.n + 1
if py > c.lowY then c.lowY = py end
for dy = -1, 1 do
for dx = -1, 1 do
local nx, ny = px + dx, py + dy
if (dx ~= 0 or dy ~= 0) and nx >= 0 and nx < bw
and ny >= 0 and ny < bh then
local ni = ny * bw + nx
if solidPx[ni] and not comp[ni] then
comp[ni] = c
stack[#stack + 1] = ni
end
end
end
end
end
end
end
end
for _, c in ipairs(comps) do
c.z0 = cluster.minY * 8 + math.floor(c.lowY / 8) * 8
+ (support and 8 or 0) + (8 - depth) / 2
c.z1 = c.z0 + depth
end
-- A `cutout` or `console` pin is ONE object by contract: keep only
-- the largest connected drawing. Loose black scraps -- a cast
-- shadow's drawn edge, a seam, the vertical rules the surrounding
-- furniture draws down its own edges -- are background even though
-- black pixels always survive the shade flood, and this is what
-- removes them. Every other pool may hold several objects per
-- cluster (two stools side by side, a leaf beside a vase), so this
-- cannot be the default.
if force then
local cs = S.shapeAt[keyOf(cluster.tiles[1][1], cluster.tiles[1][2])]
if cs and (cs.class == "cutout" or cs.class == "console")
and #comps > 1 then
local biggest = comps[1]
for _, c in ipairs(comps) do
if c.n > biggest.n then biggest = c end
end
for idx, c in pairs(comp) do
if c ~= biggest then solidPx[idx] = nil end
end
end
end
for ly = 0, bh - 1 do
Budget.tick()
for lx = 0, bw - 1 do
local i = at(lx, ly)
if i then
local c = comp[ly * bw + lx]
local z0, z1 = c.z0, c.z1
local x, y = wx0 + lx, baseY + c.lowY - ly
local u = (srcU[i] + 0.5) / atlasW
local v = (srcV[i] + 0.5) / atlasH
local function quad(c1, c2, c3, c4, shade)
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
end
quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y + 1, z1 },
{ x, y + 1, z1 }, OBJ_SHADE.front)
quad({ x + 1, y, z0 }, { x, y, z0 }, { x, y + 1, z0 },
{ x + 1, y + 1, z0 }, OBJ_SHADE.back)
if not at(lx, ly - 1) then
quad({ x, y + 1, z0 }, { x + 1, y + 1, z0 }, { x + 1, y + 1, z1 },
{ x, y + 1, z1 }, OBJ_SHADE.top)
end
if y > baseY and not at(lx, ly + 1) then
quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y, z0 },
{ x, y, z0 }, OBJ_SHADE.bottom)
end
if not at(lx - 1, ly) then
quad({ x, y, z0 }, { x, y, z1 }, { x, y + 1, z1 },
{ x, y + 1, z0 }, OBJ_SHADE.side)
end
if not at(lx + 1, ly) then
quad({ x + 1, y, z1 }, { x + 1, y, z0 }, { x + 1, y + 1, z0 },
{ x + 1, y + 1, z1 }, OBJ_SHADE.side)
end
end
end
end
-- the ground the prop stands on: the commonest flat tile touching the
-- cluster, painted under every cluster tile (the art that was there is
-- now standing up as the object)
local votes, best, bestN = {}, nil, 0
for _, c in ipairs(cluster.tiles) do
for _, d in ipairs(DIRS4) do
local nk = keyOf(c[1] + d[1], c[2] + d[2])
local ns = S.shapeAt[nk]
if ns and ns.flat and ns.class ~= "void" and not memberC[nk] then
local t = S.tileAt[nk]
votes[t] = (votes[t] or 0) + 1
if votes[t] > bestN then best, bestN = t, votes[t] end
end
end
end
for _, c in ipairs(cluster.tiles) do
local k = keyOf(c[1], c[2])
if support and (support.class == "wall" or support.class == "cliff"
or support.art == "bookcase"
or support.class == "building") then
-- a figure drawn above a FULL-HEIGHT block (the gym statue on its
-- plinth) is a statue on a pillar with ONE cell of footprint: the
-- block below already carries the whole base, so the drawn cell
-- becomes synthesized floor rather than a second block marching
-- the base backwards. Furniture supports (a monitor on its desk)
-- keep the box-extension below -- their drawn cell is the
-- furniture's own upper rows, and floor there would amputate it.
--
-- STRUCTURE, not height, decides which: `cliff` and `bookcase` are
-- full-height blocks like `wall` and belong here, while `desk` is
-- 24px and still furniture. The Plateau's statues on stacked
-- pilasters found this -- taking the furniture branch turned each
-- statue's own two rows into a 32px box wearing the pilaster's art,
-- so every one of them stood inside a slab of its own plinth.
-- A `building` support belongs here too: the template's stamped
-- model already carries every surface under the standee (that is
-- what its `support` height asserts), so a box here would stand
-- INSIDE the modelled tabletop. Its stamp pre-painted the floor
-- under these tiles, which the `or` keeps when no flat tile
-- touches a cluster ringed by its own furniture.
S.skip[k] = true
S.ground[k] = best or S.ground[k]
elseif support then
-- the claimed tile keeps rendering as the box the prop stands on,
-- wearing the art its own ROW would have without the drawing (the
-- trim row stays trim); only when the whole row is the prop does
-- it fall back to the row below
S.shapeAt[k] = support
local src = keyOf(c[1], cluster.maxY + 1)
for dx = 1, 3 do
for _, sx in ipairs({ c[1] - dx, c[1] + dx }) do
local nk = keyOf(sx, c[2])
local ns = S.shapeAt[nk]
if not memberC[nk] and ns and ns.authored
and ns.class == support.class then
src = nk
break
end
end
if src ~= keyOf(c[1], cluster.maxY + 1) then break end
end
S.tileAt[k] = S.tileAt[src]
else
S.skip[k] = true
S.ground[k] = best
end
end
return true
end
-- ---- authored masks with a body ----
-- One authored mask emitted as a per-pixel voxel slab in WORLD space --
-- the treatment every solid standee in this file gets, driven by a hand
-- drawn silhouette instead of a flood.
--
-- The caller owns placement entirely, because placement is the whole
-- difference between the two things that use this: `x0` is the world x of
-- the mask's west edge, `yOf(ly)` the world y a drawn row lands at, and
-- `bandOf(ly)` its z span. A bicycle hung on a wall keeps its drawn
-- elevation and juts south of the band; a cash register stands on the
-- counter's top plane and sits inside its own cell.
--
-- `bandOf` is per ROW rather than per object so one drawing can hold parts
-- of different thickness (the register's receipt curl over its body).
-- Where the band CHANGES between two stacked rows the lower row still gets
-- its top face: without that the body would be open along the strip the
-- thinner part does not cover, and you would see into the machine.
--
-- `omit` is a rect of the mask this pass does NOT extrude, because it is
-- not a face at all -- maskPlate lays it flat instead. It leaves the mask
-- for good here, neighbours included, so the extrusion closes up around
-- the notch exactly as if the drawing had never filled it.
local function maskSlab(quads, m, perRow, atlasW, atlasH, x0, yOf, bandOf,
yFloor, omit)
local bw, bh = m.w * 8, m.h * 8
local function at(lx, ly)
if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end
if omit and lx >= omit.x0 and lx <= omit.x1
and ly >= omit.r0 and ly <= omit.r1 then return false end
return m.mask[ly * bw + lx] or false
end
for ly = 0, bh - 1 do
Budget.tick()
local z0, z1 = bandOf(ly)
local pz0, pz1 = bandOf(ly - 1)
local capped = (pz0 ~= z0 or pz1 ~= z1)
for lx = 0, bw - 1 do
if at(lx, ly) then
local tile = m.tiles[math.floor(ly / 8) * m.w
+ math.floor(lx / 8) + 1]
local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW
local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH
local x, y = x0 + lx, yOf(ly)
local function quad(c1, c2, c3, c4, shade)
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
end
quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y + 1, z1 },
{ x, y + 1, z1 }, OBJ_SHADE.front)
quad({ x + 1, y, z0 }, { x, y, z0 }, { x, y + 1, z0 },
{ x + 1, y + 1, z0 }, OBJ_SHADE.back)
if capped or not at(lx, ly - 1) then
quad({ x, y + 1, z0 }, { x + 1, y + 1, z0 }, { x + 1, y + 1, z1 },
{ x, y + 1, z1 }, OBJ_SHADE.top)
end
if y > yFloor and not at(lx, ly + 1) then
quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y, z0 },
{ x, y, z0 }, OBJ_SHADE.bottom)
end
if not at(lx - 1, ly) then
quad({ x, y, z0 }, { x, y, z1 }, { x, y + 1, z1 },
{ x, y + 1, z0 }, OBJ_SHADE.side)
end
if not at(lx + 1, ly) then
quad({ x + 1, y, z1 }, { x + 1, y, z0 }, { x + 1, y + 1, z0 },
{ x + 1, y + 1, z1 }, OBJ_SHADE.side)
end
end
end
end
end
-- The other half of the same drawing: a rect of the mask that is a
-- TOP-VIEW surface, laid HORIZONTAL instead of extruded.
--
-- This is the methodology's band classification at rect granularity, and
-- the reason the register is not a box. A GB cell packs several facings,
-- and the register's keypad is drawn from ABOVE -- its keys lie on the
-- machine's deck, sealed behind their own black border inside the outer
-- silhouette. Extruding it stands that surface on end and paints the keys
-- up the machine's face, which is the extruded-picture failure exactly.
--
-- So the rect lands one voxel proud of what maskSlab left below it, at `y`,
-- one voxel thick, filling the body's whole depth band (`z0`, `D`).
--
-- The rect STRETCHES over that band rather than laying its rows 1:1: it is
-- the machine's whole deck, so it has to reach the machine's whole depth,
-- and the alternative -- panel at the front, bare deck behind -- leaves a
-- strip of the base band's top showing through where the keys should be.
-- Sampled at the voxel's CENTRE, the same rule Stage 1 samples the atlas
-- with, so a band scales by whole voxels and nothing blurs: at 8 rows over
-- 12 voxels every second drawn row doubles. The one place in the model
-- where a texel is not 1:1 with a drawn pixel, and the reason `depth` is an
-- authored number again. No bottom faces: it rests on the box.
local function maskPlate(quads, m, perRow, atlasW, atlasH, x0, r, y, z0, D)
local bw, bh = m.w * 8, m.h * 8
local rows = r.r1 - r.r0 + 1
-- depth voxel -> the drawn row it wears
local function rowAt(k)
if k < 0 or k >= D then return nil end
return r.r0 + math.min(rows - 1, math.floor((k + 0.5) * rows / D))
end
local function at(lx, k)
local ly = rowAt(k)
if not ly or lx < r.x0 or lx > r.x1 then return false end
return m.mask[ly * bw + lx] or false
end
-- The plate's rim, in the two directions the drawing treats differently.
-- ACROSS the rows the neighbour is the extrusion standing BESIDE the
-- notch (the register's display unit), which is tall and covers the
-- plate's edge, so that face must not be drawn twice. ALONG them the
-- neighbour is the extrusion BELOW it (the base band, whose own front
-- face stops one voxel short), so the plate's front lip is exposed and
-- is the deck's own front edge.
local function beside(lx, ly)
if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end
return m.mask[ly * bw + lx] or false
end
for k = 0, D - 1 do
Budget.tick()
local ly, z = rowAt(k), z0 + k
for lx = r.x0, r.x1 do
if at(lx, k) then
local tile = m.tiles[math.floor(ly / 8) * m.w
+ math.floor(lx / 8) + 1]
local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW
local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH
local x = x0 + lx
local function quad(c1, c2, c3, c4, shade)
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
end
quad({ x, y + 1, z }, { x + 1, y + 1, z }, { x + 1, y + 1, z + 1 },
{ x, y + 1, z + 1 }, OBJ_SHADE.top)
if not at(lx, k + 1) then
quad({ x, y, z + 1 }, { x + 1, y, z + 1 }, { x + 1, y + 1, z + 1 },
{ x, y + 1, z + 1 }, OBJ_SHADE.front)
end
if not at(lx, k - 1) then
quad({ x + 1, y, z }, { x, y, z }, { x, y + 1, z },
{ x + 1, y + 1, z }, OBJ_SHADE.back)
end
if not beside(lx - 1, ly) then
quad({ x, y, z }, { x, y, z + 1 }, { x, y + 1, z + 1 },
{ x, y + 1, z }, OBJ_SHADE.side)
end
if not beside(lx + 1, ly) then
quad({ x + 1, y, z + 1 }, { x + 1, y, z }, { x + 1, y + 1, z },
{ x + 1, y + 1, z + 1 }, OBJ_SHADE.side)
end
end
end
end
end
-- ---- figures: a thing drawn INTO furniture, cut out and stood up ----
-- One authored figure at one matched position.
--
-- The mask IS the classification: no flood, no shade segmentation, no
-- validation gate. Every automatic route in this file asks the art where
-- the object ends, and a figure painted into its own furniture has no
-- answer to give -- so the profile answers instead, and this only has to
-- believe it. Which also means figures build HEADLESS: unlike every
-- other standee here, nothing below reads a pixel.
--
-- A PERSON is a SPRITE, not a prop, and an entry that states no `depth`
-- gets exactly the treatment SpriteBillboards gives a character: one flat
-- plane of the drawing's own pixels, no thickness, standing at its feet
-- and leaned back by the camera's pitch at draw time so it always reads
-- face-on -- because that is what the artwork is. A seated man drawn
-- face-on is a 2D icon like every other Gen 1 figure; extruding him into
-- a slab reconstructs a body nobody drew (the ten-voxel version read as a
-- wedge of furniture, and even one voxel showed an edge the sprites never
-- show).
--
-- So the card's quads are emitted in its OWN LOCAL SPACE -- x from the
-- mask's west edge, y from his feet, all at z = 0 -- and the placement
-- (`wx`, `wz`, `y`) rides along for VoxelScene to build the lean matrix
-- from. One quad per pixel rather than one alpha-keyed texture: the
-- tileset atlas has no alpha to key on, and per-pixel quads cut the exact
-- same silhouette straight out of the live atlas, so every palette bake
-- (SGB, RED++ per-tile groups, a mod's own art) textures him for free.
--
-- An entry that DOES state a `depth` is not a person, and takes the other
-- branch: a per-pixel voxel slab in world space (maskSlab above), standing
-- on the same furniture the card would have stood on. The Marts' cash
-- register is why -- a machine set down on a counter is a box seen from
-- the front, and a card of it is the billboard failure the standee pools
-- exist to avoid. It keeps the card's anchoring exactly: its feet on the
-- support's top plane, and its body in the 8px depth band of the tile row
-- its lowest pixel is drawn in, which is where a character card would
-- have pivoted. So the machine sits at the FRONT of the counter cell it
-- is drawn low in, and never leans into the aisle behind it.
local function buildFigure(S, map, fig, tx, ty, perRow)
local bw, bh = fig.w * 8, fig.h * 8
local function at(lx, ly)
if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end
return fig.mask[ly * bw + lx] or false
end
-- his feet and his west edge: the card's own origin
local lowY, minX = 0, bw - 1
for ly = 0, bh - 1 do
for lx = 0, bw - 1 do
if at(lx, ly) then
if ly > lowY then lowY = ly end
if lx < minX then minX = lx end
end
end
end
-- He stands ON the furniture he was drawn into -- the same lift a pinned
-- prop above a pinned box takes (see buildObject), and gated the same
-- way: a thing set down on furniture occupies a BLOCKED cell, while a
-- seat you merely walk up to is in a walkable one. The row under his
-- card is SCANNED for the tallest authored upright rather than read at
-- its west corner: the corner tile can be furniture that is not his
-- seat (the couch's raised backrest column stands there, `top` art and
-- taller than the cushion he actually sits on).
local baseY = 0
local blocked = not map:isWalkableCell(math.floor(tx / 2),
math.floor((ty + fig.h - 1) / 2))
if blocked then
for dx = 0, fig.w - 1 do
local bs = S.shapeAt[keyOf(tx + dx, ty + fig.h)]
if bs and bs.authored and bs.art == "upright"
and (bs.h or 0) > baseY then
baseY = bs.h
end
end
end
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
if fig.depth then
-- An OBJECT: the standee slab, standing on the FRONT edge of the tile
-- row its feet are drawn in -- the south face of the 8px band a
-- character card would have pivoted in. It is anchored there and
-- grows NORTH rather than being centred, so that `depth` is free to
-- exceed the 8px band without the machine ever creeping toward the
-- aisle: a till drawn low on a counter is at the counter's front, and
-- a deeper one just eats more of the bare top behind it. (At the
-- 8 the band itself is, the two rules agree.)
--
-- `thin` caps the top rows to their own thickness, centred in the
-- body's depth -- the register's receipt curl leaves the arm's top
-- face by a slot in the middle of it, not flush with its front.
local south = ty * 8 + math.floor(lowY / 8) * 8 + 8
local function bandOf(ly)
local z0 = south - fig.depth
if fig.thin and ly < fig.thin.rows then
local m = math.floor((fig.depth - fig.thin.depth) / 2)
return z0 + m, z0 + m + fig.thin.depth
end
return z0, south
end
local function yOf(ly) return baseY + lowY - ly end
maskSlab(S.objectQuads, fig, perRow, atlasW, atlasH, tx * 8,
yOf, bandOf, baseY, fig.flat)
if fig.flat then
-- The top-view rect lands on the plane its own BOTTOM row would
-- have stood at -- which is the top of whatever the extrusion left
-- under it (the register's base band), so the keys lie on the deck
-- and never float.
--
-- In depth it fills the body's whole band, STRETCHED to it: the rect
-- is the machine's deck, so it reaches as deep as the machine does,
-- and its last drawn row stays the deck's front edge directly over
-- the fascia below it -- an object drawn LOW on a surface is drawn
-- NEAR its front.
maskPlate(S.objectQuads, fig, perRow, atlasW, atlasH, tx * 8,
fig.flat, yOf(fig.flat.r1), south - fig.depth, fig.depth)
end
else
local quads = {}
for ly = 0, bh - 1 do
Budget.tick()
for lx = 0, bw - 1 do
if at(lx, ly) then
local tile = fig.tiles[math.floor(ly / 8) * fig.w
+ math.floor(lx / 8) + 1]
local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW
local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH
local x, y = lx - minX, lowY - ly
quads[#quads + 1] = { { x, y, 0 }, { x + 1, y, 0 },
{ x + 1, y + 1, 0 }, { x, y + 1, 0 },
u = u, v = v, shade = 1 }
end
end
end
-- Where the card stands. `wz` is the MIDDLE of the tile row his feet
-- are drawn in, which is the same convention a character card uses
-- (its feet plane sits at its cell's middle) -- so he sorts against
-- the couch and against a player walking past exactly the way an NPC
-- standing there would.
S.figures[#S.figures + 1] = {
quads = quads,
wx = tx * 8 + minX,
wz = ty * 8 + math.floor(lowY / 8) * 8 + 4,
y = baseY,
}
end
-- What each covered tile wears now that he is off it. Only the ART
-- changes: the couch tiles keep their `counter` box (they ARE the
-- couch) and the floor tiles he overhung stay flat floor -- the
-- profile just names the version of each drawing without him in it,
-- so nothing has to be synthesized or repainted from a neighbour vote.
for i = 1, #fig.tiles do
local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w)
S.tileAt[keyOf(tx + dx, ty + dy)] = fig.under[i]
end
end
-- Every authored figure, wherever the map draws it.
--
-- Matched by TILE PATTERN rather than by coordinates: one blockset entry
-- places this couch once in each of the eleven Pokemon Centers (and the
-- Celadon Hotel), so the pattern finds all of them without the profile
-- naming a single map or cell. The repaint above replaces the pattern's
-- own tiles, so a match can never fire twice on the same drawing.
function Structures.buildFigures(S, map, x0, x1, y0, y1)
local figures = TileShape.figures(map.tileset.id)
if not figures then return end
local perRow = map.tileset.tilesPerRow or 16
for _, fig in ipairs(figures) do
for ty = y0, y1 - fig.h + 1 do
for tx = x0, x1 - fig.w + 1 do
Budget.tick()
local hit = true
for i = 1, #fig.tiles do
local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w)
if S.tileAt[keyOf(tx + dx, ty + dy)] ~= fig.tiles[i] then
hit = false
break
end
end
if hit then buildFigure(S, map, fig, tx, ty, perRow) end
end
end
end
end
-- ---- mounted: a thing drawn INTO a wall band, stood proud of it ----
-- One authored mounted object at one matched position.
--
-- Same authoring premise as a figure -- the mask IS the classification,
-- because a drawing painted onto the wall it hangs on has no background
-- margin for a flood to enter by, and here the wall's own #555 stripes
-- are a flood boundary as well, so a silhouette comes back striped.
-- Like a figure it therefore builds HEADLESS: nothing below reads a
-- pixel.
--
-- But a mounted object is an OBJECT, so it is built the way every other
-- standee here is -- a per-pixel voxel slab wearing the drawing's own
-- texels, quads emitted in world space -- and not as a sprite card:
--
-- ELEVATION is the drawn one. A figure stands on its own feet; this
-- keeps the row it is painted in, because the band it is painted into
-- is a measured 16px face rising off the floor. So drawn row `ly`
-- becomes world y = (band height - 1) - ly, and a bicycle whose wheels
-- are drawn on the band's bottom row lands on the floor while one hung
-- clear of it stays hung.
-- DEPTH juts SOUTH of the band's own face (z0 at the drawing's south
-- edge), so the object stands in front of the wall rather than inside
-- it. It overhangs the walkable cell in front, which is what a bicycle
-- leaning on a wall does; nothing about collision changes.
local function buildMountedAt(S, map, m, tx, ty, perRow)
local bh = m.h * 8
local z0 = (ty + m.h) * 8
local z1 = z0 + (m.depth or 2)
maskSlab(S.objectQuads, m, perRow, map.tileset.imageWidth or 128,
map.tileset.imageHeight or 48, tx * 8,
function(ly) return (bh - 1) - ly end,
function() return z0, z1 end, 0)
-- What the band wears now that the object is off it: the plain panel
-- the artist drew everywhere else along the same wall. Only the ART
-- changes -- these tiles keep the `wall` box they always resolved to,
-- because they ARE the wall.
for i = 1, #m.tiles do
local dx, dy = (i - 1) % m.w, math.floor((i - 1) / m.w)
S.tileAt[keyOf(tx + dx, ty + dy)] = m.under[i]
end
end
-- Every authored mounted object, wherever the map draws it. Matched by
-- TILE PATTERN like a figure, and for the same reason -- one blockset
-- entry can place the same drawing in several rooms -- and the repaint
-- above replaces the pattern's own tiles, so a match never fires twice
-- on one drawing.
function Structures.buildMounted(S, map, x0, x1, y0, y1)
local list = TileShape.mounted(map.tileset.id)
if not list then return end
local perRow = map.tileset.tilesPerRow or 16
for _, m in ipairs(list) do
for ty = y0, y1 - m.h + 1 do
for tx = x0, x1 - m.w + 1 do
Budget.tick()
local hit = true
for i = 1, #m.tiles do
local dx, dy = (i - 1) % m.w, math.floor((i - 1) / m.w)
if S.tileAt[keyOf(tx + dx, ty + dy)] ~= m.tiles[i] then
hit = false
break
end
end
if hit then buildMountedAt(S, map, m, tx, ty, perRow) end
end
end
end
end
-- ---- tall grass ----
-- ---- closing a standee's sides ----
--
-- The grass tufts and the flowers are both built the same way: each row of
-- the 8x8 drawing becomes a horizontal RUN of lit pixels, stood up as a
-- front face and a back face one voxel apart, with a lid on top. What that
-- leaves open is the two ENDS of every run -- so the slab was a pair of
-- billboards rather than a solid, and from any angle off square you looked
-- in through the edge and straight out the other side. At the low cameras
-- this mod has grown (1ST, 3RD, the battle's floor-level seat) that is
-- most of the time.
--
-- A wall goes on an end only where the pixel beyond it is actually clear,
-- which for a run's end it is by construction -- except where two runs on
-- the same row meet across a gap of nothing, which cannot happen, and at
-- the tile's border, where the neighbouring tile's own standee may or may
-- not continue the shape. The border is closed anyway: tufts sit on their
-- own half-cells with a gap between them, so an open border edge is a hole
-- in the open, not a seam with anything.
--
-- Each wall samples ONE texel at its centre -- the end pixel it is closing
-- off -- so it wears that pixel's own colour, which is the nearest coloured
-- pixel to the surface being filled. Sampling a single texel is also what
-- carries the animation: when a frame keys that pixel out, the wall's own
-- fragments discard with the faces either side of it, so a swaying tuft
-- never leaves a wall standing where its blade no longer is.
-- `everyPixel` is for a standee whose silhouette ANIMATES. The mesh is
-- built once, over the UNION of every frame's mask, and each frame is cut
-- out again in texture space -- so a run that is six pixels wide in the
-- union may be two pixels wide in the frame on screen, and the four pixels
-- that dropped out took the union's end walls with them. What is left
-- exposed is an interior boundary, which had no wall because in the union
-- it was not a boundary at all. That is the gap that survived closing the
-- run ends: the first frame looked solid and every other frame did not.
--
-- So an animated standee gets a wall on BOTH sides of EVERY pixel. A wall
-- between two lit pixels is enclosed by the front and back faces and never
-- seen; the moment its neighbour is keyed out it becomes the edge, already
-- in place and already wearing the right colour. Each is inset a hair into
-- its own pixel so the two that meet at a boundary are not coplanar -- the
-- voxel pass draws with culling off, and two quads in the same plane would
-- z-fight rather than politely take turns.
local SIDE_INSET = 0.03
local function sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
ax0, ay0, atlasW, atlasH, py, lit, everyPixel)
local function texel(px)
return (ax0 + px + 0.5) / atlasW, (ay0 + py + 0.5) / atlasH
end
local function left(px, at)
local u, v = texel(px)
quads[#quads + 1] = { -- facing -X
{ at, yBot, zB }, { at, yBot, zF },
{ at, yTop, zF }, { at, yTop, zB },
uv = { { u, v }, { u, v }, { u, v }, { u, v } },
shade = OBJ_SHADE.side,
}
end
local function right(px, at)
local u, v = texel(px)
quads[#quads + 1] = { -- facing +X
{ at, yBot, zF }, { at, yBot, zB },
{ at, yTop, zB }, { at, yTop, zF },
uv = { { u, v }, { u, v }, { u, v }, { u, v } },
shade = OBJ_SHADE.side,
}
end
if everyPixel then
for px = ix, ix2 do
left(px, px + SIDE_INSET)
right(px, px + 1 - SIDE_INSET)
end
return
end
if not lit(ix - 1, py) then left(ix, ix) end
if not lit(ix2 + 1, py) then right(ix2, ix2 + 1) end
end
-- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one
-- whole clump of grass. Each tile stands as its own thin per-pixel slab
-- at ITS OWN depth -- the cell's north tile row in the north half of the
-- cell, the south row in the south half -- over the flat grass base the
-- tile already renders. So the player walks BETWEEN the two rows, and
-- the southern row occludes their feet the way the 2D grass overdraw
-- did. Transparency respected: only the tuft strokes stand. Runs of
-- adjacent pixels merge into single quads, and one template per grass
-- tile id is stamped across the map (grass comes in fields).
--
-- One tile is ONE standing piece, full height. The first cut split each
-- tile again into its top and bottom four art rows and stood those at
-- two different depths, which cut every blade that runs down the tile
-- clean in half -- the two halves ended up 4px tall and 4px apart in
-- depth, so a clump read as two stubs rather than one tuft.
local GRASS_THICK = 2
local function grassTemplate(map, data, tileId)
local perRow = map.tileset.tilesPerRow or 16
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local ax0 = (tileId % perRow) * 8
local ay0 = math.floor(tileId / perRow) * 8
local function opaque(px, py)
if px < 0 or px > 7 or py < 0 or py > 7 then return false end
local r, g, b, a = data:getPixel(ax0 + px, ay0 + py)
return a > 0 and math.min(r, g, b) <= 0.83
end
local quads = {}
-- the slab stands across the middle of its own tile, so the two tile
-- rows of a cell are half a cell apart in depth
local zMid = 4
local zB, zF = zMid - GRASS_THICK / 2, zMid + GRASS_THICK / 2
for iy = 0, 7 do
local yTop = 8 - iy
local yBot = yTop - 1
local ix = 0
while ix < 8 do
if opaque(ix, iy) then
local ix2 = ix
while ix2 + 1 < 8 and opaque(ix2 + 1, iy) do
ix2 = ix2 + 1
end
local u0 = (ax0 + ix + 0.05) / atlasW
local u1 = (ax0 + ix2 + 0.95) / atlasW
local v0 = (ay0 + iy + 0.05) / atlasH
local v1 = (ay0 + iy + 0.95) / atlasH
quads[#quads + 1] = { -- front
{ ix, yBot, zF }, { ix2 + 1, yBot, zF },
{ ix2 + 1, yTop, zF }, { ix, yTop, zF },
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
shade = 1,
}
quads[#quads + 1] = { -- back
{ ix2 + 1, yBot, zB }, { ix, yBot, zB },
{ ix, yTop, zB }, { ix2 + 1, yTop, zB },
uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } },
shade = 0.68,
}
-- blade tips: a top strip where the row above is clear
if not opaque(ix, iy - 1) then
quads[#quads + 1] = {
{ ix, yTop, zB }, { ix2 + 1, yTop, zB },
{ ix2 + 1, yTop, zF }, { ix, yTop, zF },
uv = { { u0, v0 }, { u1, v0 }, { u1, v0 }, { u0, v0 } },
shade = 1,
}
end
-- and underneath, where a blade ends in mid-air over the ground
if not opaque(ix, iy + 1) then
quads[#quads + 1] = {
{ ix, yBot, zF }, { ix2 + 1, yBot, zF },
{ ix2 + 1, yBot, zB }, { ix, yBot, zB },
uv = { { u0, v1 }, { u1, v1 }, { u1, v1 }, { u0, v1 } },
shade = OBJ_SHADE.bottom,
}
end
-- and the run's two end walls, which is what makes a blade a solid
-- thing rather than two billboards you can see between (sideQuads
-- above argues it, and why each wall wears its end pixel's colour)
sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
ax0, ay0, atlasW, atlasH, iy, opaque)
ix = ix2 + 1
else
ix = ix + 1
end
end
end
return quads
end
function Structures.buildGrass(S, map, x0, x1, y0, y1, data)
local templates = {}
local quads = S.grassQuads
for ty = y0, y1 do
for tx = x0, x1 do
Budget.tick()
local k = keyOf(tx, ty)
local s = S.shapeAt[k]
-- tufts only where the CELL is tall grass by the engine's own rule
-- (isGrassCell: the cell's collision tile). The grass GRAPHIC also
-- appears as decorative filler inside ordinary ground blocks, and a
-- tile-level test sprouted tufts all over town plazas.
if s and s.art == "grass"
and map:isGrassCell(math.floor(tx / 2), math.floor(ty / 2)) then
local tileId = S.tileAt[k]
local tpl = templates[tileId]
if not tpl then
tpl = grassTemplate(map, data, tileId)
templates[tileId] = tpl
end
local wx, wz = tx * 8, ty * 8
for _, q in ipairs(tpl) do
quads[#quads + 1] = {
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
uv = q.uv, shade = q.shade,
}
end
end
end
end
end
-- ---- flowers ----
-- The animated flower tile stands up as a billboard ONE VOXEL deep, cut
-- to the drawing's darkest tones PLUS everything they enclose -- the
-- round-scenery hull's rule: flood the tile border through every
-- non-dark pixel, and what the flood cannot reach is the flower, its
-- pale petal insides included. The mesh is static and the flower is
-- not, so the geometry spans the UNION of that mask over the base art
-- and every animation frame, and TerrainAtlas rewrites the tile's slot
-- each step with only the CURRENT frame's mask opaque -- the rest keyed
-- to alpha, which the voxel shader discards. The standing silhouette
-- trims itself frame by frame in texture space; the sway animates
-- without a vertex moving, off the same engine clock as the flat path.
--
-- The ground beneath is synthesized from the commonest flat neighbour,
-- like the ground under a detected prop: the tile's own slot no longer
-- holds art anyone can draw flat.
local FLOWER_THICK = 1
local function flowerFrames(tileset, tileId)
local out = {}
local ok, declared = pcall(function()
if tileset.animatedTiles then return tileset.animatedTiles end
local TileRenderer = require("src.render.TileRenderer")
return TileRenderer.defaultAnimatedTiles(tileset)
end)
if not ok then return out end
for _, spec in ipairs(type(declared) == "table" and declared or {}) do
if spec.kind == "frames" and spec.tile == tileId then
for _, path in pairs(spec.images or {}) do
local okF, frame = pcall(Assets.imageData, path)
if okF and frame then out[#out + 1] = frame end
end
end
end
return out
end
local function flowerTemplate(map, data, tileId)
local tileset = map.tileset
local perRow = tileset.tilesPerRow or 16
local atlasW = tileset.imageWidth or 128
local atlasH = tileset.imageHeight or 48
local ax0 = (tileId % perRow) * 8
local ay0 = math.floor(tileId / perRow) * 8
-- per image: dark tones, then the border flood that finds what they
-- enclose. Each image closes over ITS OWN outline before the union --
-- a pocket two frames only enclose together is not part of either.
local dark = {}
local function markMask(img, ox, oy)
local d, reach, stack = {}, {}, {}
for py = 0, 7 do
for px = 0, 7 do
local r, g, b, a = img:getPixel(ox + px, oy + py)
if a > 0 and math.min(r, g, b) <= 0.5 then
d[py * 8 + px] = true
end
end
end
for i = 0, 7 do
for _, s in ipairs({ i, 56 + i, i * 8, i * 8 + 7 }) do
if not d[s] and not reach[s] then
reach[s] = true
stack[#stack + 1] = s
end
end
end
while #stack > 0 do
local p = table.remove(stack)
local px, py = p % 8, math.floor(p / 8)
for _, dir in ipairs(DIRS4) do
local nx, ny = px + dir[1], py + dir[2]
if nx >= 0 and nx < 8 and ny >= 0 and ny < 8 then
local ni = ny * 8 + nx
if not d[ni] and not reach[ni] then
reach[ni] = true
stack[#stack + 1] = ni
end
end
end
end
for i = 0, 63 do
if d[i] or not reach[i] then dark[i] = true end
end
end
markMask(data, ax0, ay0)
for _, frame in ipairs(flowerFrames(tileset, tileId)) do
pcall(markMask, frame, 0, 0)
end
local function on(px, py)
if px < 0 or px > 7 or py < 0 or py > 7 then return false end
return dark[py * 8 + px] == true
end
local quads = {}
local zB = 4 - FLOWER_THICK / 2 -- one slab at the tile's middle
local zF = zB + FLOWER_THICK
for py = 0, 7 do
Budget.tick()
local yTop, yBot = 8 - py, 7 - py
local ix = 0
while ix < 8 do
if on(ix, py) then
local ix2 = ix
while ix2 + 1 < 8 and on(ix2 + 1, py) do ix2 = ix2 + 1 end
local u0 = (ax0 + ix + 0.05) / atlasW
local u1 = (ax0 + ix2 + 0.95) / atlasW
local v0 = (ay0 + py + 0.05) / atlasH
local v1 = (ay0 + py + 0.95) / atlasH
quads[#quads + 1] = { -- front
{ ix, yBot, zF }, { ix2 + 1, yBot, zF },
{ ix2 + 1, yTop, zF }, { ix, yTop, zF },
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
shade = OBJ_SHADE.front,
}
quads[#quads + 1] = { -- back
{ ix2 + 1, yBot, zB }, { ix, yBot, zB },
{ ix, yTop, zB }, { ix2 + 1, yTop, zB },
uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } },
shade = OBJ_SHADE.back,
}
-- ------- the shell, closed on all four remaining faces
--
-- A flower SWAYS: the geometry spans the union of every animation
-- frame's mask and each frame is cut back out of it in texture
-- space (see the header). So "is there a pixel next door" has two
-- different answers -- one in the union this mesh was built from,
-- and one in the frame actually on screen -- and only the second
-- decides what is exposed.
--
-- Closing the union's own edges is therefore not enough, and was
-- the bug the first cut of this shipped: the base frame looked
-- solid and every other frame still had gaps, because a pixel that
-- drops out of a frame takes the union's wall with it and leaves an
-- interior boundary that never had one.
--
-- So every pixel gets a cap on all four of its remaining faces,
-- whatever its neighbours do. A cap between two lit pixels sits
-- inside the slab, enclosed by the front and back faces, and is
-- never seen; the moment its neighbour is keyed out it IS the edge,
-- already there and already wearing the right colour. Each samples
-- its own pixel's texel, so it appears and vanishes with the pixel
-- it belongs to rather than with the one it is closing off.
--
-- Inset a hair into its own pixel, because the voxel pass draws
-- with culling off: the two caps that meet at a boundary would be
-- coplanar and z-fight rather than politely take turns.
for px = ix, ix2 do
local tu = (ax0 + px + 0.5) / atlasW
local tv = (ay0 + py + 0.5) / atlasH
local xa, xb = px, px + 1
local yT = yTop - SIDE_INSET
local yB = yBot + SIDE_INSET
quads[#quads + 1] = { -- the pixel's own lid
{ xa, yT, zB }, { xb, yT, zB }, { xb, yT, zF }, { xa, yT, zF },
uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } },
shade = OBJ_SHADE.top,
}
quads[#quads + 1] = { -- and its floor
{ xa, yB, zF }, { xb, yB, zF }, { xb, yB, zB }, { xa, yB, zB },
uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } },
shade = OBJ_SHADE.bottom,
}
end
sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
ax0, ay0, atlasW, atlasH, py, on, true)
ix = ix2 + 1
else
ix = ix + 1
end
end
end
return quads
end
function Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
local templates = {}
-- flowerQuads, not objectQuads: flowers sit on WALKABLE cells, so
-- their mesh draws after the characters with the character pull
-- (ChunkMesher's flower mesh) -- terrain-baked they lose the depth
-- fight against the pulled card whenever the player stands among them
local quads = S.flowerQuads
for ty = y0, y1 do
for tx = x0, x1 do
Budget.tick()
local k = keyOf(tx, ty)
local s = S.shapeAt[k]
if s and s.art == "flower" then
-- the tile's atlas slot carries only the standing cutout now, so
-- EVERY flower position -- ring included -- paints synthesized
-- ground instead of its own art: the commonest flat neighbour
-- that is not itself a flower, else the map's commonest ground
-- (forMap's end-of-build vote resolves the `false`)
S.skip[k] = true
local votes, best, bestN = {}, nil, 0
for _, d in ipairs(DIRS4) do
local nk = keyOf(tx + d[1], ty + d[2])
local ns = S.shapeAt[nk]
if ns and ns.flat and ns.class ~= "void"
and ns.class ~= "flower" then
local t = S.tileAt[nk]
votes[t] = (votes[t] or 0) + 1
if votes[t] > bestN then best, bestN = t, votes[t] end
end
end
S.ground[k] = best or false
-- standee BODY only, like grass: standing scenery past a map's
-- edge would poke into the map next door
if tx >= 0 and ty >= 0 and tx < tw and ty < th then
local tileId = S.tileAt[k]
local tpl = templates[tileId]
if not tpl then
tpl = flowerTemplate(map, data, tileId)
templates[tileId] = tpl
end
local wx, wz = tx * 8, ty * 8
for _, q in ipairs(tpl) do
quads[#quads + 1] = {
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
uv = q.uv, shade = q.shade,
}
end
end
end
end
end
end
-- Drop one map's analysis (Cut changed the block layer) or everything.
-- Hull templates key on art content (tileset + tiles), which a block edit
-- cannot change, so only the full drop clears them (atlas reload).
function Structures.invalidate(mapId)
if mapId then
cache[mapId] = nil
else
cache = {}
atlasData = {}
roundCache = {}
Buildings.invalidate()
end
end
Assets.register(function() Structures.invalidate() end)
return Structures