bills, pewter gym, register, celadon mansion, tables, garbage cans
DramaticShape · 2026-08-01 ·
parent 47363b8d23
bills, pewter gym, register, celadon mansion, tables, garbage cans
Changed files
data/voxel_heights.lua | 979 +++++++++++++++++++++++++++++++++++++++++++----
lib/Buildings.lua | 544 +++++++++++++++++++++++----
lib/Structures.lua | 1255 +++++++++++++++++++++++++++++++++++++++++++++++++++++--------
lib/TileShape.lua | 213 +++++++++--
lib/Water.lua | 70 +++-
tests/dramatic_shape_test.lua | 35 +-
tests/voxel_figure_test.lua | 166 ++++++++
tools/building_voxels.py | 1005 ++++++++++++++++++++++++++++++++++++++++++++-----
8 files changed, 3811 insertions(+), 456 deletions(-)
Diff
Diff truncated at 200.0 KB — fetch the full change with git show 91cc2d6f51 after cloning.
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index 97f52f6..2c5ae61 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -58,12 +58,43 @@
-- collapses onto a one-cell-deep box at its
-- full drawn height; back rows become hidden
-- floor, and an unpinnable trim row above
--- becomes the cap
+-- becomes the cap. Its FRONT carries the
+-- measured pane relief -- every non-black
+-- region the drawing seals behind its own
+-- black frame sinks one voxel, the same rule
+-- lib/Buildings.lua recesses a facade with,
+-- so the books stand in the shelf instead of
+-- being painted on it. A tileset that
+-- borrows the collapse for something that is
+-- not a shelf says `bookcase_relief = false`
+-- cylinder / canopy round scenery (tree canopies): a voxel hull
+-- / stump cut from the art's own darkest-pixel outline,
+-- round in depth -- one 16px cell, a 2x2-cell
+-- group, or with the drawn top read as a cut face
+-- can that hull cut at both ends, hollowed and
+-- tapered: an OPEN bin standing on a floor
+-- (Vermilion Gym's trash cans). The drawn mouth
+-- ellipse projects across the round top and down
+-- the well, the drawn base ellipse is ground
+-- contact rather than body, the top rows keep a
+-- wall at each end of their chord and lose the
+-- middle, and the plan narrows toward the floor
+-- (can_cap / can_base in art rows; can_height,
+-- can_well, can_taper in voxels)
+-- planter the same hull for a round drawing stacked TWO
+-- cells high on ONE cell of plot (the Centers'
+-- potted plants): it stands in the lower cell and
+-- the upper cell is the height it overhangs
-- billboard / prop a prop drawn face-on: a standing per-pixel
-- cutout, black-outline segmented; drawn above
-- a pinned box it stands ON that box (a
-- monitor on its desk). prop is a second pool
-- so two touching cutouts stay separate
+-- bike the same cutout at TWO voxels: a vehicle drawn
+-- side-on is a LINE drawing, and the air inside
+-- its frame is what makes it read as one -- any
+-- thicker and neighbouring strokes close those
+-- gaps with their own side faces
--
-- A tileset entry may also carry `prop_ground` (not a class): a map of
-- prop tile id -> ground tile id naming the tile painted under that
@@ -83,7 +114,20 @@
-- and nothing automatic can either when the figure has no background
-- margin to flood from and wears the same shades as what it sits on.
-- The Pokemon Center's seated man is the case; see POKECENTER below for
--- the format.
+-- the format. A figure entry that states a `depth` is not a person and
+-- stands as a per-pixel SOLID instead of a flat card -- the Marts' cash
+-- register, a machine set down on a counter (`thin` gives its top rows a
+-- thickness of their own, for the part of a drawing that is paper).
+--
+-- And `mounted` (not a class either): the same authored-mask escape for a
+-- thing painted INTO a WALL band, stood proud of the panel as a thin
+-- per-pixel slab instead of standing on its feet as a sprite card. Where
+-- a figure is a person and reads face-on from any angle, a mounted object
+-- is an object drawn side-on and holds the wall's own plane; and it keeps
+-- its DRAWN elevation, so a thing hung clear of the floor stays hung.
+-- The Bike Shop's two wall bicycles are the case -- and the mask there is
+-- measured rather than drawn, by flooding the panel tile's own stripe out
+-- from behind them; see CLUB below.
--
-- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from
-- above, a facade seen face-on and sloped ends as diagonal silhouettes,
@@ -117,6 +161,13 @@ return {
desk = 24,
prop = 16,
cutout = 16,
+ -- a vehicle drawn side-on (the Bike Shop's bicycles): a standee like
+ -- the pools above, two voxels thin so the air inside its frame stays
+ -- air (see Structures' PINNED_DEPTH)
+ bike = 16,
+ -- a round drawing stacked two cells high on one cell of plot (the
+ -- Centers' potted plants): 32px of hull standing in its lower cell
+ planter = 32,
relief = 3,
bookcase = 32,
stair_e = 16,
@@ -195,12 +246,8 @@ return {
-- black-outline segmented -- backgrounds flood away, the pixels
-- the outline encloses stay. Everything stands on the gyms' main
-- floor tile ($11), which is also Bruno's floor.
- -- (The round boulder drawn beside some statues, $07/$08/$17/$18,
- -- is NOT pinned: it also tiles wall-to-wall as Pewter's rock rows,
- -- which are scenery for the detector, not a lone prop. Probed:
- -- the repeat-aware scenery path already gives every one of them a
- -- single 16px course in Pewter's and Bruno's rock walls alike, so
- -- a pin would only restate the derived answer.)
+ -- (The boulders, $07/$08/$17/$18, are pinned round -- see the
+ -- `cylinder` pool below.)
--
-- The rest of the tileset is the ten rooms' own furniture: the six
-- badge gyms it serves (Pewter, Cerulean, Vermilion, Celadon,
@@ -270,13 +317,100 @@ return {
-- from the darkest-pixel outline -- the same treatment the
-- overworld's tree canopies take, and per-CELL, so a hedge row
-- becomes a row of bushes instead of one boxed monolith.
- cylinder = { 44, 45, 46, 47 },
- -- The statues, the cans, and Celadon's three little trees
- -- ($40/$41 canopy over $50/$51 trunk). A trunk is not round, so
- -- the tree cannot be a ball like the shrubs beside it -- it takes
- -- the thin standee pool every interior plant takes, which is also
- -- a pool apart from the cylinders it touches.
- prop = { 2, 56, 18, 19, 11, 12, 27, 28,
+ --
+ -- The rock gyms' boulder ($07/$08 over $17/$18) is the same
+ -- reading: one cell is one rock, drawn as a lit dome -- white
+ -- highlight up the north-west, mid-grey body, black-and-grey
+ -- rubble dither filling the rest -- with the FLOOR showing
+ -- through all four corners, which is what makes a run of them
+ -- read as a pile of separate stones rather than a wall (tile
+ -- them and the gaps between four rocks draw a grey diamond).
+ -- Left derived it fell to the repeat-aware scenery path, which
+ -- extruded the whole drawing as one 16px course: Pewter's and
+ -- Bruno's rock rows came out as square bars wearing a boulder
+ -- texture in relief -- the extruded picture. Round-pinned,
+ -- each cell is a hull whose plan view is its own drawn width
+ -- profile turned in depth: a dome 16 wide and 16 tall, full
+ -- width from the drawn shoulder down and tapering over the top
+ -- five rows exactly where the art tapers, and the corner
+ -- diamonds open up between them the way the drawing has them.
+ -- It stays 16px, so nothing that stands on or beside a rock
+ -- moves. The perimeter rows take the same model as the maze
+ -- clusters -- one drawing, one rock, everywhere.
+ -- Scanned: across all 222 maps these four tiles occur 87 times
+ -- on this atlas and every one of them anchors a whole boulder
+ -- cell (87 tile-$07 hits, 87 grid matches), in exactly two maps
+ -- -- PEWTER_GYM's walls and rock maze, and BRUNOS_ROOM's
+ -- clusters. DOJO shares gym.png but places none of them, so
+ -- the pin is not copied there.
+ cylinder = { 44, 45, 46, 47,
+ 7, 8, 23, 24 },
+ -- Vermilion Gym's trash cans ($0B/$0C over $1B/$1C), the switch
+ -- puzzle's fifteen cans plus the sixteenth beside the leader's
+ -- platform. An open galvanised bin in the 3/4 view, and its plan is
+ -- measured: the silhouette runs straight down both flanks for art
+ -- rows 4-10 and is 11px wide there, so the can is 11 across -- and
+ -- being round in plan, 11 DEEP. Above row 4 is the MOUTH seen from
+ -- above (the drawn ring is 9x5, a circle flattened to 55%, which is
+ -- what fixes it as a top view rather than a face-on dome); below row
+ -- 10 is the base circle's front arc, ground contact, with the
+ -- drawing's own $555 halo one pixel outside it as the grounding
+ -- shadow.
+ --
+ -- Left in the thin standee pool the can was a flat disc on edge --
+ -- fifteen coins standing in a row. Pinned a plain `cylinder` it
+ -- revolves every drawn row, bottom arc included, and comes out a
+ -- barrel balanced on a three-voxel stem. `can` is the hull cut at
+ -- both ends and hollowed: the mouth projects across the round top,
+ -- the base rows are ground rather than body, and the top can_well
+ -- voxel rows keep a wall at each end of every chord and lose their
+ -- middle, so the bin is open and you look down into it.
+ --
+ -- can_cap and can_base are the two ellipses in art rows and come off
+ -- the pixels. The other three do NOT, and are the numbers taste
+ -- moves:
+ --
+ -- can_height 9 Un-projected strictly the drawing states a squat
+ -- drum barely two rows of straight side tall,
+ -- because the GB artist spent most of a 16px cell
+ -- on the opening -- but a bin is taller than it is
+ -- wide and the flat game reads as one, the drawing
+ -- being 14px tall beside a 16px player. The lowest
+ -- surviving body row (art row 10: black rim, shaded
+ -- flank, lit face) is repeated up to this, the
+ -- plainest continuation of the material drawn.
+ -- `heights` must follow it so anything riding a can
+ -- lands on the rim.
+ -- can_well 5 How far down the mouth is hollowed. The drawing
+ -- paints an opening and cannot say how deep.
+ -- can_taper 4 Voxels of DIAMETER the base loses -- 11 at the
+ -- rim to 7 on the floor, stepped twice over the
+ -- height. The drawing's base arc pulls in to 9px
+ -- on its own, so the direction is drawn; the amount
+ -- is not. 2 is one barely-visible step, 4 reads as
+ -- a cone.
+ --
+ -- Scanned: the four ids occur as this grid 16 times on the GYM atlas
+ -- and only in VERMILION_GYM -- cells (1/3/5/7/9, 7), the same five
+ -- on rows 9 and 11, and (6,1). DOJO shares gym.png and places none,
+ -- so the pin is not copied there. The same four ids form the same
+ -- grid on eight OTHER atlases (the Bike Shop's crates, the overworld
+ -- roofs, the Centers' counters, ...) for 142 more hits; those are id
+ -- collisions between tilesets, and a pin is per tileset id, so they
+ -- are none of this entry's business.
+ can = { 11, 12, 27, 28 },
+ can_cap = 9,
+ can_base = 4,
+ can_height = 9,
+ can_well = 5,
+ can_taper = 4,
+ heights = { can = 9 },
+ -- The statues and Celadon's three little trees ($40/$41 canopy over
+ -- $50/$51 trunk). A trunk is not round, so the tree cannot be a
+ -- ball like the shrubs beside it -- it takes the thin standee pool
+ -- every interior plant takes, which is also a pool apart from the
+ -- cylinders it touches.
+ prop = { 2, 56, 18, 19,
64, 65, 80, 81 },
-- The Hall of Fame's recording machine, the one piece of real
-- furniture in the tileset. It is drawn 32px wide and THREE tile
@@ -629,6 +763,11 @@ return {
-- the floor above). Bookcases, dining table with its flower pot,
-- stools, the TV on the floor, and the staircase up.
REDS_HOUSE_1 = {
+ -- the `house_stool` template below stands 5 voxels and the
+ -- `reds_house_table` model 6 (both the drawn elevation): whoever
+ -- sits on a stool cell and whatever object sprite stands on the
+ -- table rides these heights, not the 8/12px class defaults
+ heights = { stool = 5, table = 6 },
wall = { 0, 36, 37, 52, 53 },
stool = { 2, 3, 18, 19 },
-- the dining table (38-44/58-60); its top row also caps the
@@ -656,6 +795,12 @@ return {
-- wall-height volumes and towers the table, so every object is
-- pinned to the shape it depicts.
HOUSE = {
+ -- the `house_stool` template below stands 5 voxels and the
+ -- `house_table` model 6 (both the drawn elevation): whoever sits
+ -- on a stool cell -- Daisy at her table -- and whatever object
+ -- sprite stands on a table cell rides these heights, not the
+ -- 8/12px class defaults
+ heights = { stool = 5, table = 6 },
-- the wall band stays one 16px face: blank courses, the window,
-- the framed picture, and the schoolhouse blackboard (72-75/88-91)
wall = { 0, 36, 45, 46, 52, 61, 62, 72, 73, 75, 88, 89, 90, 91 },
@@ -694,14 +839,17 @@ return {
-- look-only), the pokeball poster (2/3/18/19), and the pillars
-- (16/41) with their bases (4/5/20/21; 20 is the $14 water-fallback
-- trap and would recess into a pond lip). The healing machines'
- -- bodies (72/76/77 console face, 6/22 button panel, 73 light edge,
- -- 7/13 shadowed edge) are ALSO wall: drawn 16px tall against the
- -- band, so wall height is their drawn height and they read as
- -- consoles jutting from it; the near-black screen tiles must be
- -- pinned or the void rule flattens them, and 72 is the $48
- -- water-fallback trap
- wall = { 2, 3, 4, 5, 6, 7, 13, 16, 18, 19, 20, 21, 22, 40, 41,
- 72, 73, 76, 77, 92, 93, 94, 95 },
+ -- console face (76/77) and button panel (6/22) are ALSO wall:
+ -- drawn 16px tall against the band, so wall height is their drawn
+ -- height and they read as equipment jutting from it. (The
+ -- `center_heal_machine` template below claims those four tiles at
+ -- every placement and models the console properly; these pins are
+ -- the degradation path when the profile is absent.) The
+ -- near-black screen tiles must be pinned or the void rule flattens
+ -- them. What is NOT wall is the machines' two flanks -- see
+ -- `prop` below.
+ wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41,
+ 76, 77, 92, 93, 94, 95 },
-- the counters, half a cell high: top band (8) with the nurse's
-- tray (10), front face (24/25, the game's counterTiles), left end
-- cap (56) and the Cable Club's light sections (90/91). 8px is
@@ -732,6 +880,29 @@ return {
-- bodies so they stand ON them) and the PC (66/70/82/86), which
-- stands on its pinned desk
billboard = { 58, 59, 66, 70, 74, 75, 82, 86 },
+ -- The healing machines' two FLANKS, which are not the machine and
+ -- not the wall: thin equipment standing on the floor beside the
+ -- console, drawn against a plain light-grey ground with no floor
+ -- checker in it. `wall` boxed each of them into a solid 16px
+ -- half-cell wearing its drawing in relief -- the billboard failure,
+ -- one tile wide.
+ -- 72 the west flank of both machines, and 73 its mirror on the
+ -- east machine: a pair of PIPES leaving the console and
+ -- bending down into the floor in an L, the 8px motif drawn
+ -- once per pipe. Round and thin, so the THIN pool's 5px
+ -- slab, per-pixel, is the whole of them; a box is a wall
+ -- stub with pipework printed on it.
+ -- 7/13 the west machine's east flank: a KEYBOARD, one 16px panel
+ -- over two tiles (keys, and the wide bar down its right).
+ -- Same thin standee; the drawn panel IS its thickness.
+ -- All four ids are used at these flanks and nowhere else in the
+ -- game -- 12 maps, the same two cells in each -- so this pin cannot
+ -- reach anything but the machines. 72 is still the $48
+ -- water-fallback trap and still has to carry SOME pin; it just
+ -- wanted the thin one, not the wall.
+ prop = { 7, 13, 72, 73,
+ -- ...and the potted plants -- see THE POTTED PLANTS below
+ 32, 33, 34, 35, 48, 49, 50, 51 },
-- The man on the couch, cut out by hand and stood up (see `figures`
-- below). Nothing automatic reaches him. A class pin resolves a
-- whole 8x8 tile and he SHARES his tiles with the couch, so pinning
@@ -745,34 +916,35 @@ return {
-- sofa he is painted into, so that is what `figures` carries.
-- the PC's desk body, which the PC stands on
table = { 9, 88 },
- -- the potted plants: bush (32/33/48/49) over pot (34/35/50/51,
- -- 50 is the $32 water-fallback trap). Standing per-pixel
- -- billboards in the THIN pool, like every other interior plant --
- -- and a pool apart from the sofa, which touches the corner pair
- -- and must stay a separate object. The bush's light checker
- -- highlights share the floor's shades, so the mask drains some of
- -- them; the standee reads darker than the flat art, which is the
- -- accepted trade for a real plant silhouette
- prop = { 32, 33, 34, 35, 48, 49, 50, 51 },
- -- ...but the POTS were draining outright. The plant pair is drawn
- -- as one 4x4-tile block and the pot's olive base is flush on that
- -- block's bottom row, so the background vote -- which reads the
- -- shades touching the drawing's own bounding box -- came back with
- -- "dark" in it, and every dark pixel in the plant left with the
- -- floor. The pots rendered as hollow black frames around one or
- -- two surviving pixels while the flat art has solid olive bodies.
- --
- -- So name the background outright for these eight tiles: the floor
- -- IS light and white here, and nothing else is. That restores 152
- -- pixels of the block (55% -> 69% of it kept) and cannot move any
- -- other prop, which matters -- the same call tileset-wide would pull
- -- the dark wall band into the healing consoles' screens and strip
- -- three pixels off the PC, because those two want the opposite
- -- answer on the very same shades.
- prop_bg = {
- { tiles = { 32, 33, 34, 35, 48, 49, 50, 51 },
- shades = { "light", "white" } },
- },
+ -- THE POTTED PLANTS: a leaf crown (32/33/48/49) over an urn
+ -- (34/35/50/51; 50 is the $32 water-fallback trap), the most
+ -- repeated interior prop in the game -- 78 placements over 13 maps
+ -- across this id and MART (scan: six per Center, two in the
+ -- Celadon hotel's lounge rows, four in the Plateau lobby).
+ --
+ -- A plant is the THIN pool's textbook case: the drawing is ONE
+ -- organic silhouette -- crown, 3px stem, urn, all 8-connected,
+ -- 350 of the block's 512 pixels -- so it stands as a per-pixel
+ -- cutout 5 voxels deep at its real drawn height, 32px over the
+ -- two stacked cells. Its lowest drawn row is the block's bottom
+ -- row, so it stands in the pot cell's south band -- drawn low,
+ -- near the front of its blocked plot -- and the crown overhangs
+ -- the stem the way the drawing says, which no box or hull cut to
+ -- cell granularity can do. Both cells' tiles carry the pin (in
+ -- `prop` above); the pool clusters them into one drawing, and the
+ -- two side-by-side placements (every map pairs or quads them)
+ -- share one cluster whose per-pixel geometry is identical to two
+ -- separate ones.
+ -- The plant drawing has no floor margin to vote from: the urn's
+ -- #555 foot lies flush on the block's bottom edge, so the rim
+ -- vote reads "dark" as background and drains the urn's own body.
+ -- Name the background outright instead: the floor showing
+ -- through the crown's gaps is white+light and nothing else,
+ -- while the crown's own white/light highlights are sealed inside
+ -- its black/#555 outline -- a light+white flood from the ring
+ -- reproduces the extract mask exactly, leaves intact.
+ prop_bg = { { tiles = { 32, 33, 34, 35, 48, 49, 50, 51 },
+ shades = { "light", "white" } } },
-- THE MAN ON THE COUCH. He is drawn INTO the lounge furniture and
-- spills out of it in BOTH directions, which is why he needs three
-- tile columns:
@@ -908,6 +1080,21 @@ return {
-- part of that same work surface now that the machine has been cut
-- off them as a sprite -- see `figures` below.
counter = { 8, 14, 15, 16, 24, 25, 30, 31, 41, 56, 89 },
+ -- the same potted plants as POKECENTER above -- MART shares that
+ -- atlas, so this is the identical drawing tile for tile, and
+ -- INDIGO_PLATEAU_LOBBY (the only map on this id that places it)
+ -- puts four of them along the hall. Across POKECENTER and MART
+ -- all eight of these ids appear exactly 78 times each, which is
+ -- exactly how often the 2x4 plant grid appears: they are never
+ -- anything but this plant, so pinning them by tile cannot catch
+ -- anything else. Same reading as the POKECENTER entry: one
+ -- organic silhouette in the THIN standee pool, per-pixel, 5
+ -- voxels deep at its drawn 32px height, with the background
+ -- shades named because the urn's foot lies flush on the block's
+ -- bottom edge and votes the drawing's own darks away.
+ prop = { 32, 33, 34, 35, 48, 49, 50, 51 },
+ prop_bg = { { tiles = { 32, 33, 34, 35, 48, 49, 50, 51 },
+ shades = { "light", "white" } } },
-- Left to the derived default on purpose: the floor checker and
-- its shadowed variants (1/11/17/26/27/54) and the exit mat
-- (12/28) all sit in cells the ROM marks walkable, so the cell
@@ -915,13 +1102,14 @@ return {
-- interior, used to be left to the volume path here; it is now the
-- back wall's third band, because a run of four rows has to be
-- contiguous for the wall to collapse into one box at all.)
- -- THE CASH REGISTER: a keypad and a curl of receipt paper drawn
- -- face-on across two tile rows in the middle of the counter's east
- -- arm. Like the Center's seated man it is a FIGURE drawn into the
- -- furniture, so it gets the same treatment -- a flat sprite card
- -- standing on the counter -- and for the same reason: no class pin
- -- can separate it from the counter, because the counter draws its
- -- own edging down columns 0 and 15 of the very same tiles.
+ -- THE CASH REGISTER: a till drawn across two tile rows in the middle
+ -- of the counter's east arm, and the same drawing in all nine maps
+ -- on this id (eight Marts plus the Indigo Plateau lobby), always at
+ -- cell (1,5). Like the Center's seated man it is drawn INTO the
+ -- furniture, so it is cut out by the same authored mask, and for the
+ -- same reason: no class pin can separate it from the counter,
+ -- because the counter draws its own edging down columns 0 and 15 of
+ -- the very same tiles.
--
-- That edging is what defeated every automatic reading. Black
-- always survives the shade flood, so the standee pools extruded
@@ -933,13 +1121,70 @@ return {
-- to the top right, and the counter keeps its edging and its light
-- work surface.
--
+ -- But it is a MACHINE, not a person, so unlike the seated man it
+ -- carries a `depth` and stands as a solid rather than a sprite
+ -- card. A flat card of a till is exactly the billboard the standee
+ -- pools exist to avoid -- it read as a decal on the counter, and
+ -- turned edge-on with the camera.
+ --
+ -- AND IT IS NOT A BOX. The 16x16 packs two facings, and the black
+ -- linework says which is which: the keypad has its OWN complete
+ -- border (cols 2-8, rows 4-11) sealed inside the outer silhouette,
+ -- and what surrounds it is an L --
+ --
+ -- cols 2-----8 9--12
+ -- +-----------+-----+ row 4
+ -- | keypad | arm | the L's upright arm: the printer and
+ -- +-----------+-----+ row 11 display the paper feeds out of
+ -- | base / drawer | rows 12-15: the L's foot, drawn face-on
+ -- +------------------+
+ --
+ -- So the keypad is NOT a face. It is the machine's DECK seen from
+ -- ABOVE -- the keys lie on it -- and `flat` lays it horizontal in
+ -- the notch of the L, at the height the base band leaves it and
+ -- with drawn row = depth row 1:1. Extruding it instead stood that
+ -- surface on end and painted the keys up the front of a plain box:
+ -- the extruded-picture failure, and what the first pass shipped.
+ --
+ -- Everything else falls out of the drawing: the base band is 4 rows,
+ -- so the deck stands 4 above the counter; the arm is 8, so it rises
+ -- 8 above the deck; the paper reaches 4 more.
+ --
+ -- TWO AUTHORED NUMBERS.
+ --
+ -- `depth` 12, three quarters of the cell. The keypad's own eight
+ -- drawn rows measure 8, and at 8 the machine read thin on the
+ -- counter, so the body runs half again deeper and the deck STRETCHES
+ -- over it -- 8 rows into 12 voxels, centre-sampled, so every second
+ -- row doubles and the keypad still covers the whole surface. This
+ -- is the one place in the model where a texel is not 1:1 with a
+ -- drawn pixel; laying the panel 1:1 instead leaves a strip of the
+ -- base band's top showing behind the keys. The model is anchored at
+ -- the counter cell's FRONT and grows north into the bare top behind
+ -- it, so deepening it can never push the till toward the aisle: at
+ -- 12 it still stops 4 voxels short of the cell's north edge. This
+ -- is the number to move if it wants more or less presence.
+ --
+ -- `thin`: 4 rows at 2. Row 4 is the machine's
+ -- own drawn top edge -- a black rule from column 3 to column 9 -- so
+ -- everything above it is the receipt strip, and paper is 2 voxels
+ -- like every other `cutout` here. Centred in the body's band, so
+ -- the strip leaves the arm's top face by a slot in the middle of it
+ -- rather than flush with its front. At the body's 8 the curl came
+ -- out as a chunky wedge on the corner and stopped reading as paper.
+ --
-- `under` is 16/41 twice -- the plain work surface, which already
-- carries the west edging (black over white) and the east (dark
-- over black), so the counter closes up behind the machine with no
- -- synthesis at all.
+ -- synthesis at all. The `counter` pin on 14/15/30/31 stays: the
+ -- box under the till is still the counter, and the machine stands
+ -- on its 8px top plane.
figures = {
{
w = 2,
+ depth = 12,
+ thin = { rows = 4, depth = 2 },
+ flat = { x = { 2, 8 }, rows = { 4, 11 } },
tiles = { 14, 15,
30, 31 },
under = { 16, 41,
@@ -1648,6 +1893,11 @@ return {
-- footing.
bookcase = { 7, 8, 9, 10, 23, 24, 25, 26, 39, 40, 41, 42,
55, 56, 57, 58 },
+ -- ...and the drums are the reason this tileset turns the shelf
+ -- relief OFF: the collapse here is borrowed for a MACHINE, whose
+ -- light regions are the barrel's own lit face and not panes
+ -- behind a frame. Sinking them would dent the drum.
+ bookcase_relief = false,
-- The big OCTAGONAL boardroom table -- the Fan Club's and Silph
-- 11F's, the same drawing in both -- half a cell high, and half a
-- cell on purpose. A `counter` is ONE band: exactly the drawing's
@@ -1712,6 +1962,13 @@ return {
-- drawn into the WALKABLE cell in front; 43/44 belong to the stool
-- there, not to the desk.
table = { 11, 12, 13, 14, 27, 28, 29, 30 },
+ -- and those eight ids are the ONLY `table` in this tileset, so the
+ -- height override is this desk's alone: 8px, the height its apron
+ -- is drawn and the plane the `bills_desk` template stands its top
+ -- on (see `buildings` below). The pin is only the degradation
+ -- path here -- the template claims both placements -- but the two
+ -- must not disagree about where the desk's top is.
+ heights = { table = 8 },
-- the seats: Bill's desk stool (43/44 over 59/60) and the Fan
-- Club's four members' chairs (61/62 over 59/60), a whole cell
-- each. All of them sit in WALKABLE cells, so they were flat
@@ -1765,11 +2022,13 @@ return {
-- rounded foot, 53, stands one row lower on the floor and is
-- walkable, so it stays flat ground in front of the band).
-- Drawn INTO the band and therefore wall as well: the two
- -- bicycles hung on the Bike Shop's wall (1/2/3 over 17/18/19,
+ -- bicycles against the Bike Shop's wall (1/2/3 over 17/18/19,
-- exactly two tile rows = exactly the band's height), the Cable
-- Club's link poster (48/49/50/51 -- 50 is $32, the shore trap
-- that had it recessing to -2), and the Trade Center's pair of
-- lit notice boards (63/68/68/69 over 66/67/67/70).
+ -- The bicycles keep their `wall` pin as the degradation path, but
+ -- `mounted` below lifts them off the panel -- see there.
wall = { 1, 2, 3, 6, 17, 18, 19, 48, 49, 50, 51, 52, 63, 66, 67,
68, 69, 70 },
-- Counters, half a cell high -- the house rule for anything you
@@ -1797,28 +2056,114 @@ return {
-- character standing on one sits on it.
stool = { 38, 39, 42, 43 },
-- The showroom bicycles -- six of them standing on the Bike Shop
- -- floor, in two columns, each drawn 3 tiles by 2 side-on. The
- -- THIN standee pool, not `bookcase`: a bike is nearly all air
- -- (two wheel rings and a frame), and collapsing a rack of them
- -- onto a one-cell-deep box at full height would trade six
- -- bicycles for one blank 32px panel wearing a bicycle print. As
- -- cutouts they segment cleanly -- the drawing has floor margin on
- -- three sides and its own black outline seals the wheels -- and
- -- the standee builder's connected-component split gives each bike
- -- its own feet in its own cell even where a lower bike's
- -- handlebar stem touches the wheel of the one drawn above it.
+ -- floor, in two columns, each drawn 3 tiles by 2 side-on. A THIN
+ -- standee pool, not `bookcase`: a bike is nearly all air (two
+ -- wheel rings and a frame), and collapsing a rack of them onto a
+ -- one-cell-deep box at full height would trade six bicycles for
+ -- one blank 32px panel wearing a bicycle print. As cutouts they
+ -- segment cleanly -- the drawing has floor margin on three sides
+ -- and its own black outline seals the wheels -- and the standee
+ -- builder's connected-component split gives each bike its own
+ -- feet in its own cell even where a lower bike's handlebar stem
+ -- touches the wheel of the one drawn above it.
-- 11/12/14 the saddle and bar row, 27/28/9 the frame and wheels.
- prop = { 9, 11, 12, 14, 27, 28 },
- -- The crate and the standing pump beside the shop's south wall
+ --
+ -- Their own pool at TWO voxels rather than `prop`'s five, which is
+ -- the whole reason `bike` exists. The segmentation was never the
+ -- problem -- the mask the detector cuts is a clean bicycle, wheels,
+ -- forks, handlebars and all. Depth was: every stroke of a line
+ -- drawing extruded five voxels closes the gap to its neighbour with
+ -- its own side faces, so off-axis (which is every camera rung but
+ -- flat) the air inside the frame filled in and the showroom's six
+ -- bicycles came out as one dark lump against the wall. At two the
+ -- negative space survives and they read as bicycles again.
+ bike = { 9, 11, 12, 14, 27, 28 },
+ -- The toolbox and the standing pump beside the shop's south wall
-- (29/13 over 21/22), twice. A separate pool from the bicycles
- -- on purpose, and the thicker one: a crate is a deliberate object
- -- with a body, where a bike is a silhouette.
+ -- on purpose, and the thicker one: a toolbox is a deliberate
+ -- object with a body, where a bike is a silhouette.
+ --
+ -- Kept as the DEGRADATION PATH only. The `bike_shop_toolbox`
+ -- building template (see `buildings.CLUB`) claims these cells and
+ -- models the drawing properly -- a per-pixel standee is the wrong
+ -- primitive for a box, and 10 voxels of it turned every black
+ -- outline pixel into a 10-deep bar. Buildings runs before the
+ -- standee scan, so where the template stamps, this pin never
+ -- fires; without a profile it is still better than the volume
+ -- path.
billboard = { 13, 21, 22, 29 },
-- The Bike Shop floor's second checker phase. It is NOT in the
-- tileset's walkable list, so the cells it floors resolve by rule
-- 4 and it rose as a wall stub -- or, worse, got swept into the
-- bicycle beside it and dragged to 8px. It is floor; it is flat.
ground = { 30 },
+ -- THE TWO BICYCLES AGAINST THE SHOWROOM'S NORTH WALL. Same object
+ -- as the six on the floor -- 24x16 of bicycle seen side-on, wheels
+ -- on the floor line -- but drawn INTO the wall band instead of onto
+ -- the floor, which is a different problem entirely:
+ --
+ -- a class pin resolves a whole 8x8 tile, so `wall` (what these
+ -- carried) paints the bicycle flat on the panel: a green wall
+ -- with a bicycle printed on it, which is what the shop looked
+ -- like;
+ -- and nothing automatic can cut it out either. The panel behind
+ -- it is tile 6's stripe -- a #aaa field ruled with #555 every
+ -- four rows -- and #555 is a flood BOUNDARY, so a silhouette
+ -- flood comes back with three full-width wall stripes welded
+ -- across the bike (rows 3, 7 and 15 of the drawing). Naming the
+ -- background shades instead (prop_bg) cannot work either: the
+ -- bicycle's own basket is drawn in the very #aaa and white the
+ -- wall field uses.
+ --
+ -- So the silhouette is authored -- but MEASURED, not drawn by hand.
+ -- 6 is the plain panel these three tile columns are painted over,
+ -- and it is perfectly regular, so compositing 6 across the same 3x2
+ -- grid and flooding the background in through the pixels that still
+ -- MATCH it separates bicycle from wall exactly: 247 pixels, one
+ -- connected component, no stripe left on it and no hole in it.
+ -- That is also why `under` is six 6s -- the wall wears the panel
+ -- the artist drew everywhere else along the same run.
+ --
+ -- `depth` 2 for the same reason the `bike` pool above is 2, and
+ -- because a family shares a silhouette: these are the showroom's
+ -- bicycles, leaning on the wall rather than standing in the room.
+ -- The slab juts 2 voxels SOUTH of the band, so they stand in front
+ -- of the wall and overhang the walkable cell a little -- which is
+ -- what a bicycle leaning on a wall does. Collision is untouched.
+ --
+ -- Only BIKE_SHOP places this grid on the club atlas, at tile (1,0)
+ -- and (6,0). The same six tile ids DO pair up in gate.png (both
+ -- Route gate upper floors and all four Safari rest houses), but
+ -- that is a different image and `mounted` is keyed per tileset, so
+ -- the pattern cannot reach them.
+ mounted = {
+ {
+ w = 3,
+ depth = 2,
+ tiles = { 1, 2, 3,
+ 17, 18, 19 },
+ under = { 6, 6, 6,
+ 6, 6, 6 },
+ pixels = {
+ ".........XXX............",
+ "..XXXXX.XXXX............",
+ "..XXXXXXXXXX............",
+ "..XXXXXXXXXX............",
+ "..XXXXXXXXXXXXX.........",
+ "..XXXXXXXXXXXXXX........",
+ "..XXXXXXXXXXXXXX........",
+ "..XXXXXXXXXXXXXX..XXX...",
+ ".XXXXXXXXXXXXXXXXXXXXXX.",
+ "XXXXXXXXX..XXXXXXXXXXXXX",
+ "XXXXXXXXX.XXXXXXXXXXXXXX",
+ "XXXXXXXXX.XXXXXXXXXXXXXX",
+ "XXXXXXXXX.XXXXXXXXXXXXXX",
+ "XXXXXXXXX..XXX..XXXXXXXX",
+ ".XXXXXXX....X...XXXXXXX.",
+ "...XXX.....XXX....XXX...",
+ },
+ },
+ },
-- Left to the derived default on purpose:
-- $0F $1F the main floor checker -> walkable, ground
-- $0A $1A the dark slab under the Bike Shop's exit warp -> a
@@ -1920,11 +2265,34 @@ return {
-- towered to 24.
bookcase = { 43, 54 },
-- The galley barrels -- three down the kitchen's east wall, one in
- -- the captain's room, one in each house. A per-pixel cutout in
- -- the THIN pool, the treatment Lt. Surge's trash cans get: 72/73
- -- the lid, 88/89 the banded body. 72 is $48, the shore-set trap
- -- that had each barrel resolving to water at -2.
- prop = { 72, 73, 88, 89 },
+ -- the captain's room, one in each house. 72/73 the mouth, 88/89
+ -- the banded body. 72 is $48, the shore-set trap that had each
+ -- barrel resolving to water at -2.
+ --
+ -- This is Lt. Surge's trash can REDRAWN PIXEL FOR PIXEL on the ship
+ -- atlas: lay the two masks side by side and the only difference is
+ -- that the gym's floor stripes cross the gym copy's background,
+ -- which the flood keeps and this one has no need of. So it takes
+ -- the same treatment and the same numbers as GYM's can -- see there
+ -- for why the mouth and base ellipses are measured and the height,
+ -- well and taper are authored.
+ --
+ -- Scanned: six placements on the SHIP atlas and no others --
+ -- SS_ANNE_KITCHEN (13,5), (13,7) and (13,9) down the east wall,
+ -- SS_ANNE_CAPTAINS_ROOM (4,1), and one each in CERULEAN_BADGE_HOUSE
+ -- and FUCHSIA_GOOD_ROD_HOUSE at (7,7), both of which are ship
+ -- interiors reusing the tileset. Twenty-one more hits are id
+ -- collisions on other atlases and no business of this entry; the one
+ -- worth naming is VERMILION_DOCK, because SHIP_PORT is a near-twin
+ -- of this image -- but its $48/$49/$58/$59 are a hull panel, not a
+ -- barrel (diffed pixel for pixel), so the pin is NOT copied there.
+ can = { 72, 73, 88, 89 },
+ can_cap = 9,
+ can_base = 4,
+ can_height = 9,
+ can_well = 5,
+ can_taper = 4,
+ heights = { can = 9 },
-- The stairs, both flights, in every map that has them (1F, 2F,
-- 3F, B1F and the captain's room). Same two drawings the whole
-- game uses, and the warp table says which is which: 41/42/57/58
@@ -2429,6 +2797,11 @@ return {
-- PLATEAU, so the wall reads as set INTO the terrace instead of
-- standing in front of a trench of synthesized ground.
bookcase_backfill = "above",
+ -- and no shelf relief: what the collapse carries here is MASONRY,
+ -- whose courses are the wall itself rather than panes set behind
+ -- a frame. Sinking them cut a stepped bevel into the League's
+ -- walls and the pilasters (shot before it was turned off).
+ bookcase_relief = false,
-- ONE course, and it must stay one: $15/$16 + $05/$06 + $30/$31 is
-- the pilaster -- cap, shaft, plaque base -- and $15/$16 over $30/$31
-- IS the statue's plinth, the artist having drawn the same stone
@@ -3364,6 +3737,84 @@ return {
z = 13 }, -- keyboard
},
},
+ -- F05: the healing machine behind the counter -- the pair in every
+ -- Center (cells (1,0):(2,1) and (6,0):(7,1)), 22 placements, plus
+ -- the Indigo Plateau lobby's pair under MART below (scan
+ -- "58,59;74,75;76,77;6,22").
+ --
+ -- The drawing is 4x4 tiles but only its middle COLUMN is this
+ -- template's. The corners are the striped wall band (40), and
+ -- the outer tile columns are the flanking EQUIPMENT -- the hoses
+ -- (72 west, 73 east) and the control keyboard (7/13) -- which are
+ -- separate thin objects, not part of this console: they are
+ -- pinned into the THIN standee pool above and stay out of this
+ -- grid. What no class can split is the middle -- a wall-height
+ -- cabinet with a monitor perched on its front top edge, drawn
+ -- across two map rows because it towers over the 16px band
+ -- behind it, which the volume path can only read as more wall.
+ --
+ -- Read off the pixels (local x, the whole drawing's x8..x23):
+ -- rows 15-31 the CABINET's front face, 16 wide, 17 tall:
+ -- black front-top edge with vent notches (15),
+ -- white top rail (16), black panel in a white
+ -- frame (17-28), white rail / light plinth / black
+ -- ground line (29-31).
+ -- rows 6-14 the cabinet's TOP FACE seen from above -- not a
+ -- second-story facade: a white expanse with a lit
+ -- west strip (x1 white) and a shaded east strip
+ -- (x14 #555) wrapping the monitor, full-width in
+ -- front of it (13-14), strips beside it (7-12).
+ -- Its 9 rows plus the front edge row 15 ARE the
+ -- cabinet's depth: 10. `desk.top` lays the band
+ -- flat as the lid; where the monitor's drawing
+ -- occludes it, the lid continues the nearest strip
+ -- (the drawing's own pixels).
+ -- rows 1-13 the MONITOR, x2..x13: black back rim (1) and
+ -- white cap (2-3) seen from above -- 3 rows plus
+ -- the front edge, depth 4 -- then bezel, screen
+ -- and control strip (4-13) face-on, 10 tall,
+ -- standing on the cabinet top at its front: the
+ -- drawn base edge (13) is one band row up from the
+ -- front strip (14), so its front plane is one
+ -- voxel back from the cabinet's -- z 20..23. The
+ -- screen interior (x5..x10, rows 6-8) is sealed
+ -- behind its own black frame and sinks one voxel
+ -- via `inset` -- the pane rule applied by hand,
+ -- because `panes = false` blocks the global pass.
+ -- rows 0-5 at the flanks: wall stripes and the machine's
+ -- cast shadow on the wall east of it -- BACKGROUND,
+ -- the same standing as the potted plants' floor.
+ -- The `wall` element keeps the band solid over the
+ -- back map row: a 16-tall, 16-deep block cycling
+ -- the drawing's own stripe unit (x0, rows 0-3),
+ -- what the neighbouring cells' wall pins render.
+ --
+ -- `depth = 4` is BOTH map rows: the back row is the wall's plot,
+ -- the front row the machine's own, and the cabinet leans against
+ -- the wall at z 16..25. `panes = false`: the front panel seals
+ -- DARK behind LIGHT (a black panel in a white frame), the
+ -- opposite polarity to the pane rule, so the global recess pass
+ -- would sink the frame and leave the panel proud.
+ {
+ id = "center_heal_machine",
+ tiles = {
+ { 58, 59 },
+ { 74, 75 },
+ { 76, 77 },
+ { 6, 22 },
+ },
+ roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
+ slab = 0, frontEave = 0, ledge = nil, depth = 4,
+ panes = false,
+ wall = { h = 16, depthPx = 16, x = 0, cycle = { 0, 3 } },
+ desk = { fascia = { 15, 16 }, base = { 17, 31 }, z = 16,
+ depthPx = 10, top = { 6, 14 } },
+ parts = {
+ { kind = "upright", x = { 2, 13 }, top = { 1, 3 },
+ facade = { 4, 13 }, z = 20, depth = 4,
+ inset = { x = { 5, 10 }, rows = { 6, 8 } } }, -- the monitor
+ },
+ },
},
MART = {
@@ -3388,6 +3839,31 @@ return {
z = 13 }, -- keyboard
},
},
+ -- F05 again: the Indigo Plateau lobby has the Centers' healing
+ -- machine pair too (cells (5,4):(6,5) and (10,4):(11,5)), and its
+ -- MART tileset shares the POKECENTER atlas, so this is the same
+ -- drawing tile for tile. Same part table as the POKECENTER entry
+ -- above.
+ {
+ id = "center_heal_machine",
+ tiles = {
+ { 58, 59 },
+ { 74, 75 },
+ { 76, 77 },
+ { 6, 22 },
+ },
+ roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
+ slab = 0, frontEave = 0, ledge = nil, depth = 4,
+ panes = false,
+ wall = { h = 16, depthPx = 16, x = 0, cycle = { 0, 3 } },
+ desk = { fascia = { 15, 16 }, base = { 17, 31 }, z = 16,
+ depthPx = 10, top = { 6, 14 } },
+ parts = {
+ { kind = "upright", x = { 2, 13 }, top = { 1, 3 },
+ facade = { 4, 13 }, z = 20, depth = 4,
+ inset = { x = { 5, 10 }, rows = { 6, 8 } } }, -- the monitor
+ },
+ },
},
GYM = {
@@ -3416,5 +3892,350 @@ return {
},
},
},
+
+ CLUB = {
+ -- F06: the Bike Shop's OPEN TOOLBOX -- BIKE_SHOP cells (6,6) and
+ -- (7,7), and nowhere else in the game (2 placements).
+ --
+ -- The drawing looks down INTO the box: what fills its middle is not
+ -- a face but the inside of the tray, with a wrench lying in it, and
+ -- the lid standing open on the right. That is why every solid
+ -- treatment fails it. As a `billboard` (what these tiles carried)
+ -- the whole 16x16 went up as one 10-voxel per-pixel slab -- the
+ -- extruded picture in its pure form, a black monolith wearing the
+ -- drawing as a decal. Reading the middle as a cabinet FRONT and
+ -- extruding that is the same mistake one level down: it puts a wall
+ -- where the opening is. An open box has to be hollow, so `tray`
+ -- builds four walls, a floor slab and AIR between them.
+ --
+ -- The bands, and they measure out exactly (8 depth rows for a
+ -- one-tile-row plot):
+ -- row 4 the FAR rim, black across the back wall -> z 0
+ -- rows 5-10 the tray's inside, seen from above, with the
+ -- wrench lying across it -- drawn row = depth row,
+ -- six rows for six rows -> z 1-6
+ -- row 11 the near wall's INNER face, in shadow
+ -- row 12 the near rim, black right across the box -> z 7
+ -- rows 13-15 the near wall's outer face, seen face-on: a light
+ -- panel between its black rim and its black foot,
+ -- so the wall stands 4 voxels and the rim is its top
+ --
+ -- `x` is the box's outer span and `inner` the opening's, so the
+ -- one-column difference on each side IS the wall. x12 is left out
+ -- of both on purpose: the #555 at x11-x12 on rows 13-15 is the 3/4
+ -- shear of the right wall, which un-projecting simply removes --
+ -- the wall is where the plan says it is, not where the projection
+ -- slides it to. `floor = 0` puts the tray's bottom one voxel thick,
+ -- leaving three voxels of open box above it.
+ --
+ -- The LID is an upright part riding the rim, hinged on the right
+ -- and standing up: 12 tall as drawn, spanning the box's whole depth
+ -- (8) and 4 thick, which is the black outline plus the two columns
+ -- of white body the drawing gives it. It overhangs east of the
+ -- hinge, which is what a hinged lid standing open does.
+ {
+ id = "bike_shop_toolbox",
+ tiles = {
+ { 29, 13 },
+ { 21, 22 },
+ },
+ roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
+ slab = 0, frontEave = 0, ledge = nil, depthPx = 14,
+ tray = { top = { 4, 11 }, front = { 12, 15 }, x = { 2, 11 },
+ inner = { 3, 9 }, floor = 0 },
+ parts = {
+ { kind = "upright", x = { 11, 14 }, top = { 0, 0 },
+ facade = { 0, 11 }, z = 0, depth = 14 }, -- the open lid
+ },
+ },
+ },
+
+ MANSION = {
+ -- F05: the TALL display cabinet, the one with the trophy behind
+ -- its glass -- CELADON_CHIEF_HOUSE cells 3,0 and 4,0 and
+ -- CELADON_MANSION_1F cell 2,2 (3 placements). Its 32 rows read
+ -- like every band table's: 0-8 the cabinet top seen from above
+ -- (black rim, white highlight courses along the north and west,
+ -- grey field, the front corner shaded at row 8); row 9 the top's
+ -- own black front edge -- which is what the rim treatment paints,
+ -- so slab = 1 and that row folds into the roof band instead of
+ -- extruding under it; 10-31 the front face: the trophy in its
+ -- dark display opening, the nameplate under it, then the two
+ -- panelled doors of the base. The contents come off the pixels,
+ -- not the band table: the trophy, the plate and both door panels
+ -- are non-black regions the drawing seals behind its own black
+ -- frame, so the measured recess pass sinks each of them a voxel
+ -- and the frames stay proud. Nine drawn top rows over a 32px
+ -- plot, so the rims map 1:1 -- the drawn front-corner shading
+ -- lands one voxel behind the front edge -- and the uniform field
+ -- cycles between. Both cell rows of the plot are BLOCKED and
+ -- both are cabinet drawing, so D is the whole grid: the rank is
+ -- built into the room's north wall, and its front stands flush
+ -- with the short case's beside it. 23 voxels tall.
+ {
+ id = "mansion_trophy_case",
+ tiles = {
+ { 38, 41 },
+ { 40, 21 },
+ { 56, 87 },
+ { 50, 51 },
+ },
+ roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 },
+ slab = 1, frontEave = 0, ledge = nil,
+ },
+ -- F06: the SHORT book case standing beside it -- the same
+ -- cabinet on a grid one tile row shorter (CELADON_CHIEF_HOUSE
+ -- cells 2,0 and 5,0, CELADON_MANSION_1F tiles 2,5 and 6,5; 4
+ -- placements). Identical top band, identical panelled base;
+ -- only the display opening is shorter, a shelf of books where
+ -- the tall one has the trophy. Same band numbers as F05 --
+ -- which is what makes the pair read as one line of furniture --
+ -- and 15 voxels tall against the tall one's 23, exactly the 8
+ -- rows of drawing between them. The grid must carry the 38/41
+ -- cap row: {34,35} over {50,51} alone also stands on
+ -- CELADON_MANSION_2F, where it is the bottom of a taller
+ -- two-shelf bank and not this drawing at all. The 8px of wall
+ -- behind the cap is not part of the cabinet and keeps its own
+ -- `wall` pin.
+ {
+ id = "mansion_bookcase",
+ tiles = {
+ { 38, 41 },
+ { 34, 35 },
+ { 50, 51 },
+ },
+ roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 },
+ slab = 1, frontEave = 0, ledge = nil,
+ },
+ -- F07: the long table in the middle of the chief's house
+ -- (CELADON_CHIEF_HOUSE cell 2,3), the lab table's read at four
+ -- cells wide and two deep. Rows 0-23 are the tabletop seen from
+ -- above; 24-26 are the top slab's own front edge,
+ -- black/#555/black, and row 27 the #555 shadow that closes it --
+ -- exactly what the rim treatment paints, so slab = 3 and rows
+ -- 24-27 fold into the roof band. That leaves 28-30 as the base,
+ -- the same three rows the lab table's is, and the two tables
+ -- stand the same 6 voxels: row 28 the apron running the whole
+ -- width, 29-30 the end legs under it. The legs stop one row
+ -- short of the grid, so the measured ground line lands there and
+ -- the model does not float. Both cell rows of the plot are
+ -- blocked, so D is the whole grid: the 24 drawn top rows map 1:1
+ -- from the north rim and the field's own rows cycle for the last
+ -- 8. The `table` pin these tiles keep stays 12px -- it is
+ -- shared with the 2F/3F writing desks and the rooftop shed, and
+ -- nothing rides this table -- so it is only the degradation
+ -- path, neutralized wherever this template stamps.
+ {
+ id = "mansion_long_table",
+ tiles = {
+ { 38, 39, 39, 39, 39, 39, 39, 41 },
+ { 54, 55, 55, 55, 55, 55, 55, 57 },
+ { 54, 55, 55, 55, 55, 55, 55, 57 },
+ { 60, 58, 58, 58, 58, 58, 58, 59 },
+ },
+ roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 },
+ slab = 3, frontEave = 0, ledge = nil,
+ },
+ },
+
+ HOUSE = {
+ -- F08: the dining table of the generic town house -- 18
+ -- placements, every home's cells (3,3):(4,4), Blue's and the
+ -- Daycare among them -- the chief's long table (F07 above) at two
+ -- cells wide, the same read to the row. Rows 0-23 are the rounded
+ -- tabletop seen from above (black rim, white highlight course,
+ -- grey field, #555 east rim); 24-26 the slab's own front edge,
+ -- black/#555/black, and 27 the #555 apron shadow that closes it,
+ -- folded into the band; 28-30 the base -- the apron's black
+ -- underrun and the corner legs -- stopping one row short of the
+ -- grid, so the measured ground line keeps the model on its plot.
+ -- The same 6 voxels the whole table family stands. The `table`
+ -- pins stay as the degradation path, neutralized where this
+ -- stamps; the schoolhouse's half-width table with its book and the
+ -- trashed house's ransacked corner are different grids and keep
+ -- theirs.
+ {
+ id = "house_table",
+ tiles = {
+ { 38, 39, 39, 41 },
+ { 54, 47, 47, 57 },
+ { 54, 47, 47, 57 },
+ { 60, 58, 58, 59 },
+ },
+ roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 },
+ slab = 3, frontEave = 0, ledge = nil,
+ },
+ -- F09: the stool at every one of those tables (94 placements on
+ -- this tileset) -- the first template with NO base piece. The
+ -- drawing is one object, a round seat on legs, drawn MID-CELL over
+ -- its own floor (rows 0-4 are the room behind it), and no band
+ -- split fits that: a roof band starts at the drawing's top row.
+ -- So it is a desk-set of exactly one upright part anchored to the
+ -- floor: rows 5-10 the seat seen from above (its lid), row 11 the
+ -- seat's own front edge, 12-15 the legs with the floor showing
+ -- between them, which the per-pixel facade cut keeps open. The
+ -- drawing measures 7 deep (six seat rows plus the front edge at
+ -- drawn row = depth row); the shipped stool is grown two voxels
+ -- past that north AND south (z 3..13, developer-tuned against the
+ -- in-game read), with `stretch` mapping the seat band over the
+ -- deeper lid instead of smearing its last row. `panes = false`:
+ -- a stool has no windows, and the pane rule would sink the legs'
+ -- lit faces behind their own outlines. The 5-voxel stand is the
+ -- drawn elevation, and the tileset's `heights` above pins the
+ -- `stool` ride height to it, so whoever sits here sits ON the
+ -- seat; the old stool standee pins stay as the degradation path.
+ {
+ id = "house_stool",
+ tiles = {
+ { 2, 3 },
+ { 18, 19 },
+ },
+ roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
+ slab = 0, frontEave = 0, ledge = nil,
+ panes = false,
+ parts = {
+ { kind = "upright", x = { 2, 13 }, top = { 5, 10 },
+ facade = { 11, 15 }, z = 3, depth = 11,
+ stretch = true }, -- the stool
+ },
+ },
+ },
+
+ REDS_HOUSE_1 = {
+ -- F08 again: Red's and the Copycat's ground-floor dining table
+ -- (cells (3,4):(4,5) of both maps) -- the house table on the
+ -- reds_house atlas, with two differences the fields absorb.
+ --
+ -- Its tabletop field stops at row 22 and row 23 is the top's own
+ -- #555 south rim, which roofBack = 24 would lay MID-TABLE as a
+ -- dark stripe: back stops at 23 and the field cycles from there
+ -- (the drawn rim's place at the south edge is under the rim
+ -- treatment's own black, like every sibling's).
+ --
+ -- And the POTTED PLANT is drawn standing on the tabletop (rows
+ -- 5-15, x10..x21, tiles 40/55/56 with the cutout pool's 54/57
+ -- beside them). It stays the cutout standee it has always been:
+ -- `keep` leaves its tiles unclaimed so the pin and its standee
+ -- survive, `scrub` replaces its pixels with the field shade so the
+ -- model tops out as the plain surface it sits on, and `support`
+ -- states the model's 6-voxel top plane so the standee stands ON
+ -- the modelled tabletop (Structures reads it off the claim; a
+ -- plain claim's h = 0 supports nothing).
+ {
+ id = "reds_house_table",
+ tiles = {
+ { 38, 39, 40, 41 },
+ { 54, 55, 56, 57 },
+ { 44, 42, 42, 43 },
+ { 60, 58, 58, 59 },
+ },
+ roofRows = 28, roofBack = 23, roofFront = 1, roofCycle = { 2, 22 },
+ slab = 3, frontEave = 0, ledge = nil,
+ scrub = { { 10, 5, 21, 15 } },
+ keep = { 40, 54, 55, 56, 57 },
+ support = 6,
+ },
+ -- F09 again: the stools around it (10 placements across Red's and
+ -- the Copycat's ground floors), pixel for pixel the house
+ -- tileset's drawing. Same part table; see HOUSE above.
+ {
+ id = "house_stool",
+ tiles = {
+ { 2, 3 },
+ { 18, 19 },
+ },
+ roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
+ slab = 0, frontEave = 0, ledge = nil,
+ panes = false,
+ parts = {
+ { kind = "upright", x = { 2, 13 }, top = { 5, 10 },
+ facade = { 11, 15 }, z = 3, depth = 11,
+ stretch = true }, -- the stool
+ },
+ },
+ },
+
+ INTERIOR = {
+ -- F0x: Bill's desk, and the Silph president's -- one drawing, two
+ -- placements (BILLS_HOUSE cell 1,4 and SILPH_CO_11F cell 10,12,
+ -- both found by the scan and nothing else). The whole 4x4-tile
+ -- block is one designed unit, but this template deliberately takes
+ -- only the desk's own two cells.
+ --
+ -- What the 16 drawn rows are: the TABLETOP seen from above, the
+ -- lab table's pattern tile for tile -- black rim, a white
+ -- highlight course inside it, grey field -- over a 16px plot, so
+ -- they map 1:1 and nothing cycles. Standing on it, and drawn INTO
+ -- that top view the way lab_computers' objects are: a terminal
+ -- (screen head over a keypad panel, rows 3-13) and the ball
+ -- (rows 1-13). Drawn row = depth row on a top view, so each part's
+ -- `z` is where the drawing puts it: the head's front row is 8, the
+ -- keypad picks up at 9, and the ball's front row is its drawn
+ -- bottom. Only the two DEPTHS and how much of each blob is lid
+ -- rather than face are authored -- the projection cannot state
+ -- either.
+ --
+ -- Why the grid stops at two rows. This drawing's front face is its
+ -- APRON, and the artist drew the apron into the WALKABLE cell in
+ -- front (43/44 + 91/92) -- exactly where the lab table's fascia and
+ -- base are. But 43/44 is also the upper half of the CHAIR pushed
+ -- up to the desk (43/44 over 59/60, pinned `stool` in the tileset
+ -- section above), and a template claims whole TILES: reading the
+ -- apron would take the chair's back with it and leave a backless
+ -- stub in both rooms. So the apron stays with the chair, `plane`
+ -- authors the desk's height instead, and the body under the lid is
+ -- synthesized in the drawing's own shades (the band table's rim
+ -- treatment -- a shaded box closed by the outline at the floor).
+ -- 8px is not a taste number: it is the apron's own drawn row count
+ -- and the `table` height these tiles are already pinned to, so the
+ -- desk stands where the degradation path stood it.
+ {
+ id = "bills_desk",
+ tiles = {
+ { 11, 12, 13, 14 },
+ { 27, 28, 29, 30 },
+ { 43, 44, 91, 92 },
+ { 59, 60, 31, 31 },
+ },
+ roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
+ slab = 0, frontEave = 0, ledge = nil, depth = 4,
+ -- the desk's own plot is its two cells; the grid runs on because
+ -- its apron and the CHAIR share tiles 43/44
+ desk = { fascia = { 16, 18 }, base = { 19, 23 }, depth = 2 },
+ parts = {
+ -- the notes: two sheets lying on the desk, so PAPER -- one
+ -- voxel proud at drawn row = depth row, and nothing raised
+ { kind = "flat", x = { 2, 15 }, rows = { 3, 7 } },
+ -- the keyboard: a real slab, not a print on the desk. Its
+ -- keys are drawn from ABOVE so they ride the top face across
+ -- its depth, and its drawn bottom frame row stands as the
+ -- front edge
+ { kind = "upright", x = { 4, 15 }, top = { 8, 12 },
+ facade = { 12, 13 }, z = 8, depth = 6 },
+ -- the cord: the kinked dark run the drawing puts between the
+ -- keyboard's top-right corner and the computer's left corner.
+ -- Raised to the keyboard's own height so it reads as a cable
+ -- spanning them rather than a scuff on the desk
+ { kind = "upright", x = { 16, 19 }, top = { 8, 8 },
+ facade = { 8, 9 }, z = 8, depth = 2 },
+ -- the computer: drawn in 2:1 ISOMETRIC, turned 45 degrees to
+ -- the map -- see the `iso` branch in buildParts. `plan` = rx
+ -- makes its footprint a SQUARE turned 45, so from directly
+ -- above it is the cube the drawing depicts and not the slab
+ -- the 2:1 would otherwise build
+ { kind = "iso", x = { 19, 30 }, rows = { 1, 13 },
+ plan = 6, z = 9 },
+ -- the chair pushed up to the desk, drawn into the walkable
+ -- cell in front. It stands on the FLOOR, not on the desk, so
+ -- its rise is the whole plane back down; two voxels deep
+ -- because it is a chair, and `inside` cuts it per pixel, so
+ -- this is the thin slab the `stool` standee was -- the desk
+ -- claiming tiles 43/44 for its apron is what makes the
+ -- template owe it
+ { kind = "upright", x = { 2, 13 }, top = { 20, 21 },
+ facade = { 22, 31 }, rise = -8, z = 22, depth = 10 },
+ },
+ },
+ },
},
}
diff --git a/lib/Buildings.lua b/lib/Buildings.lua
index b07c913..04a55df 100644
--- a/lib/Buildings.lua
+++ b/lib/Buildings.lua
@@ -68,6 +68,47 @@ local RECESS_MAX = 24
local SHADE = { top = 0.95, south = 1.0, north = 0.68,
side = 0.78, bottom = 0.5 }
+-- ------- how far a merged run may reach: the tile lattice
+--
+-- Merging is what keeps a 90k-voxel house down to ~2k quads, and under a
+-- straight projection a run may be as long as it likes -- a straight line
+-- is a straight line however finely it is cut. THE WORLD CURVE IS NOT
+-- STRAIGHT. It drops every vertex by the square of its distance from the
+-- focus (see WorldCurve), so a quad's interior is the CHORD of a parabola
+-- its neighbours draw the arc of: a run of length L hangs k*L^2/4 below
+-- the short quads butted against it, and the join tears open.
+--
+-- Nothing bounded a run's length before, and the runs that ran away were
+-- the ones wearing a CONSTANT texel -- the roof's black eave outline, its
+-- fascia, the shaded underside -- because a flat run has no art to break
+-- it. Those reached 102px across a gym, which at V-CURVE 3 hangs some
+-- three world pixels under the roof surface beside it: the eave tore off
+-- the roof and the drop showed the building's dark interior through the
+-- slot. (Strip runs, the drawing marching along the atlas, break at the
+-- tileset's own boundaries and were never the problem.)
+--
+-- So a run stops at the next 8px lattice line. Buildings are stamped at
+-- tx*8 (see stamp), so the model's lattice IS the map's: every quad in the
+-- scene -- terrain, props, this -- now ends on the same lines, every join
+-- is vertex-for-vertex, and the bend carries them together. What is left
+-- is the sag WITHIN one cell, k*64/4, which is under a twentieth of a
+-- world pixel at any rung.
+--
+-- It costs quads on a dense city map (Cerulean's object stream goes from
+-- 35.7k to 41.6k, and its longest edge from 102px to 8px) and it costs them
+-- whether the curve is on or not, which is the deliberate trade: the mesh
+-- is cached per map and built asynchronously over seconds, so meshing for
+-- the curve's sake only when the curve is on would mean rebuilding every
+-- live map on a keypress.
+local CELL = 8
+
+-- How far a run starting at `a` may go before it crosses the next lattice
+-- line. Floor-mod, so the awning's negative z lands on the same lines the
+-- positive side does.
+local function runCap(a)
+ return CELL - a % CELL
+end
+
local function keyOf(tx, ty)
return (ty + 64) * 4096 + (tx + 64)
end
@@ -170,6 +211,39 @@ local function read(t, data, perRow)
local inside = {}
for i = 0, W * H - 1 do inside[i] = not outside[i] end
+
+ -- `scrub` names pixel rects where the drawing paints an object standing
+ -- ON the surface (Red's potted plant on the dining tabletop). The object
+ -- keeps its own standee -- the template's `keep` leaves its tiles
+ -- unclaimed -- so the band beneath it is the one surface the drawing
+ -- implies but never paints clear: every rect pixel takes the field
+ -- shade, sourced from the first field texel outside the rects, and the
+ -- model's top comes out as the plain surface the object sat on.
+ if t.scrub then
+ local function inRect(x, y)
+ for _, r in ipairs(t.scrub) do
+ if x >= r[1] and x <= r[3] and y >= r[2] and y <= r[4] then
+ return true
+ end
+ end
+ return false
+ end
+ local donor = nil
+ for i = 0, W * H - 1 do
+ if col[i] == GREY and inside[i]
+ and not inRect(i % W, math.floor(i / W)) then
+ donor = i
+ break
+ end
+ end
+ for i = 0, W * H - 1 do
+ if inRect(i % W, math.floor(i / W)) then
+ col[i] = GREY
+ ax[i], ay[i] = ax[donor], ay[donor]
+ inside[i] = true
+ end
+ end
+ end
return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside }
end
@@ -276,6 +350,14 @@ local function measure(sp, t)
end
end
+ -- The pane rule reads a LIGHT region the drawing seals behind a BLACK
+ -- frame. A drawing built the other way round -- the healing machine's
+ -- dark screens sealed behind their own white bezels -- inverts under
+ -- it: every lit edge sinks and the black panes stand proud, a black
+ -- lattice a voxel off the face. `panes = false` says the drawing does
+ -- not carry the rule's polarity, so the facade stays flush.
+ if t.panes == false then recess = {} end
+
-- One representative texel per shade, taken from the building's own art:
-- the roof's fascia and its undersides are geometry the drawing implies
-- but never paints, and they must still wear its palette (and pick up
@@ -300,7 +382,12 @@ local function measure(sp, t)
-- onto ground the drawing merely stands its legs on: the lab table's
-- third row is the walkable cell the player faces it from, and the
-- full-grid depth would stand the model in their path.
- return { top = top, ytop = ytop, D = (t.depth or #t.tiles) * 8,
+ -- `depth` names the plot in TILE ROWS, which is the right grain for a
+ -- building. `depthPx` names it in voxels, for an object whose real
+ -- depth is not a whole tile row -- the Bike Shop toolbox is a box
+ -- standing in the middle of its own cell, not a thing that fills a plot.
+ return { top = top, ytop = ytop,
+ D = t.depthPx or ((t.depth or #t.tiles) * 8),
ground = ground,
recess = recess, interior = interior, shadeTexel = shadeTexel }
end
@@ -343,19 +430,308 @@ local function deskSetModel(sp, pr, t)
return sx
end
+ -- The parts list, shared by every base piece: a desk plane or an
+ -- open tray rim alike, `plane` is simply the height they ride.
+ local ytop = 0
+ local function buildParts(plane)
+ for _, p in ipairs(t.parts) do
+ Budget.tick()
+ local x0, x1 = p.x[1], p.x[2]
+ if p.kind == "flat" then
+ -- drawn row = depth row by default; `z` renames the origin when
+ -- the flat sits below the desk's own drawn top span (the Center
+ -- PC's keyboard)
+ local r0 = p.rows[1]
+ local z0 = p.z or r0
+ for sy = r0, p.rows[2] do
+ local z = z0 + (sy - r0)
+ if z >= 0 and z < D then
+ for sx = x0, x1 do
+ if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end
+ end
+ end
+ end
+ elseif p.kind == "iso" then
+ -- An ISO part is drawn in 2:1 isometric -- a box TURNED 45
+ -- degrees to the map, so one rhombus carries its top, its front
+ -- and its side at once and no band or facade split can reach
+ -- them. Un-projecting it is that projection run backwards: the
+ -- box stands as a real diamond in plan and every voxel wears the
+ -- texel the drawing paints where that voxel projects TO. The
+ -- drawn top lands on the top, the screen on the screen-facing
+ -- side and the flank on the flank, and nothing is segmented by
+ -- hand -- which is the only way to get this right, because the
+ -- three faces meet on a diagonal no rectangle can name.
+ --
+ -- Everything but the depth centre falls out of the drawn rect,
+ -- because the projection fixes it: the half-width is the drawn
+ -- rhombus's x radius, HALF that again its z radius (2:1 is what
+ -- makes it isometric), the near corner's drawn row is the base
+ -- rhombus's front tip, and whatever drawn height is left once
+ -- that rhombus is accounted for is the box's own height. Bill's
+ -- computer: rx 6, rz 3, base centre row 10, and 6 voxels tall --
+ -- which puts its left corner's vertical edge at drawn rows
+ -- 4..10, exactly where the drawing paints one.
+ --
+ -- `plan` is the one thing the drawing CANNOT state: 2:1 is the
+ -- projection, not the object, so reading rz as the plan radius
+ -- too builds a box half as deep as it is wide -- a slab, not the
+ -- cube the drawing depicts. `plan` names the real z radius and
+ -- the drawn row is scaled into it, so a cube is `plan = rx` and
+ -- the drawing still lands on it pixel for pixel.
+ local pr0, pr1 = p.rows[1], p.rows[2]
+ local rx = math.floor((x1 - x0 + 1) / 2)
+ local rz = math.floor(rx / 2)
+ local plan = p.plan or rz
+ local oy = pr1 - rz
+ local h = oy - rz - pr0
+ local ytp = plane + h
+ if ytp > ytop then ytop = ytp end
+ for sx = x0, x1 do
+ -- doubled, so a rect of even width keeps its centre between
+ -- two columns instead of limping one to the left
+ local dx2 = 2 * sx - (x0 + x1)
+ for dz = -plan, plan do
+ local z = p.z + dz
+ local d2 = math.abs(dx2) * plan + 2 * math.abs(dz) * rx
+ if z >= 0 and z < D and d2 <= (2 * rx + 1) * plan then
+ -- the plan row scaled back into the drawn rhombus
+ local dzs = math.floor((2 * dz * rz + plan) / (2 * plan))
+ for y = 0, h do
+ local sy = oy + dzs - y
+ local i = sy * W + sx
+ if sy >= pr0 and sy <= pr1 and inside[i] then
+ put(sx, plane + y, z, i)
+ end
+ end
+ end
+ end
+ end
+ else
+ local tr0, tr1 = p.top[1], p.top[2]
+ local fr0, fr1 = p.facade[1], p.facade[2]
+ local pd = p.depth
+ -- `rise` lifts a part off the desk's top plane and `z` names its
+ -- back-most depth row (the field a flat part already carries). An
+ -- object STANDING on a desk needs neither: it starts on the plane
+ -- at the plot's back. The healing machine's console needs both --
+ -- it stands in the FRONT map row of a grid whose back row is the
+ -- wall band it leans against, and its screen head is MOUNTED on
+ -- the console's front two voxels above the body's top. Both come
+ -- off the drawing, not off taste.
+ local base = plane + (p.rise or 0)
+ local pz = p.z or 0
+ local ytp = base + (fr1 - fr0)
+ if ytp > ytop then ytop = ytp end
+ -- `inset` sinks an authored pane one voxel: the pane rule
+ -- applied by hand, for a part whose screen IS sealed behind its
+ -- own black frame while the template's `panes = false` (set for
+ -- the polarity-inverted panel elsewhere in the same drawing)
+ -- blocks the global pass. Same mechanism as a recess: the front
+ -- voxel is simply not placed.
+ local ins = p.inset
+ for sx = x0, x1 do
+ -- the lid: the part's drawn top laid across its depth from the
+ -- back, last row continuing forward; the front lid row is the
+ -- facade's own top row -- the drawn front-top edge. `stretch`
+ -- maps the drawn band over the whole depth instead, the tray's
+ -- rule: for a part authored DEEPER than its drawing (the house
+ -- stool grown past its drawn seat), clamping would print the
+ -- last row as a long smear off the back band's edge.
+ for z = pz, pz + pd - 1 do
+ local front = z == pz + pd - 1
+ local sy
+ if front then
+ sy = fr0
+ elseif p.stretch then
+ sy = math.min(tr0 + math.floor((z - pz) * (tr1 - tr0 + 1)
+ / (pd - 1)), tr1)
+ else
+ sy = math.min(tr0 + z - pz, tr1)
+ end
+ while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end
+ local ok = sy <= tr1 or (front and inside[fr0 * W + sx])
+ if ok and z >= 0 and z < D then
+ put(sx, ytp, z, (front and fr0 or sy) * W + sx)
+ end
+ end
+ -- the body: facade rows anchored to the part's own base
+ for sy = fr0 + 1, fr1 do
+ local y = base + (fr1 - sy)
+ local i = sy * W + sx
+ if inside[i] then
+ local ix = interiorAt(sx, sy, x0, x1)
+ for z = pz, pz + pd - 1 do
+ if z >= 0 and z < D then
+ if z == pz + pd - 1 then
+ local sunk = ins and sx >= ins.x[1] and sx <= ins.x[2]
+ and sy >= ins.rows[1] and sy <= ins.rows[2]
+ if not sunk and not pr.recess[i] then put(sx, y, z, i) end
+ elseif z == pz then
+ put(sx, y, z, i)
+ else
+ put(sx, y, z, sy * W + ix)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ -- A TRAY is an open container -- the drawing looks down INTO it, so its
+ -- top-view band is not a lid but the inside of the box, and the model
+ -- has to be hollow. Bands, all measured 1:1 like any other band table:
+ -- `top` is the opening (drawn row -> depth row), `front` the near wall
+ -- seen face-on (drawn row -> elevation), `x` the box's outer span and
+ -- `inner` the opening's, so the difference between them is the wall.
+ -- Four walls stand to the rim, the floor slab lies `floor` voxels thick
+ -- under the opening, and the cavity between them is left as AIR -- which
+ -- is the whole point, and what an extruded facade can never be. Parts (a
+ -- standing lid) then ride the rim like any object on a desk's plane.
+ if t.tray then
+ local tr = t.tray
+ local top0 = tr.top[1]
+ local fr0, fr1 = tr.front[1], tr.front[2]
+ local bx0, bx1 = tr.x[1], tr.x[2]
+ local ix0, ix1 = tr.inner[1], tr.inner[2]
+ local floor = tr.floor or 0
+ local plane = fr1 - fr0 + 1 -- the rim: the wall's height
+ -- Which drawn row lies at depth z. The far rim is the band's first
+ -- row and the near rim the front wall's own, and the drawn inside
+ -- STRETCHES over whatever depth is between them: a box deeper than
+ -- its drawing has rows to spare is the ordinary case once the plot
+ -- stops being the grid, and the alternative -- running out of rows
+ -- and repeating the last one -- would print the wrench twice.
+ local lo, hi = top0 + 1, tr.top[2] - 1 -- the drawn inside
+ local span = math.max(1, D - 3) -- interior depth rows - 1
+ local function trayRow(z)
+ if z == 0 then return top0 end
+ if z == D - 1 then return fr0 end
+ return lo + math.floor((z - 1) * (hi - lo) / span)
+ end
+ for sx = bx0, bx1 do
+ Budget.tick()
+ for z = 0, D - 1 do
+ local hollow = sx >= ix0 and sx <= ix1 and z > 0 and z < D - 1
+ for y = 0, (hollow and floor or plane - 1) do
+ if hollow or y == plane - 1 then
+ -- the opening seen from above: the tray's own floor and
+ -- whatever lies in it -- and the rim is the same band where
+ -- the wall meets it
+ local i = trayRow(z) * W + sx
+ if inside[i] then put(sx, y, z, i) end
+ else
+ -- the wall below the rim: the front band folded up it, the
+ -- drawn face on the front and back layers and the de-outlined
+ -- interior between, exactly as a facade extrudes.
+ --
+ -- NO recess pass here, and it must stay that way: a pane sinks
+ -- by DELETING its front voxel so the one behind becomes the
+ -- pane, and a container's wall is one voxel thick -- there is
+ -- nothing behind it, so the front panel simply opened a hole
+ -- straight into the box and you could see the wrench through it.
+ local sy = fr1 - y
+ local i = sy * W + sx
+ if inside[i] then
+ local px = (z == 0 or z == D - 1) and sx
+ or interiorAt(sx, sy, bx0, bx1)
+ put(sx, y, z, sy * W + px)
+ end
+ end
+ end
+ end
+ end
+ if plane > ytop then ytop = plane end
+ buildParts(plane)
+ return { at = function(x, y, z)
+ if x < 0 or x >= W or y < 0 or z < 0 or z >= D then
+ return nil
+ end
+ return vox[key(x, y, z)]
+ end,
+ W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
+ end
+
+ -- No base piece at all: the drawing IS its parts (the house stool -- a
+ -- seat and its legs, nothing under them but floor). The plane the parts
+ -- anchor to is the ground itself.
+ if not t.desk then
+ buildParts(0)
+ return { at = function(x, y, z)
+ if x < 0 or x >= W or y < 0 or z < 0 or z >= D then
+ return nil
+ end
+ return vox[key(x, y, z)]
+ end,
+ W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
+ end
+
+ -- The desk's top plane. Usually the drawing states it: the fascia and
+ -- base rows it paints below the objects ARE the front face, and their
+ -- row count is the height. Bill's desk paints neither inside its grid
+ -- -- its apron is drawn into the WALKABLE cell in front, and that cell
+ -- is left out on purpose so the chair standing there keeps its own
+ -- tiles -- so `plane` names the height directly and the body below the
+ -- lid is synthesized: the band table's own rim treatment, a shaded box
+ -- closed by the outline where it meets the floor, in the drawing's
+ -- shades via shadeTexel.
local f0, f1 = t.desk.fascia[1], t.desk.fascia[2]
local b0, b1 = t.desk.base[1], t.desk.base[2]
local plane = (b1 - b0 + 1) + (f1 - f0 + 1)
+ -- The desk's own PLOT, when the grid holds more than the desk. Bill's
+ -- grid runs on into the walkable cell, because the drawing puts the
+ -- desk's apron AND the chair pushed up to it in the same tiles -- so
+ -- the desk box has to stop at its own cell (`depth`) and stand on its
+ -- own ground line rather than the grid's, which the chair's feet set
+ -- eight rows lower. The base band's last row IS that ground line by
+ -- definition, and for every desk drawn inside its own grid it is the
+ -- measured one to the row (lab table, lab computers, Center PC, the
+ -- Bike Shop toolbox), so this changes nothing for them.
+ -- ...and in voxels (`depthPx`) plus a back origin (`z`) when the desk
+ -- is shallower than a tile row and leans against something: the
+ -- healing machine's cabinet is 10 deep -- its drawn top band's 9 rows
+ -- plus the front edge -- standing against the wall band, so its box
+ -- runs z 16..25 of a 32-deep plot.
+ local deskD = t.desk.depthPx or (t.desk.depth and t.desk.depth * 8) or D
+ local dz0 = t.desk.z or 0
+ local dz1 = dz0 + deskD - 1
+ local deskG = b1 + 1
+
+ -- The WALL element: the band the machine backs onto, whose tiles this
+ -- grid claims. The drawing shows it only as the stripe background
+ -- around the tower (the same standing as the potted plants' floor),
+ -- so the block cycles the drawing's own stripe unit -- real pixels of
+ -- column `x`, rows `cycle` -- at wall-band height over the back plot,
+ -- exactly what the neighbouring cells' `wall` pins render.
+ if t.wall then
+ local wl = t.wall
+ local c0, c1 = wl.cycle[1], wl.cycle[2]
+ local cn = c1 - c0 + 1
+ local wx = wl.x or 0
+ for y = 0, wl.h - 1 do
+ Budget.tick()
+ local sy = c0 + (wl.h - 1 - y) % cn
+ for sx = 0, W - 1 do
+ for z = 0, wl.depthPx - 1 do
+ put(sx, y, z, sy * W + wx)
+ end
+ end
+ end
+ end
+
-- the base band, extruded exactly like every lab table's
for sy = b0, b1 do
Budget.tick()
- local y = ground - 1 - sy
+ local y = deskG - 1 - sy
for sx = 0, W - 1 do
if inside[sy * W + sx] then
local ix = interiorAt(sx, sy, 0, W - 1)
- for z = 0, D - 1 do
- local px = (z == 0 or z == D - 1) and sx or ix
+ for z = dz0, dz1 do
+ local px = (z == dz0 or z == dz1) and sx or ix
put(sx, y, z, sy * W + px)
end
end
@@ -364,91 +740,72 @@ local function deskSetModel(sp, pr, t)
for i in pairs(pr.recess) do
local sy = math.floor(i / W)
if sy >= b0 and sy <= b1 then
- vox[key(i % W, ground - 1 - sy, D - 1)] = nil
+ vox[key(i % W, deskG - 1 - sy, dz1)] = nil
end
end
- -- the slab: fascia rows wrap every side; the lid continues the
- -- sibling tables' top -- black rim, white highlight courses along
- -- the north and west, grey field
+ -- the slab: fascia rows wrap every side
for sy = f0, f1 do
Budget.tick()
local y = plane - 1 - (sy - f0)
for sx = 0, W - 1 do
- for z = 0, D - 1 do put(sx, y, z, sy * W + sx) end
- end
- end
- local field = t.desk.lid == "white" and WHITE or GREY
- for sx = 0, W - 1 do
- for z = 0, D - 1 do
- local shade = field
- if sx == 0 or sx == W - 1 or z == 0 or z == D - 1 then
- shade = BLACK
- elseif sx == 1 or z == 1 then
- shade = WHITE
- end
- put(sx, plane - 1, z, pr.shadeTexel[shade])
+ for z = dz0, dz1 do put(sx, y, z, sy * W + sx) end
end
end
- local ytop = plane
- for _, p in ipairs(t.parts) do
- Budget.tick()
- local x0, x1 = p.x[1], p.x[2]
- if p.kind == "flat" then
- -- drawn row = depth row by default; `z` renames the origin when
- -- the flat sits below the desk's own drawn top span (the Center
- -- PC's keyboard)
- local r0 = p.rows[1]
- local z0 = p.z or r0
- for sy = r0, p.rows[2] do
- local z = z0 + (sy - r0)
- if z >= 0 and z < D then
- for sx = x0, x1 do
- if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end
+ if t.desk.top then
+ -- The lid wears the desk's own drawn top band -- the drawing DOES
+ -- paint this tabletop (the healing machine's white top face with
+ -- its lit west and shaded east strips), so nothing is synthesized
+ -- where it is visible: band rows map back-to-front, the first
+ -- fascia row is the drawn front-top edge, same rule as an upright
+ -- part's lid. Where a part's drawing occludes the band (the monitor
+ -- standing on it), the lid continues the nearest strip BESIDE the
+ -- part -- still the drawing's own pixels, the same sibling-pattern
+ -- rule every synthesized lid follows.
+ local tr0, tr1 = t.desk.top[1], t.desk.top[2]
+ for z = dz0, dz1 do
+ Budget.tick()
+ local sy = z == dz1 and f0 or math.min(tr0 + (z - dz0), tr1)
+ for sx = 0, W - 1 do
+ local px = sx
+ for _, p in ipairs(t.parts) do
+ local px0, px1 = p.x[1], p.x[2]
+ local r0, r1
+ if p.kind == "flat" or p.kind == "iso" then
+ r0, r1 = p.rows[1], p.rows[2]
+ else
+ r0, r1 = p.top[1], p.facade[2]
end
- end
- end
- else
- local tr0, tr1 = p.top[1], p.top[2]
- local fr0, fr1 = p.facade[1], p.facade[2]
- local pd = p.depth
- local ytp = plane + (fr1 - fr0)
- if ytp > ytop then ytop = ytp end
- for sx = x0, x1 do
- -- the lid: the part's drawn top laid across its depth from the
- -- back, last row continuing forward; the front lid row is the
- -- facade's own top row -- the drawn front-top edge
- for z = 0, pd - 1 do
- local front = z == pd - 1
- local sy = front and fr0 or math.min(tr0 + z, tr1)
- while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end
- local ok = sy <= tr1 or (front and inside[fr0 * W + sx])
- if ok then
- put(sx, ytp, z, (front and fr0 or sy) * W + sx)
+ if sx >= px0 and sx <= px1 and sy >= r0 and sy <= r1 then
+ px = (sx - px0 < px1 - sx) and (px0 - 1) or (px1 + 1)
+ px = math.max(0, math.min(W - 1, px))
+ break
end
end
- -- the body: facade rows anchored to the desk's top plane
- for sy = fr0 + 1, fr1 do
- local y = plane + (fr1 - sy)
- local i = sy * W + sx
- if inside[i] then
- local ix = interiorAt(sx, sy, x0, x1)
- for z = 0, pd - 1 do
- if z == pd - 1 then
- if not pr.recess[i] then put(sx, y, z, i) end
- elseif z == 0 then
- put(sx, y, z, i)
- else
- put(sx, y, z, sy * W + ix)
- end
- end
- end
+ put(sx, plane - 1, z, sy * W + px)
+ end
+ end
+ else
+ -- the lid continues the sibling tables' top -- black rim, white
+ -- highlight courses along the north and west, grey field
+ local field = t.desk.lid == "white" and WHITE or GREY
+ for sx = 0, W - 1 do
+ for z = dz0, dz1 do
+ local shade = field
+ if sx == 0 or sx == W - 1 or z == dz0 or z == dz1 then
+ shade = BLACK
+ elseif sx == 1 or z == dz0 + 1 then
+ shade = WHITE
end
+ put(sx, plane - 1, z, pr.shadeTexel[shade])
end
end
end
+ if plane > ytop then ytop = plane end
+ buildParts(plane)
+
return { at = function(x, y, z)
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then return nil end
return vox[key(x, y, z)]
@@ -638,7 +995,8 @@ local function emit(m, sp, atlasW, atlasH)
local function runX(y, z, dx, dy, dz, x)
local i0 = ci(x, y, z)
local strip, n = nil, 1
- while true do
+ local cap = runCap(x)
+ while n < cap do
local nx = x + n
local i = ci(nx, y, z)
if not i or ci(nx + dx, y + dy, z + dz) then break end
@@ -730,8 +1088,8 @@ local function emit(m, sp, atlasW, atlasH)
while z <= zmax do
local i = ci(x, y, z)
if i and not ci(x + d, y, z) then
- local n = 1
- while z + n <= zmax do
+ local n, cap = 1, runCap(z)
+ while n < cap and z + n <= zmax do
local j = ci(x, y, z + n)
if j ~= i or ci(x + d, y, z + n) then break end
n = n + 1
@@ -840,7 +1198,7 @@ function Buildings.build(S, map, data, perRow)
end
built = models[key]
end
- Buildings.stamp(S, map, built, tx, ty, bw, bh)
+ Buildings.stamp(S, map, built, tx, ty, bw, bh, t)
end
end
end
@@ -850,9 +1208,24 @@ end
-- One placement: claim its tiles (so the detector leaves them alone and
-- the mesher paints ground under them) and copy the model into place.
-function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
- local shape = { class = "building", h = 0, art = "building",
- flat = false, authored = true }
+--
+-- Two template fields alter what a claim means, for a drawing that
+-- carries a STANDEE on its surface (Red's potted plant on the dining
+-- table). `keep` names tile ids the stamp must NOT claim: their authored
+-- pins stay live, so the standee scan still stands the object exactly as
+-- it always did. `support` is the model's top plane in voxels: the claim
+-- shape carries it as its height, which is what tells that scan the
+-- standee's shelf -- a plain claim stays at h = 0, and Structures treats
+-- a building claim with height as a full model (skip, never a second
+-- box; see its support branches).
+function Buildings.stamp(S, map, quads, tx, ty, bw, bh, t)
+ local shape = { class = "building", h = (t and t.support) or 0,
+ art = "building", flat = false, authored = true }
+ local keep = nil
+ if t and t.keep then
+ keep = {}
+ for _, id in ipairs(t.keep) do keep[id] = true end
+ end
-- the ground the building stands on: the commonest flat tile around its
-- feet, so a house on a path keeps its path
@@ -878,9 +1251,18 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
for r = 0, bh - 1 do
for c = 0, bw - 1 do
local k = keyOf(tx + c, ty + r)
- S.shapeAt[k] = shape
- S.skip[k] = true
- S.ground[k] = best or false
+ if keep and keep[S.tileAt[k]] then
+ -- unclaimed by request: the tile keeps its pin (the plant's
+ -- cutout pool) and the standee scan finds it there. Only the
+ -- ground is set now, so the scan's own claim of these tiles has
+ -- the building's floor to paint when no flat tile touches a
+ -- cluster ringed by its own furniture.
+ S.ground[k] = best or false
+ else
+ S.shapeAt[k] = shape
+ S.skip[k] = true
+ S.ground[k] = best or false
+ end
end
end
diff --git a/lib/Structures.lua b/lib/Structures.lua
index 2e3e70c..ad3d5ec 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -75,9 +75,12 @@ local OBJECT_DEPTH = 6 -- voxel thickness of a detected prop
-- 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
+-- 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 }
+ console = 10, post = 6, signpost = 2, bike = 2 }
local MAX_ROWS = 6 -- volume height cap: 48px
@@ -297,7 +300,9 @@ function Structures.forMap(map)
Structures.buildStairs(S, map, x0, x1, y0, y1)
-- ---- bookcases: pinned shelves collapsed to one cell of depth ----
- Structures.buildBookcases(S, map, x0, x1, y0, y1)
+ -- 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
@@ -307,6 +312,14 @@ function Structures.forMap(map)
-- 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 = {}
@@ -558,14 +571,51 @@ end
local ROUND_SHADE = { front = 1.0, back = 0.68, side = 0.78,
top = 1.0, bottom = 0.55 }
-local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
- N = N or 16 -- art canvas: 16 = one cell, 32 = 2x2 cells
- local N2 = N / 2
+-- 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 (NxN, row 0 = top), anchored at cell (cx, cy)
+ -- 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))]
@@ -576,19 +626,19 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
math.floor(tile / perRow) * 8 + py % 8
end
- -- shade class of every canvas pixel, indexed py * N + px
+ -- shade class of every canvas pixel, indexed py * NX + px
local cls = {}
- for py = 0, N - 1 do
- for px = 0, N - 1 do
+ 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 * N + px] = a == 0 and "off"
- or Structures.shadeClass(math.min(r, g, b))
+ cls[py * NX + px] = a == 0 and "off"
+ or Structures.shadeClass(math.min(r, g, b))
end
end
- -- 4-connected flood from the canvas border through `passable` classes
- local function floodOutside(passable)
+ -- 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
@@ -596,39 +646,56 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
stack[#stack + 1] = i
end
end
- for i = 0, N - 1 do
- seed(i); seed(N * (N - 1) + i); seed(i * N); seed(i * N + N - 1)
+ 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 = i % N
+ local px, py = i % NX, math.floor(i / NX)
if px > 0 then seed(i - 1) end
- if px < N - 1 then seed(i + 1) end
- if i >= N then seed(i - N) end
- if i < N * (N - 1) then seed(i + N) 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; dither fallback
- local out = floodOutside({ off = true, dark = true,
- light = true, white = true })
- local mask, enclosed = {}, 0
- for i = 0, N * N - 1 do
- if not out[i] then
- mask[i] = true
- if cls[i] ~= "black" then enclosed = enclosed + 1 end
- end
- end
- if enclosed < N * N / 8 then
- out = floodOutside({ off = true, light = true, white = true })
- mask = {}
- for i = 0, N * N - 1 do
- if not out[i] and cls[i] ~= "off" then mask[i] = true 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, N * N - 1 do any = any or mask[i] end
+ 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
@@ -638,24 +705,96 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
local capY0, capY1 = nil, nil
if capRows and capRows > 0 then
local top = nil
- for iy = 0, N - 1 do
- for ix = 0, N - 1 do
- if mask[iy * N + ix] then top = iy break end
+ 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, N - 2)
+ capY1 = math.min(top + capRows - 1, NY - 2)
for iy = capY0, capY1 do
- for ix = 0, N - 1 do mask[iy * N + ix] = nil end
+ 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, N * N - 1 do any = any or mask[i] end
+ 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
@@ -670,11 +809,15 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
local ox = (t % perRow) * 8
local oy = math.floor(t / perRow) * 8
local score, n = 0, 0
- for py = 0, N - 1 do
- for px = 0, N - 1 do
- local i = py * N + px
+ for py = 0, NY - 1 do
+ for px = 0, NX - 1 do
+ local i = py * NX + px
local c = cls[i]
- if not mask[i] and (c == "light" or c == "white") then
+ -- 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)
@@ -691,14 +834,17 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
end
end
- -- discs: per mask pixel a z chord [z0, z1), from its row's span circle
- local z0, z1, src = {}, {}, {}
+ -- 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, N - 1 do
+ for iy = 0, NY - 1 do
local lo, hi = nil, nil
- for ix = 0, N - 1 do
- if mask[iy * N + ix] then
+ for ix = 0, NX - 1 do
+ if mask[iy * NX + ix] then
lo = lo or ix
hi = ix
end
@@ -709,7 +855,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
local c = (lo + hi + 1) / 2
local hw = (hi - lo + 1) / 2
for ix = lo, hi do
- local i = iy * N + ix
+ local i = iy * NX + ix
if mask[i] then
local dx = ix + 0.5 - c
local n = 1
@@ -717,23 +863,148 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
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
- src[i] = iy
+ -- 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
- for iy = yBot + 1, N - 1 do
+ -- 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 * N + ix
+ local b = yBot * NX + ix
if z0[b] then
- local i = iy * N + ix
- z0[i], z1[i], src[i] = z0[b], z1[b], yBot
+ 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
@@ -742,31 +1013,47 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
-- projection below
local capTopRow, capZ0, capZ1 = nil, nil, nil
if capY0 then
- for iy = 0, N - 1 do
+ 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 * N + ix
+ 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 z1[i], z1[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 > N - 1 or iy < 0 or iy > N - 1 then return false end
- local i = iy * N + ix
- return z0[i] ~= nil and iz >= z0[i] and iz < z1[i]
+ 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(N - 1, iy + 4) do
- local i = iy2 * N + ix
+ 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)
@@ -778,56 +1065,95 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
-- 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)
- local r = src[iy * N + ix]
+ -- 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 = ix + dir * step
- local i2 = r * N + x2
- if x2 < 0 or x2 > N - 1 or not mask[i2] then break end
+ 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(ix, r)
+ return texel(a, r)
end
local quads = {}
- for iy = 0, N - 1 do
+ for iy = 0, NY - 1 do
if loRow[iy] then
- local yB, yT = N - 1 - iy, N - iy
+ 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 * N + ix
+ local i = iy * NX + ix
if z0[i] then
local ix2 = ix
while ix2 + 1 <= hiRow[iy] do
- local j = iy * N + ix2 + 1
- if z0[j] == z0[i] and z1[j] == z1[i]
+ 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 ax0, ay = texel(ix, src[i])
- local ax1 = (texel(ix2, src[i]))
- local u0, u1 = (ax0 + 0.05) / atlasW, (ax1 + 0.95) / atlasW
- local v0, v1 = (ay + 0.05) / atlasH, (ay + 0.95) / atlasH
local x0, x1 = ix - N2, ix2 - N2 + 1
- local zF, zB = z1[i] - N2, z0[i] - N2
- quads[#quads + 1] = {
- { x0, yB, zF }, { x1, yB, zF }, { x1, yT, zF }, { x0, yT, zF },
- uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
- shade = ROUND_SHADE.front,
- }
- quads[#quads + 1] = {
- { x1, yB, zB }, { x0, yB, zB }, { x0, yT, zB }, { x1, yT, zB },
- uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } },
- shade = ROUND_SHADE.back,
- }
+ -- 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
@@ -837,19 +1163,20 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
-- 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 * N + ix
+ local i = iy * NX + ix
if z0[i] then
- local ax, ay = texel(ix, src[i])
+ 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
- local function pieces(nx, ny, emit)
- local iz = z0[i]
- while iz < z1[i] do
+ -- 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 < z1[i] and not solidAt(nx, ny, iz2 + 1) do
+ while iz2 + 1 < zHi and not solidAt(nx, ny, iz2 + 1) do
iz2 = iz2 + 1
end
emit(iz - N2, iz2 - N2 + 1, iz, iz2)
@@ -859,6 +1186,10 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
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
@@ -881,7 +1212,12 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
u = tu, v = tv, shade = ROUND_SHADE.top,
}
end
- if capTopRow and iy == capTopRow and capZ1 then
+ -- 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
@@ -890,7 +1226,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
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(ix, ry)
+ local cax, cay = texel(srcX[i] or ix, ry)
top(iz - N2, iz - N2 + 1,
(cax + 0.5) / atlasW, (cay + 0.5) / atlasH)
end
@@ -900,11 +1236,21 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
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 < N - 1 then
+ 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 },
@@ -946,8 +1292,12 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
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)
- local stumpCap = 6
+ -- (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
@@ -955,6 +1305,21 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
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
@@ -1015,13 +1380,72 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
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
- local cap = s.class == "stump" and stumpCap or nil
+ -- 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 "") .. "|"
+ 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)],
@@ -1029,7 +1453,8 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
local tpl = roundCache[sig]
if not tpl then
local tq, tbg = roundTemplate(S, map, data, cx, cy,
- groundTiles, 16, cap)
+ groundTiles, 16, cap, nil, nil,
+ base, tall, well, taper)
tpl = { quads = tq, bg = tbg }
roundCache[sig] = tpl
end
@@ -1179,11 +1604,107 @@ end
-- 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 }
+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, tx, northTy, frontTy, capTile)
+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 perRow = map.tileset.tilesPerRow or 16
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local function uvRect(tile)
@@ -1194,12 +1715,13 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile)
end
local size = frontTy - northTy + 1
- local bands = size + (capTile and 1 or 0)
+ 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)
@@ -1208,14 +1730,101 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile)
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
- 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 }
+ 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 } },
@@ -1245,10 +1854,19 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile)
end
end
-function Structures.buildBookcases(S, map, x0, x1, y0, y1)
+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
@@ -1295,7 +1913,17 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1)
S.ground[tk] = false
end
end
- bookcaseRank(S, map, tx, top, front, capTile)
+ -- 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
@@ -1304,6 +1932,24 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1)
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 ----
@@ -2023,8 +2669,14 @@ function Structures.buildObject(S, map, region, cluster,
-- 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") then
+ and (bs.art == "upright" or bs.art == "bookcase"
+ or bs.class == "building") then
baseY, support = bs.h, bs
end
end
@@ -2159,7 +2811,8 @@ function Structures.buildObject(S, map, region, cluster,
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") then
+ 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
@@ -2174,8 +2827,14 @@ function Structures.buildObject(S, map, region, cluster,
-- 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
+ 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
@@ -2204,7 +2863,167 @@ function Structures.buildObject(S, map, region, cluster,
return true
end
--- ---- figures: a person drawn INTO furniture, cut out and stood up ----
+-- ---- 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.
--
@@ -2215,22 +3034,34 @@ end
-- believe it. Which also means figures build HEADLESS: unlike every
-- other standee here, nothing below reads a pixel.
--
--- A figure is a SPRITE, not a prop. It 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).
+-- 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 quads are emitted in the card's OWN LOCAL SPACE -- x from the
+-- 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
@@ -2273,34 +3104,76 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
- 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 }
+
+ 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
- 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,
- }
+ -- 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
@@ -2342,6 +3215,78 @@ function Structures.buildFigures(S, map, x0, x1, y0, y1)
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 ----
-- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one
diff --git a/lib/TileShape.lua b/lib/TileShape.lua
index 31e5916..0ea0ada 100644
--- a/lib/TileShape.lua
+++ b/lib/TileShape.lua
@@ -71,6 +71,21 @@ local FALLBACK_HEIGHTS = {
-- body builds from the bark rows and the drawn ellipse projects onto
-- the hull's round top
stump = 16,
+ -- the same hull cut at both ends, hollowed and tapered: an OPEN bin
+ -- standing on a floor (the Vermilion Gym trash cans). The drawn mouth
+ -- ellipse projects onto the round top and down the well, the drawn base
+ -- ellipse is ground contact rather than body, and the plan narrows toward
+ -- the floor. Height is AUTHORED (the profile's can_height, which this
+ -- pin must be kept equal to so anything riding a can lands on its rim) --
+ -- the drawing's own straight run is only a couple of rows, because a GB
+ -- cell spends most of itself on the opening
+ can = 9,
+ -- round scenery drawn ONE cell wide and TWO cells TALL, standing on one
+ -- cell of plot: the Pokemon Centers' potted plants. Carved as one
+ -- 16x32x16 hull in the SOUTH (pot) cell -- the drawing's upper cell is
+ -- the object's height, not its depth. BOTH cells take the class; the
+ -- group build anchors on the north one (Structures.buildCylinders)
+ planter = 32,
billboard = 16,
signpost = 16,
post = 16,
@@ -91,6 +106,10 @@ local FALLBACK_HEIGHTS = {
desk = 24,
prop = 16,
cutout = 16,
+ -- a vehicle drawn SIDE-ON: the showroom bicycles. Standee height like
+ -- every other cutout pool -- what differs is the thickness (see
+ -- Structures' PINNED_DEPTH)
+ bike = 16,
console = 16,
relief = 3,
bookcase = 32,
@@ -127,6 +146,8 @@ local ART = {
cylinder = "cylinder",
canopy = "canopy",
stump = "cylinder",
+ can = "cylinder",
+ planter = "planter",
billboard = "billboard",
-- signposts share the billboard treatment but as their own pool at a
-- 2-voxel depth: a sign is a thin plate on a stick, and the standard
@@ -165,6 +186,13 @@ local ART = {
desk = "upright",
prop = "billboard",
cutout = "billboard",
+ -- a bicycle is a LINE drawing seen side-on, and its negative space --
+ -- the air inside the frame, between the wheel and the fork -- is what
+ -- makes it read as a bicycle at all. Its own pool at two voxels: any
+ -- thicker and the side faces of neighbouring strokes close those gaps
+ -- from every angle but dead-on, and six of them in a showroom come out
+ -- as one dark lump (which is what the 5px `prop` pool gave)
+ bike = "billboard",
-- a machine standing on furniture: the billboard treatment with
-- body, plus the one-object contract `cutout` has -- the drawing is
-- ringed by the furniture it sits on, and those edges must not be
@@ -183,6 +211,7 @@ local ART = {
local spec = nil -- the loaded data file, or false when absent
local cache = {} -- tileset id -> resolved shape list
local figCache = {} -- tileset id -> parsed figure masks, or false
+local mntCache = {} -- tileset id -> parsed mounted masks, or false
local bgCache = {} -- tileset id -> prop background shades, or false
-- The shape profile ships with the mod (data/voxel_heights.lua) and is read
@@ -430,68 +459,158 @@ end
-- pixel by pixel (see data/voxel_heights.lua):
--
-- figures = { { w = <tiles across>,
+-- depth = <voxels of body; ABSENT for a person>,
+-- thin = { rows = <top rows>, depth = <voxels> },
+-- flat = { x = { <lx0>, <lx1> }, rows = { <r0>, <r1> } },
-- tiles = { ...w*h tile ids, row-major... },
-- under = { ...w*h ids: what each tile wears once the
-- figure is lifted off it... },
-- pixels = { ...h*8 strings of w*8 chars, "." = not the
-- figure... } } }
--
--- No class: a figure is always a flat sprite card, drawn the way
--- SpriteBillboards draws a character (see Structures.buildFigures).
+-- No class -- what the entry carries instead is a `depth`, or does not:
+--
+-- WITHOUT one it is a flat sprite card, drawn the way SpriteBillboards
+-- draws a character. That is the right reading for a PERSON: a Gen 1
+-- figure is a face-on 2D icon, and extruding one reconstructs a body
+-- nobody drew (see Structures.buildFigures).
+-- WITH one it is an OBJECT and gets the standee treatment every other
+-- solid here gets -- a per-pixel slab in world space, standing on the
+-- same furniture the card would have stood on. The Marts' cash
+-- register is the case: a machine on a counter is a box, not an icon.
+--
+-- Two fields say which parts of such a drawing are NOT the extrusion,
+-- because a solid drawn in one 16x16 GB cell still packs more than one
+-- facing:
+--
+-- `thin` caps the thickness over the mask's top rows, for the part of
+-- the drawing that is not the machine (the register's receipt curl).
+-- `flat` names a rect of the mask that is a TOP-VIEW surface rather
+-- than a face -- the register's keypad, whose keys lie ON its deck.
+-- The rect lays horizontal one voxel proud of whatever the extrusion
+-- leaves below it, at the elevation its BOTTOM row would have had,
+-- with drawn row = depth row 1:1 (the mapping the lab tabletop is
+-- drawn with). So a drawing whose front elevation is an L reads as
+-- one: body up the side and along the base, keys lying in the notch.
--
-- Returned normalized: `mask` as a set keyed by ly * (w * 8) + lx, so
-- Structures can read it as a bitmap without re-parsing per position.
-- A malformed entry is dropped rather than half-applied -- a typo in a
-- mask should leave the couch alone, not carve a hole in it.
-function TileShape.figures(tilesetId)
- local hit = figCache[tilesetId]
- if hit ~= nil then return hit or nil end
-
- local s = load()
- local entry = s and s.tilesets and s.tilesets[tilesetId]
- local list = entry and entry.figures
+--
+-- `mounted` (below) carries the same four fields, so the parse is shared,
+-- and so are the optional ones that give an authored mask a BODY: `depth`,
+-- `thin` and `flat` above. `depth` is left nil when unstated, because
+-- absence is meaningful on a figure: no depth means the flat sprite card a
+-- person is drawn as.
+local function authoredMasks(list)
local out = {}
- if type(list) == "table" then
- for _, f in ipairs(list) do
- local ok = type(f) == "table" and type(f.w) == "number"
- and type(f.tiles) == "table" and type(f.under) == "table"
- and type(f.pixels) == "table"
- local w = ok and math.floor(f.w) or 0
- local h = (w >= 1) and (#f.tiles / w) or 0
- ok = ok and w >= 1 and h >= 1 and h == math.floor(h)
- and #f.under == #f.tiles and #f.pixels == h * 8
- if ok then
- for i = 1, h * 8 do
- local row = f.pixels[i]
- if type(row) ~= "string" or #row ~= w * 8 then
- ok = false
- break
- end
+ if type(list) ~= "table" then return out end
+ for _, f in ipairs(list) do
+ local ok = type(f) == "table" and type(f.w) == "number"
+ and type(f.tiles) == "table" and type(f.under) == "table"
+ and type(f.pixels) == "table"
+ local w = ok and math.floor(f.w) or 0
+ local h = (w >= 1) and (#f.tiles / w) or 0
+ ok = ok and w >= 1 and h >= 1 and h == math.floor(h)
+ and #f.under == #f.tiles and #f.pixels == h * 8
+ if ok then
+ for i = 1, h * 8 do
+ local row = f.pixels[i]
+ if type(row) ~= "string" or #row ~= w * 8 then
+ ok = false
+ break
end
end
- if ok then
- local mask, n = {}, 0
- for ly = 0, h * 8 - 1 do
- local row = f.pixels[ly + 1]
- for lx = 0, w * 8 - 1 do
- if row:sub(lx + 1, lx + 1) ~= "." then
- mask[ly * (w * 8) + lx] = true
- n = n + 1
- end
+ end
+ if ok then
+ local mask, n = {}, 0
+ for ly = 0, h * 8 - 1 do
+ local row = f.pixels[ly + 1]
+ for lx = 0, w * 8 - 1 do
+ if row:sub(lx + 1, lx + 1) ~= "." then
+ mask[ly * (w * 8) + lx] = true
+ n = n + 1
end
end
- if n > 0 then
- out[#out + 1] = { w = w, h = h, n = n, mask = mask,
- tiles = f.tiles, under = f.under }
- end
+ end
+ local depth = tonumber(f.depth)
+ local thin = nil
+ if type(f.thin) == "table" and tonumber(f.thin.rows)
+ and tonumber(f.thin.depth) then
+ thin = { rows = math.floor(tonumber(f.thin.rows)),
+ depth = math.floor(tonumber(f.thin.depth)) }
+ end
+ local flat = nil
+ if type(f.flat) == "table" and type(f.flat.x) == "table"
+ and type(f.flat.rows) == "table" then
+ flat = { x0 = math.floor(f.flat.x[1]), x1 = math.floor(f.flat.x[2]),
+ r0 = math.floor(f.flat.rows[1]),
+ r1 = math.floor(f.flat.rows[2]) }
+ end
+ if n > 0 then
+ out[#out + 1] = { w = w, h = h, n = n, mask = mask,
+ tiles = f.tiles, under = f.under,
+ depth = depth and math.floor(depth) or nil,
+ thin = thin, flat = flat }
end
end
end
+ return out
+end
+
+function TileShape.figures(tilesetId)
+ local hit = figCache[tilesetId]
+ if hit ~= nil then return hit or nil end
+
+ local s = load()
+ local entry = s and s.tilesets and s.tilesets[tilesetId]
+ local out = authoredMasks(entry and entry.figures)
figCache[tilesetId] = (#out > 0) and out or false
return figCache[tilesetId] or nil
end
+-- Hand-authored MOUNTED objects for one tileset: a thing drawn INTO the
+-- wall band it hangs on, cut out by an explicit pixel mask and stood
+-- proud of the wall's face.
+--
+-- Same authoring problem as `figures` and the same answer -- a class pin
+-- resolves a whole 8x8 tile, and the detector cannot segment a drawing
+-- that has no background margin to flood from. The Bike Shop's two wall
+-- bicycles are the case: the shop's striped wall panel runs BEHIND them,
+-- and its #555 stripes are a flood boundary, so a silhouette flood comes
+-- back with the stripes attached to the bike.
+--
+-- Two things differ from a figure, and both follow from the object being
+-- an object rather than a character:
+--
+-- it keeps its DRAWN ELEVATION. A figure stands on its own feet; a
+-- mounted thing sits where the wall band draws it, so a bicycle hung
+-- clear of the floor stays hung.
+-- it has THICKNESS (`depth`, default 2), and it is built in world
+-- space as a per-pixel slab jutting south of the band -- not as a
+-- camera-facing sprite card. A bicycle drawn side-on is a plane
+-- parallel to the wall, not a face-on icon.
+--
+-- mounted = { { w = <tiles across>,
+-- depth = <voxels it juts into the room>,
+-- tiles = { ...w*h tile ids, row-major... },
+-- under = { ...w*h ids: what each tile wears once the
+-- object is lifted off it (the plain panel)... },
+-- pixels = { ...h*8 strings of w*8 chars, "." = wall... } } }
+function TileShape.mounted(tilesetId)
+ local hit = mntCache[tilesetId]
+ if hit ~= nil then return hit or nil end
+
+ local s = load()
+ local entry = s and s.tilesets and s.tilesets[tilesetId]
+ local out = authoredMasks(entry and entry.mounted)
+
+ mntCache[tilesetId] = (#out > 0) and out or false
+ return mntCache[tilesetId] or nil
+end
+
-- Which GB shades count as BACKGROUND for a pinned per-pixel prop, per tile
-- (a tileset entry's prop_bg). Returns tile id -> set of shade names, or nil.
--
@@ -564,12 +683,30 @@ function TileShape.bookcaseBackfill(tilesetId)
return mode == "above" and mode or nil
end
+--- Does this tileset's `bookcase` run carry the measured pane RELIEF on
+--- its front (a tileset entry's bookcase_relief)? Default yes: the class
+--- almost always collapses a shelf, a rack or a display case, and every
+--- one of those seals its contents behind a frame that should stand proud
+--- of them.
+---
+--- A tileset says `bookcase_relief = false` when it borrows the collapse
+--- for something that is NOT a shelf -- the League's gate walls and
+--- pilasters, Bill's transporter drums -- where the drawing's light
+--- regions are the masonry and the barrel, not panes, and sinking them
+--- carves the surface instead of describing it.
+function TileShape.bookcaseRelief(tilesetId)
+ local s = load()
+ local entry = s and s.tilesets and s.tilesets[tilesetId]
+ return not (entry and entry.bookcase_relief == false)
+end
+
-- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset
-- record needs the next lookup to re-resolve (hot reload, mod toggle).
function TileShape.invalidate()
spec = nil
cache = {}
figCache = {}
+ mntCache = {}
bgCache = {}
end
diff --git a/lib/Water.lua b/lib/Water.lua
index db1db8e..4ab4022 100644
--- a/lib/Water.lua
+++ b/lib/Water.lua
@@ -381,9 +381,32 @@ Water.EDGE_FADE = 0.14 -- reflection eased off over this much of the fra
--
-- The scene shader's own vertex path, plus the world position the geometry
-- was actually DRAWN at -- after the world curve, because that is the space
--- the depth buffer holds and therefore the space the march has to walk in.
--- (The curve only ever moves Y, so a fragment's world XZ is the same on both
--- sides of it and the ripple can be measured off this one too.)
+-- the surface the eye MEETS lives in: which wave column a screen pixel is
+-- looking at is a question about the geometry as drawn, and relief() answers
+-- it there. (The curve only ever moves Y, so a fragment's world XZ is the
+-- same on both sides of it and the ripple can be measured off this one too.)
+--
+-- WHAT IT REFLECTS is worked out on the other side of the bend, in the FLAT
+-- world, and this is the same rule the rest of the mode keeps: the curve
+-- tips the world away and the things standing on it do not lean with it (see
+-- WorldCurve -- buildings stay upright, shadows are resolved before the bend
+-- and ride along). A lake is one of those things. Reflect off the bowl the
+-- bend has made instead and the far half of a pond is a mirror tilted twenty
+-- degrees: it throws the ray past the vertical, where the sky ramp's own
+-- measure -- a screen row, through the frame's matrix -- swings from one end
+-- of the ramp to the other across a single column, and the pond comes out
+-- with hard-edged patches of the wrong sky stamped into it -- the overhead
+-- band and the horizon band abutting in the middle of a lake, which reads as
+-- something other than water showing through. The same tilt sends the
+-- screen-space march grazing along the bank instead of over it, which is the
+-- other half: the dock and the roofs smeared across the harbour.
+--
+-- So the reflection is taken with the flat view ray about the flat normal,
+-- exactly as it would be with the curve off -- and the MARCH still has to
+-- walk the world as drawn, because that is what the depth buffer holds. Both
+-- at once: the ray is straight in the flat world, and project() bends each
+-- sample on its way to the screen, which is the same displacement the vertex
+-- stage applies and therefore lands in the same place the geometry did.
local SHADER_SRC = [[
varying float vShade;
varying vec3 vSun;
@@ -435,6 +458,20 @@ uniform vec3 eye;
uniform vec2 screen; // the canvas, in pixels
uniform float cell; // one diorama pixel, in canvas pixels
uniform float pxAngle; // radians of view one screen pixel subtends
+// The same bend the vertex stage applied. This stage has to undo it to get
+// back to the flat world it reflects in, and re-apply it on every marched
+// sample to get back to the screen. Declared in both stages, like `vp`, and
+// both are highp here.
+uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off
+
+// How far the bend has pushed the world down at world XZ `q` -- the vertex
+// stage's own displacement, as a number this stage can add and subtract.
+// Zero when the curve is off, which is the shader's "skip it" everywhere.
+float bendDrop(vec2 q) {
+ if (curve.z <= 0.0) return 0.0;
+ vec2 d = q - curve.xy;
+ return dot(d, d) * curve.z;
+}
// the sun's own pass, exactly as the scene shader reads it
uniform Image sunMap;
@@ -658,10 +695,14 @@ vec3 bodyAt(vec3 d, vec3 c, float parity) {
// clip-space Y flip is already baked into `vp`, and a canvas texture's v runs
// the same way its pixel rows do, so one 0.5x+0.5 answers for both.
//
-// This is why the march walks in the world as DRAWN rather than as authored:
-// the depth buffer holds the curved world, so a straight line in that space
-// is the ray, and a straight line in the flat one would bend through it.
+// The point arrives in the FLAT world -- the space the ray is straight in --
+// and is bent here, by the same displacement the vertex stage applied, so it
+// lands exactly where the geometry it is being compared against landed. That
+// split is the whole trick: the reflection is worked out in a world that has
+// not been tipped, and every sample of it is tipped on the way to the screen,
+// so the march reads the depth buffer it actually has.
vec4 project(vec3 p) {
+ p.y -= bendDrop(p.xz);
vec4 c = vp * vec4(p, 1.0);
if (c.w <= 1e-6) return vec4(0.0, 0.0, 0.0, 0.0);
return vec4(c.xy / c.w * 0.5 + 0.5, c.z / c.w * 0.5 + 0.5, 1.0);
@@ -925,12 +966,23 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
// drawn at, with no smooth shading anywhere across it. (The depth test
// above is the one thing that stays per fragment: that is the hardware's
// own question and it is asked in screen space.)
- vec3 view = normalize(vBent - eye);
+ //
+ // Answered on the FLAT sheet, which is where the bars are a slab of even
+ // thickness over a level plane -- the one thing relief() is built on. The
+ // bend translates every bar straight down by its own column's drop, so the
+ // field keeps its shape and only its height moves; undo that here and the
+ // walk is the walk it was written for. Try it in the world as DRAWN
+ // instead and the slab is a bowl: the backward step up the ray climbs the
+ // bowl's near side as fast as it climbs out of the water, the walk starts
+ // inside the sheet, and it hands back a column a pixel or three off -- per
+ // fragment, differently, which is a patch of noise rather than parallax.
+ vec3 sheet = vec3(vBent.x, vBent.y + bendDrop(vBent.xz), vBent.z);
+ vec3 view = normalize(sheet - eye);
vec3 hit;
vec2 col;
float face;
float axis;
- relief(vBent, view, hit, col, face, axis);
+ relief(sheet, view, hit, col, face, axis);
// and the bar's centre, so a column is sampled and reflected from one
// place rather than from wherever inside it the fragment happened to land
vec3 surf = vec3(col.x + 0.5, hit.y, col.y + 0.5);
@@ -1001,7 +1053,7 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
vec3 rgb = mix(base, refl, clamp(f, 0.0, 1.0));
#ifdef VOXEL_GRID
- rgb *= 1.0 - gridDark * columnSeam(hit, vBent, axis);
+ rgb *= 1.0 - gridDark * columnSeam(hit, sheet, axis);
#endif
return vec4(rgb, 1.0) * color;
}
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 0ca9283..2fb9094 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -1800,9 +1800,21 @@ T.check(plain:find("waveNormal(vec2 q, float tilt)", 1, true) ~= nil
and plain:find("waveNormal(col,", 1, true) ~= nil,
"still one answer per column, so the surface stays pixel-quantised in "
.. "space while the value it reflects with is continuous")
-T.check(plain:find("relief(vBent, view, hit, col, face, axis)", 1, true) ~= nil,
+T.check(plain:find("relief(sheet, view, hit, col, face, axis)", 1, true) ~= nil,
"and the visible column is found by walking the view ray through the "
.. "slab, which is what makes a tall bar hide the short ones behind it")
+-- ...over the FLAT sheet, which is the one thing that walk is built on: an
+-- even slab over a level plane. The world curve drops each bar straight down
+-- by its own column's drop, so undoing that drop hands relief() the field it
+-- was written for. Walked in the world as DRAWN instead, the slab is a bowl:
+-- the backward step up the ray climbs the bowl's near side as fast as it
+-- climbs out of the water, the walk starts inside the sheet, and it returns a
+-- column a pixel or three off -- differently per fragment, which is a
+-- hard-edged patch of noise in the middle of a pond.
+T.check(plain:find("vec3 sheet = vec3(vBent.x, vBent.y + bendDrop(vBent.xz), vBent.z)",
+ 1, true) ~= nil,
+ "and it walks the sheet the mesh was AUTHORED as, the bend taken back off, "
+ .. "because a slab walk over a bowl starts inside the water")
-- the march's reach grows as one over the ray's descent, so a grazing camera
-- asks for hundreds of world pixels of it from a fixed number of samples --
-- which stepped over whole crests and smeared the surface into streaks
@@ -1830,7 +1842,7 @@ T.check(plain:find("waveUV(tc, col)", 1, true) ~= nil,
"and the column is what is handed to it")
-- the wireframe is ruled on the COLUMNS, not on the flat sheet they stand on
-T.check(gridded:find("columnSeam(hit, vBent, axis)", 1, true) ~= nil,
+T.check(gridded:find("columnSeam(hit, sheet, axis)", 1, true) ~= nil,
"with V-GRID on, the seams outline the column the ray landed on -- every "
.. "voxel of water its own block -- rather than ruling a grid across the "
.. "flat quad underneath and ignoring the bars entirely")
@@ -1839,6 +1851,25 @@ T.check(gridded:find("vec3 w = fwidth(base);", 1, true) ~= nil,
.. "between neighbouring fragments and its own derivative is a step")
T.check(plain:find("march(surf, r)", 1, true) ~= nil,
"the reflection marches from that column, not from the raw fragment")
+-- The two halves of the world curve, and they pull opposite ways. WHAT the
+-- lake reflects is worked out FLAT -- the same rule the rest of the mode
+-- keeps, that the world tips away and the things standing on it do not lean
+-- with it. Reflect off the bowl the bend has made instead and the far half
+-- of a pond is a mirror tilted twenty degrees, throwing the ray past the
+-- vertical, where the sky ramp's own measure (a screen row, through the
+-- frame's matrix) swings from one end of the ramp to the other across a
+-- single column and stamps hard-edged patches of the wrong sky into the
+-- water. But WHERE it lands has to be found in the world as DRAWN, because
+-- that is what the depth buffer holds -- so the ray stays straight in the
+-- flat world and every sample of it is bent on the way to the screen, by the
+-- vertex stage's own displacement.
+T.check(plain:find("p.y -= bendDrop(p.xz);", 1, true) ~= nil,
+ "and every marched sample is bent into the world as DRAWN before it is "
+ .. "projected, because that is the world the depth buffer holds")
+T.check(plain:find("vec3 r = reflect(view, n);", 1, true) ~= nil
+ and plain:find("reflect(view, vec3(0.0, 1.0, 0.0))", 1, true) ~= nil,
+ "while the reflection itself is taken about the FLAT normal, so a curved "
+ .. "world does not tip the lake the way it does not lean the buildings")
T.check(plain:find("mod(col.x + col.y, 2.0)", 1, true) ~= nil,
"and the dither's checkerboard is cut from the columns too, so a camera "
.. "pan slides the world through nothing")
diff --git a/tests/voxel_figure_test.lua b/tests/voxel_figure_test.lua
index 6f8e529..66e193e 100644
--- a/tests/voxel_figure_test.lua
+++ b/tests/voxel_figure_test.lua
@@ -214,6 +214,172 @@ Structures.buildFigures(twice, map, 0, 3, 8, 11)
T.eq(#twice.figures, 1,
"the repaint replaces the pattern, so a rescan cannot match it again")
+-- ------- a figure with a DEPTH is an object, not a card
+--
+-- The Marts' cash register: the same authored-mask escape, but a machine
+-- set down on a counter is a box seen from the front rather than a
+-- face-on icon, so it builds as a per-pixel solid. Driven over a
+-- synthetic copy of the counter's east arm, as all nine maps on the MART
+-- id draw it at cell (1,5):
+--
+-- y=9 16 41 the work surface north of it
+-- y=10 14 15 the register: keypad and receipt curl
+-- y=11 30 31
+-- y=12 16 41 the work surface it stands on
+
+T.check(TileShape.figures("POKECENTER")[1].depth == nil,
+ "the seated man states no depth -- he stays a flat sprite card")
+
+local regs = TileShape.figures("MART")
+T.check(type(regs) == "table" and #regs == 1,
+ "MART carries exactly one figure")
+local reg = regs[1]
+T.eq(reg.w, 2, "the register is two tiles across")
+T.eq(reg.h, 2, "and two tall")
+T.eq(reg.n, 150, "the mask claims 150 pixels of the 256 it spans")
+T.eq(reg.depth, 12, "its body is 12 voxels deep -- three quarters of the cell")
+T.check(reg.thin and reg.thin.rows == 4 and reg.thin.depth == 2,
+ "the four rows above its drawn top edge are 2-voxel paper")
+T.check(reg.flat and reg.flat.x0 == 2 and reg.flat.x1 == 8
+ and reg.flat.r0 == 4 and reg.flat.r1 == 11,
+ "and the keypad is a TOP-VIEW rect, not a face")
+
+local MART_ROWS = { [9] = { 16, 41 }, [10] = { 14, 15 },
+ [11] = { 30, 31 }, [12] = { 16, 41 } }
+local martS = { shapeAt = {}, tileAt = {}, figures = {}, skip = {},
+ ground = {}, runs = {}, objectQuads = {} }
+for ty, row in pairs(MART_ROWS) do
+ for i, tile in ipairs(row) do
+ martS.tileAt[keyOf(1 + i, ty)] = tile
+ martS.shapeAt[keyOf(1 + i, ty)] = COUNTER
+ end
+end
+local martMap = {
+ tileset = { id = "MART", tilesPerRow = 16,
+ imageWidth = 128, imageHeight = 48 },
+ isWalkableCell = function() return false end,
+}
+Structures.buildFigures(martS, martMap, 2, 3, 9, 12)
+
+T.eq(#martS.figures, 0, "no card was built -- it is a solid")
+T.eq(#martS.objectQuads, 351,
+ "and it landed in the standee channel as 351 quads")
+T.eq(martS.tileAt[keyOf(2, 10)], 16,
+ "its tiles wear the plain work surface now")
+T.eq(martS.tileAt[keyOf(3, 11)], 41, "all four of them")
+T.eq(martS.shapeAt[keyOf(2, 10)].class, "counter",
+ "and keep the counter box the machine stands on")
+
+local rx0, rx1, ry0, ry1, rz0, rz1
+for _, q in ipairs(martS.objectQuads) do
+ for c = 1, 4 do
+ local p = q[c]
+ rx0 = math.min(rx0 or p[1], p[1]); rx1 = math.max(rx1 or p[1], p[1])
+ ry0 = math.min(ry0 or p[2], p[2]); ry1 = math.max(ry1 or p[2], p[2])
+ rz0 = math.min(rz0 or p[3], p[3]); rz1 = math.max(rz1 or p[3], p[3])
+ end
+end
+T.eq(ry0, 8, "it stands ON the counter's 8px top plane, not the floor")
+T.eq(ry1, 24, "and is its drawn 16px tall")
+T.eq(rx0, 18, "west edge at the mask's column 2")
+T.eq(rx1, 30, "east edge at column 13, inside its own cell (16..32)")
+T.eq(rz1, 96, "its FRONT is the cell's own front edge, where it is drawn")
+T.eq(rz0, 84, "and it grows north from there, 4 short of the cell's back")
+
+-- the two thicknesses: the body at 8, the receipt curl at 2, the curl
+-- centred in the body's own band rather than flush with its front
+local bands = {}
+for _, q in ipairs(martS.objectQuads) do
+ for c = 1, 4 do bands[q[c][3]] = true end
+end
+for _, z in ipairs({ 84, 89, 91, 96 }) do
+ T.check(bands[z], "the model has a face at z = " .. z)
+end
+local curl = {}
+for _, q in ipairs(martS.objectQuads) do
+ local lo = math.min(q[1][2], q[2][2], q[3][2], q[4][2])
+ if lo >= 21 then for c = 1, 4 do curl[q[c][3]] = true end end
+end
+T.check(curl[89] and curl[91] and not curl[84] and not curl[96],
+ "clear of the arm's top only the 2-voxel paper band exists")
+
+-- THE L. The base band (drawn rows 12-15) stands 4 above the counter and
+-- the keypad lies on it as a horizontal plate, so the whole machine is
+-- exactly three surfaces: a foot, an arm, and a deck in the notch.
+local plate, deckTop = {}, 0
+for _, q in ipairs(martS.objectQuads) do
+ local flatQuad = q[1][2] == q[2][2] and q[2][2] == q[3][2]
+ and q[3][2] == q[4][2]
+ if flatQuad and q[1][2] == 13 then
+ plate[#plate + 1] = q
+ elseif flatQuad and q[1][2] == 12 then
+ deckTop = deckTop + 1
+ end
+end
+T.eq(#plate, 83,
+ "the keypad lies FLAT: one top quad per masked voxel of the deck")
+T.eq(deckTop, 7,
+ "on the base band's own top, which is 4 voxels up (drawn rows 12-15)")
+local dz0, dz1
+for _, q in ipairs(martS.objectQuads) do
+ if q[1][2] == 12 and q[3][2] == 12 then
+ for c = 1, 4 do
+ dz0 = math.min(dz0 or q[c][3], q[c][3])
+ dz1 = math.max(dz1 or q[c][3], q[c][3])
+ end
+ end
+end
+T.eq(dz0, 84, "and that deck runs the body's whole depth")
+T.eq(dz1, 96, "-- plain behind the panel, covered by it in front")
+
+local px0, px1, pz0, pz1
+for _, q in ipairs(plate) do
+ for c = 1, 4 do
+ px0 = math.min(px0 or q[c][1], q[c][1]); px1 = math.max(px1 or q[c][1], q[c][1])
+ pz0 = math.min(pz0 or q[c][3], q[c][3]); pz1 = math.max(pz1 or q[c][3], q[c][3])
+ end
+end
+T.eq(px0, 18, "the deck spans the mask's columns 2..8")
+T.eq(px1, 25, "-- the keypad panel and its own black rim")
+T.eq(pz1, 96, "the deck reaches the body's front edge")
+T.eq(pz0, 84, "and its back -- 8 drawn rows STRETCHED over 12 voxels")
+
+-- the stretch is by whole voxels, centre-sampled: 8 drawn rows over 12
+-- voxels of deck doubles every second one and blurs nothing
+local perRow16, atlasH16 = 16, 48
+local depthRow = {}
+for _, q in ipairs(plate) do
+ local z = math.min(q[1][3], q[2][3], q[3][3], q[4][3])
+ depthRow[z] = math.floor(q.v * atlasH16)
+end
+local seen = {}
+for z = 84, 95 do
+ T.check(depthRow[z] ~= nil, "deck voxel at z = " .. z .. " wears a texel")
+ seen[depthRow[z]] = (seen[depthRow[z]] or 0) + 1
+end
+T.eq(depthRow[95], 11, "the front voxel wears the keypad's own bottom rim")
+T.eq(depthRow[84], 4, "the back one wears its top rim")
+local doubled = 0
+for _, n in pairs(seen) do
+ T.check(n == 1 or n == 2, "no drawn row spreads over more than two voxels")
+ if n == 2 then doubled = doubled + 1 end
+end
+T.eq(doubled, 4, "exactly four of the eight rows double -- 8 into 12")
+
+
+-- and the arm still stands its drawn 8 rows above that deck, carrying
+-- the paper: nothing in the notch reaches higher than the plate
+local armTop, notchTop = 0, 0
+for _, q in ipairs(martS.objectQuads) do
+ for c = 1, 4 do
+ if q[c][1] >= 25 then armTop = math.max(armTop, q[c][2])
+ elseif q[c][1] <= 24 then notchTop = math.max(notchTop, q[c][2]) end
+ end
+end
+T.eq(armTop, 24, "the arm and its receipt curl reach the drawn 16px")
+T.eq(notchTop, 23,
+ "and west of it only the keys (13) and the paper overhanging them")
+
-- ------- prop_bg: the shades a pinned prop treats as background
--
-- The potted plants needed this: their pot's olive base is drawn flush on
diff --git a/tools/building_voxels.py b/tools/building_voxels.py
index 1ca5fd2..a73fa9d 100644
--- a/tools/building_voxels.py
+++ b/tools/building_voxels.py
@@ -25,6 +25,7 @@ TILESETS = os.path.join(ROOT, "assets/generated/tilesets")
PER_ROW = 16
WHITE, GREY, DARK, BLACK = 0, 1, 2, 3 # by luminance, light first
+SHADE_NAMES = dict(white=WHITE, grey=GREY, dark=DARK, black=BLACK)
# --- the shape profile, mirroring data/voxel_heights.lua's `buildings` ------
TEMPLATES = {
@@ -614,6 +615,156 @@ TEMPLATES = {
roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13),
slab=3, front_eave=0, ledge=None, tileset="gym", depth=2,
),
+ # F05: the tall display cabinet, the one with the trophy behind its
+ # glass -- CELADON_CHIEF_HOUSE cells 3,0 and 4,0 and
+ # CELADON_MANSION_1F cell 2,2. Its 32 rows read like every band
+ # table's: 0-8 the cabinet top seen from above (black rim, white
+ # highlight along the north and west, grey field, the front corner
+ # shaded at row 8), row 9 the top's own black front edge -- so
+ # slab = 1 and that row folds into the rim treatment -- and 10-31
+ # the front face: the trophy in its dark display recess, the
+ # nameplate under it, then the two panelled doors of the base.
+ # Nine drawn top rows over a 32px plot, so the rims map 1:1 (the
+ # drawn front-corner shading lands one voxel behind the front edge)
+ # and the uniform field cycles between. Both cells of the plot are
+ # blocked and both are cabinet drawing, so D is the whole grid --
+ # the rank is built into the room's north wall.
+ "mansion_trophy_case": dict(
+ tiles=[
+ [38, 41],
+ [40, 21],
+ [56, 87],
+ [50, 51],
+ ],
+ roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7),
+ slab=1, front_eave=0, ledge=None, tileset="mansion",
+ ),
+ # F06: the short book case standing beside it -- the same cabinet on
+ # a grid one tile row shorter (CELADON_CHIEF_HOUSE cells 2,0 and
+ # 5,0, CELADON_MANSION_1F tiles 2,5 and 6,5). Identical top band and
+ # identical base; only the display opening is shorter, a shelf of
+ # books instead of the trophy. Same band numbers as F05, which is
+ # what makes the two read as one line of furniture: 15 voxels tall
+ # against the tall one's 23. The 8px of wall behind it is not part
+ # of the drawing and keeps its own `wall` pin.
+ "mansion_bookcase": dict(
+ tiles=[
+ [38, 41],
+ [34, 35],
+ [50, 51],
+ ],
+ roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7),
+ slab=1, front_eave=0, ledge=None, tileset="mansion",
+ ),
+ # F07: the long table in the middle of the chief's house
+ # (CELADON_CHIEF_HOUSE cell 2,3), the lab table's read at four
+ # cells wide and two deep. Rows 0-23 are the tabletop seen from
+ # above; 24-26 are the top slab's own front edge, black/#555/black
+ # -- exactly what the rim treatment paints -- and row 27 is the
+ # #555 shadow that closes it, so slab = 3 and rows 24-27 fold into
+ # the roof band instead of extruding under it. That leaves rows
+ # 28-30 as the base, the same three the lab table's is, and the two
+ # tables stand the same 6 voxels: row 28 the apron running the whole
+ # width, 29-30 the end legs under it. The legs stop one row short of
+ # the grid, so the measured ground line lands there and the model
+ # does not float. Both cell rows of the plot are blocked, so D is
+ # the whole grid: 24 drawn top rows map 1:1 from the north rim and
+ # the field's own rows cycle for the last 8.
+ "mansion_long_table": dict(
+ tiles=[
+ [38, 39, 39, 39, 39, 39, 39, 41],
+ [54, 55, 55, 55, 55, 55, 55, 57],
+ [54, 55, 55, 55, 55, 55, 55, 57],
+ [60, 58, 58, 58, 58, 58, 58, 59],
+ ],
+ roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23),
+ slab=3, front_eave=0, ledge=None, tileset="mansion",
+ ),
+ # F08: the dining table of the generic town house -- 18 placements,
+ # every home's cells (3,3):(4,4) -- the chief's long table (F07) at
+ # two cells wide. The same read to the row: 0-23 the rounded
+ # tabletop seen from above (black rim, white highlight course, grey
+ # field, #555 east rim), 24-26 the slab's own black/#555/black
+ # front edge and 27 the #555 apron shadow that closes it, folded
+ # into the band, then 28-30 the base -- the apron's black underrun
+ # and the corner legs, stopping one row short of the grid so the
+ # measured ground line keeps the model on its plot. The same 6
+ # voxels the whole table family stands.
+ "house_table": dict(
+ tiles=[
+ [38, 39, 39, 41],
+ [54, 47, 47, 57],
+ [54, 47, 47, 57],
+ [60, 58, 58, 59],
+ ],
+ roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23),
+ slab=3, front_eave=0, ledge=None, tileset="house",
+ ),
+ # F08 again: Red's and the Copycat's ground-floor dining table --
+ # the same drawing on the reds_house atlas, with two differences
+ # the numbers absorb. Its tabletop field stops at row 22 and row