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. + -- 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). -- - -- 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" } }, - }, + -- 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 + 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 + put(sx, plane - 1, z, sy * W + px) 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) - 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 + 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 + -- 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 - 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 + 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, 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 + -- a FLAT-BASED hull (the can): the bottom baseRows rows of the mask are + -- the BASE circle's front arc -- the drawing's mirror of the cut face + -- above, ground contact seen from above rather than body. A can is only + -- round in the horizontal plane, so the drop those rows make toward the + -- middle is DEPTH, not a narrowing of the plan: left as body they revolve + -- into ever smaller discs and the can ends up balanced on a stem three + -- voxels wide (which is exactly what the first build did). Strip them and + -- the foot rule below runs the last body row's full disc straight to the + -- floor; the rows keep their own texels there, so the front view is still + -- the drawing, base rim and all. + local baseArt = nil + if baseRows and baseRows > 0 then + local bot = nil + for iy = NY - 1, 0, -1 do + for ix = 0, NX - 1 do + if mask[iy * NX + ix] then bot = iy break end + end + if bot then break end + end + if bot then + baseArt = {} + for iy = math.max(bot - baseRows + 1, (capY1 or -1) + 2), bot do + for ix = 0, NX - 1 do + local i = iy * NX + ix + if mask[i] then baseArt[i] = true end + mask[i] = nil + end + end + any = nil + for i = 0, NX * NY - 1 do any = any or mask[i] end + if not any then return {} end + end + end + + -- The can's HEIGHT, and the one place this file departs from the drawing + -- on purpose. Strictly un-projected, the drawing states a squat drum: cut + -- the mouth ellipse off the top and the base circle off the bottom and + -- barely two rows of straight side are left between them, because the GB + -- artist spent most of a 16px cell on the opening. A real bin is TALLER + -- than it is wide, and the flat game reads as one because the drawing is + -- 14px tall next to a 16px player -- so the height is authored (can_height + -- voxels) rather than measured, and the surviving body band is repeated + -- upward to fill it, bottom row first, which continues the drawn rib + -- rhythm instead of inventing a texel. Everything else still comes off + -- the pixels. + local artRow = {} + if bodyRows and bodyRows > 0 then + local body = {} + for iy = 0, NY - 1 do + for ix = 0, NX - 1 do + if mask[iy * NX + ix] then body[#body + 1] = iy break end + end + end + local nb = #body + if nb > 0 then + local top = body[1] + for iy = top - 1, math.max(NY - bodyRows, 0), -1 do + -- the LOWEST surviving body row, repeated: it is the widest and + -- plainest reading of the material (outline, shaded flank, lit + -- face) and stacks into a clean metal cylinder. Cycling the whole + -- surviving band instead stacks the drawn rim arcs into a barcode + -- of hoops, which is detail the drawing never states about the + -- side of the can. + local from = body[nb] + artRow[iy] = from + for ix = 0, NX - 1 do + mask[iy * NX + ix] = mask[from * NX + ix] + end + end + end + end + -- the ground the ball stands on: the drawing's own background names -- it. Score every flat ground tile the map places against the cell's -- unmasked light pixels and keep the closest -- mid-forest trees have @@ -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 } -local function bookcaseRank(S, map, tx, northTy, frontTy, capTile) +-- A pane is a shelf opening, a glass door or an inset panel: a non-black +-- region the drawing SEALS OFF behind its own black frame. Anything +-- wider or taller than this is a band of the front itself -- a trim +-- course, a plinth -- and stays flush. The same number and the same +-- rule lib/Buildings.lua measures a facade's panes with, so a shelf the +-- band pipeline models and a shelf this class collapses carry the same +-- relief. +local BOOK_RECESS_MAX = 24 + +-- The panes of a BANK of ranks -- every rank of the same height standing +-- side by side -- as a mask over the bank's south face, plus the atlas +-- pixel each face texel comes from. Measured over the whole bank rather +-- than per column, because a door panel drawn across two tiles is one +-- region and not two halves, and because the size test that keeps a +-- broad course flush has to see the course's real width. +-- +-- `fx` runs across the bank and `fy` DOWN from its top, so the grid +-- reads like the drawing: the rank folds its tiles up band by band, the +-- southmost row lowest, and fy = 0 is the topmost drawn row. +local function bookcasePanes(map, data, perRow, run, i, j) + if not data then return nil end + local bands = run[i].bands + local size = run[i].front - run[i].top + 1 + local W, H = (j - i + 1) * 8, bands * 8 + local light, srcU, srcV = {}, {}, {} + for fy = 0, H - 1 do + local band = bands - 1 - math.floor(fy / 8) + local row = fy % 8 + for fx = 0, W - 1 do + local col = run[i + math.floor(fx / 8)] + local tile = band < size and map:tileAt(col.tx, col.front - band) + or col.cap + if tile then + local k = fy * W + fx + local ax = (tile % perRow) * 8 + fx % 8 + local ay = math.floor(tile / perRow) * 8 + row + srcU[k], srcV[k] = ax, ay + local r, g, b, a = data:getPixel(ax, ay) + light[k] = a ~= 0 + and Structures.shadeClass(math.min(r, g, b)) ~= "black" + end + end + end + + -- The drawing's non-black regions, split across its black frames. A + -- region that reaches the face's own border is not sealed by anything + -- -- it is a course of the front running edge to edge, the way a + -- masonry band or a wall of siding does -- and it stays flush. That + -- test is what keeps this rule to shelves: `bookcase` also collapses + -- the League's gate walls and the terraces, and their courses run off + -- the drawing, so nothing there sinks. + local pane, seen = {}, {} + for k0 = 0, W * H - 1 do + if light[k0] and not seen[k0] then + local cells, stack = {}, { k0 } + seen[k0] = true + local ax0, ax1 = k0 % W, k0 % W + local ay0, ay1 = math.floor(k0 / W), math.floor(k0 / W) + local edge = false + while #stack > 0 do + local k = table.remove(stack) + cells[#cells + 1] = k + local cx, cy = k % W, math.floor(k / W) + if cx < ax0 then ax0 = cx end + if cx > ax1 then ax1 = cx end + if cy < ay0 then ay0 = cy end + if cy > ay1 then ay1 = cy end + if cx == 0 or cx == W - 1 or cy == 0 or cy == H - 1 then + edge = true + end + for _, d in ipairs(DIRS4) do + local nx, ny = cx + d[1], cy + d[2] + if nx >= 0 and nx < W and ny >= 0 and ny < H then + local nk = ny * W + nx + if light[nk] and not seen[nk] then + seen[nk] = true + stack[#stack + 1] = nk + end + end + end + end + if not edge and ax1 - ax0 < BOOK_RECESS_MAX + and ay1 - ay0 < BOOK_RECESS_MAX then + for _, k in ipairs(cells) do pane[k] = true end + end + end + end + return pane, srcU, srcV, W, H +end + +local function bookcaseRank(S, map, perRow, run, i, j, k, pane, srcU, srcV, + bankW, bankH) + local r = run[k] + local tx, northTy, frontTy, capTile = r.tx, r.top, r.front, r.cap local quads = S.objectQuads - local 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 = , +-- depth = , +-- thin = { rows = , depth = }, +-- flat = { x = { , }, rows = { , } }, -- 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. +-- +-- `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 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 + 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 + 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 list = entry and entry.figures - 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 - 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 - end - if n > 0 then - out[#out + 1] = { w = w, h = h, n = n, mask = mask, - tiles = f.tiles, under = f.under } - end - end - end - end + 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 = , +-- depth = , +-- 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 23 + # is the top's own #555 south rim, which back = 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 black, like every sibling's). And the POTTED + # PLANT is drawn standing on the tabletop (rows 5-15, x10..x21): it + # stays the cutout standee it has always been -- the template + # scrubs its pixels to the field shade and leaves its tiles + # unclaimed (the Lua entry's `keep`), so the model tops out as the + # plain surface the plant sits on. + "reds_house_table": dict( + tiles=[ + [38, 39, 40, 41], + [54, 55, 56, 57], + [44, 42, 42, 43], + [60, 58, 58, 59], + ], + roof_rows=28, roof_back=23, roof_front=1, roof_cycle=(2, 22), + slab=3, front_eave=0, ledge=None, tileset="reds_house", + scrub=[(10, 5, 21, 15)], + ), + # F09: the stool at every one of those tables -- 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 the seat band STRETCHED over the + # deeper lid. `panes = False`: a stool has no windows, and the pane + # rule would sink the legs' lit faces behind their own outlines. + "house_stool": dict( + tiles=[ + [2, 3], + [18, 19], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="house", + panes=False, + parts=[ + dict(kind="upright", x=(2, 13), top=(5, 10), facade=(11, 15), + z=3, depth=11, stretch=True), # the stool + ], + ), + # F09 again on the reds_house atlas (Red's and the Copycat's ground + # floors), pixel for pixel the same drawing. + "reds_house_stool": dict( + tiles=[ + [2, 3], + [18, 19], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="reds_house", + panes=False, + parts=[ + dict(kind="upright", x=(2, 13), top=(5, 10), facade=(11, 15), + z=3, depth=11, stretch=True), # the stool + ], + ), # F04: the Pokemon Center's PC -- every Center's northeast corner # (11 placements) plus the Indigo Plateau lobby, whose MART tileset # shares this atlas. The lab desk-set read again: a @@ -639,6 +790,247 @@ TEMPLATES = { dict(kind="flat", x=(2, 13), rows=(17, 19), z=13), # keyboard ], ), + # F05: the healing machine behind every Pokemon Center counter -- + # two 4x4-tile variants, 24 placements between them (a pair in all + # eleven Centers plus the Indigo Plateau lobby's pair, whose MART + # tileset shares this atlas). The variants differ only in the east + # flank: the west machine has the control keyboard there (7/13, + # scan "40,58,59,40;40,74,75,40;72,76,77,7;72,6,22,13" -- 12 + # placements), the east machine mirrored hoses (73, scan + # "...;72,76,77,73;72,6,22,73" -- 12). + # + # The full drawing is claimed: corners are the striped wall band + # (40), flanks the machine's own equipment. What no class can split + # is a wall-height cabinet with a monitor perched on its front top + # edge, drawn over two map rows because it towers over the 16px + # band behind it -- and hoses and a keyboard that are ATTACHED to + # it, not furniture standing on a desk plane. + # + # Read off the pixels (absolute grid x0..x31): + # rows 15-31, x8..x23 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 + # (29-31). `desk.x` bounds it to the middle columns. + # rows 6-14, x8..x23 the cabinet's TOP FACE seen from above -- + # not a second-story facade: white expanse, lit west + # strip (x9), shaded east strip (x22), wrapping the + # monitor. Its 9 rows + the front edge row 15 ARE the + # cabinet's depth: 10 (z 16..25 against the wall). + # `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, x10..x21 the MONITOR: black back rim (1) + white + # cap (2-3) seen from above -- depth 4 -- then bezel, + # screen and control strip (4-13) face-on, 10 tall, + # at the cabinet top's front (drawn base edge 13 is + # one band row up from the front strip): z 20..23. + # The screen interior (x13..x18, rows 6-8) sinks one + # voxel via `inset` -- the pane rule by hand, since + # `panes=False` blocks the global pass. + # rows 16-31, x0..x7 the HOSES (tile 72 twice): one 8-row + # motif per map row -- a horizontal run high on the + # cabinet's side (16-18, x3..x7 -> heights 13..15, + # measured off the drawn rows) and a shaded drop + # (19-23, x2..x7) that bends down INTO THE FLOOR. + # The two stacked motifs are two hoses in DEPTH (the + # drawn row band is the depth band): `box` parts at + # z 18 and z 24, 3 deep, the drop cycling its two + # shading rows (19-20) down to the floor with the + # drawn foot rows (21-23) landing at the bottom -- + # the roof-rim rule turned vertical. The second + # hose re-wears the first motif's rows: the atlas + # tile is the same, so the pixels are identical. + # rows 16-31, x24..x31 west variant: the KEYBOARD (7/13), a + # key grid with its cable at the north end and a bar + # down the east edge -- TOP-VIEW art, 16 rows = 16 + # depth rows. A `flat` part mounted on the cabinet's + # side at working height (`at` 11, authored -- the + # one number top-view art cannot state), 3 thick, + # top face wearing the drawn art. The stray dark + # dashes east of it (x30-31) are its cast shadow on + # the floor: background. East variant: mirrored + # hoses (runs 16-18 x24..x28, drops 19-23 x24..x29), + # same z slots. + # rows 0-15 elsewhere wall stripes and cast shadow: BACKGROUND. + # The `wall` element keeps the band solid over the + # back map row, full grid width -- a 16-tall, + # 16-deep block cycling the drawing's own stripe + # unit (x0, rows 0-3), exactly what the flanking + # 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. `panes=False`: the front panel + # seals DARK behind LIGHT, the opposite polarity to the pane rule. + "center_heal_machine_w": dict( + tiles=[ + [40, 58, 59, 40], + [40, 74, 75, 40], + [72, 76, 77, 7], + [72, 6, 22, 13], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=4, + panes=False, + wall=dict(h=16, depth_px=16, x=0, cycle=(0, 3)), + desk=dict(x=(8, 23), fascia=(15, 16), base=(17, 31), z=16, + depth_px=10, top=(6, 14)), + parts=[ + dict(kind="upright", x=(10, 21), top=(1, 3), facade=(4, 13), + z=20, depth=4, + inset=dict(x=(13, 18), rows=(6, 8))), # the monitor + dict(kind="box", x=(3, 7), rows=(16, 18), + z=18, depth=3), # north hose run + dict(kind="box", x=(2, 7), rows=(19, 23), cycle=(19, 20), + base=0, z=18, depth=3), # north hose drop + dict(kind="box", x=(3, 7), rows=(16, 18), + z=24, depth=3), # south hose run + dict(kind="box", x=(2, 7), rows=(19, 23), cycle=(19, 20), + base=0, z=24, depth=3), # south hose drop + dict(kind="flat", x=(24, 29), rows=(16, 31), + z=16, at=11, thick=3), # keyboard shelf + ], + ), + "center_heal_machine_e": dict( + tiles=[ + [40, 58, 59, 40], + [40, 74, 75, 40], + [72, 76, 77, 73], + [72, 6, 22, 73], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=4, + panes=False, + wall=dict(h=16, depth_px=16, x=0, cycle=(0, 3)), + desk=dict(x=(8, 23), fascia=(15, 16), base=(17, 31), z=16, + depth_px=10, top=(6, 14)), + parts=[ + dict(kind="upright", x=(10, 21), top=(1, 3), facade=(4, 13), + z=20, depth=4, + inset=dict(x=(13, 18), rows=(6, 8))), # the monitor + dict(kind="box", x=(3, 7), rows=(16, 18), + z=18, depth=3), # NW hose run + dict(kind="box", x=(2, 7), rows=(19, 23), cycle=(19, 20), + base=0, z=18, depth=3), # NW hose drop + dict(kind="box", x=(3, 7), rows=(16, 18), + z=24, depth=3), # SW hose run + dict(kind="box", x=(2, 7), rows=(19, 23), cycle=(19, 20), + base=0, z=24, depth=3), # SW hose drop + dict(kind="box", x=(24, 28), rows=(16, 18), + z=18, depth=3), # NE hose run + dict(kind="box", x=(24, 29), rows=(19, 23), cycle=(19, 20), + base=0, z=18, depth=3), # NE hose drop + dict(kind="box", x=(24, 28), rows=(16, 18), + z=24, depth=3), # SE hose run + dict(kind="box", x=(24, 29), rows=(19, 23), cycle=(19, 20), + base=0, z=24, depth=3), # SE hose drop + ], + ), + # F06: the Bike Shop's toolbox -- 2 placements, cells (6,6) and (7,7) + # of BIKE_SHOP and nowhere else in the game. + # + # A 2x2-tile drawing that packs two objects standing side by side on + # the floor, which is why no class pin reaches it: a pin resolves a + # whole 8x8 tile and the pump's columns run through both right-hand + # tiles of the toolbox's own grid. Pinned `billboard` the whole + # 16x16 went up as one 10-voxel per-pixel slab -- every black outline + # pixel a 10-deep bar, so it read as a black monolith with the + # drawing decalled on its front, and the pump came out as a wing off + # its corner. + # + # Read off the pixels: + # rows 12-15 x2..x12 the plinth: a black rim course over a light + # drawer front with its own #555 side. The + # `desk` -- 4 tall, and the first one here + # drawn NARROWER than its grid (the pump + # stands on open floor past its right end) + # rows 4-11 x2..x11 the cabinet: a #555 tool tray behind a black + # frame, its right side face (#555) drawn one + # column wide. An upright part on the plinth, + # 8 tall, wearing its drawn top edge as a lid + # rows 0-11 x10..x14 the pump: a white body in a black outline + # with a grey foot. Its own upright part, 4 + # deep and set 2 back so it stands BEHIND the + # cabinet, and `foot` because its drawn bottom + # is where the plinth crosses it, not where it + # meets the floor + # The pump is listed FIRST so the cabinet wins the two columns they + # share (x10/x11 are the cabinet's side face, drawn through it). + # depth 1 is the plot: the toolbox blocks its cell but is drawn a + # half-cell deep, so 8 leaves the walk-up row in front of it clear. + "bike_shop_toolbox": dict( + tiles=[ + [29, 13], + [21, 22], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="club", depth_px=14, + tray=dict(top=(4, 11), front=(12, 15), x=(2, 11), inner=(3, 9), + floor=0), + parts=[ + dict(kind="upright", x=(11, 14), top=(0, 0), facade=(0, 11), + z=0, depth=14), # the open lid + ], + ), + # F0x: Bill's desk, and the Silph president's -- the same drawing in + # both (BILLS_HOUSE cell 1,4 and SILPH_CO_11F cell 10,12). 16 rows of + # TABLETOP seen from above over a 16px plot, 1:1, with a terminal and + # a ball standing on it: the lab table's top surface (black rim, white + # highlight course, grey field) carrying lab-computers' objects. + # + # The grid stops at the desk's own two cells ON PURPOSE. The block + # draws the desk's apron into the WALKABLE cell in front, and the + # left half of that apron shares its tiles with the CHAIR pushed up + # to the desk (43/44 over 59/60, pinned `stool`) -- claiming those + # tiles to read the apron would take the chair's back with them. So + # the front face is the one surface here the drawing does not hand + # us inside the grid, and `plane` authors it at the 8px this desk is + # pinned to stand: the apron's own drawn row count, and table height. + "bills_desk": dict( + tiles=[ + [11, 12, 13, 14], + [27, 28, 29, 30], + [43, 44, 91, 92], + [59, 60, 31, 31], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="interior", 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=dict(fascia=(16, 18), base=(19, 23), depth=2), + parts=[ + # the notes: two sheets lying on the desk (a plain one and a + # crumpled one), so PAPER -- one voxel proud at drawn row = + # depth row, and nothing raised + dict(kind="flat", x=(2, 15), rows=(3, 7)), # the notes + # 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 + dict(kind="upright", x=(4, 15), top=(8, 12), facade=(12, 13), + z=8, depth=6), # the keyboard + # 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 + dict(kind="upright", x=(16, 19), top=(8, 8), facade=(8, 9), + z=8, depth=2), # the cord + # the computer: drawn in 2:1 isometric, turned 45 degrees to + # the map -- see the iso branch in build_parts. `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 + dict(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 + dict(kind="upright", x=(2, 13), top=(20, 21), facade=(22, 31), + rise=-8, z=22, depth=10), # the chair + ], + ), # B23: the Victory Road entrance on Route 23: a rock face with two # barred doors. The roof band is the pale cliff top seen from above. "victory_road_gate": dict( @@ -662,7 +1054,7 @@ RECESS_MAX = 24 # --------------------------------------------------------------- stage 1 -- -def sprite(tiles, seal="", tileset="overworld"): +def sprite(tiles, seal="", tileset="overworld", scrub=None): """Composite the building and read it the way Structures reads the map: palette index per pixel plus the light-only silhouette flood. @@ -719,6 +1111,26 @@ def sprite(tiles, seal="", tileset="overworld"): src = [[((tiles[y // 8][x // 8] % PER_ROW) * 8 + x % 8, (tiles[y // 8][x // 8] // PER_ROW) * 8 + y % 8) for x in range(W)] for y in range(H)] + + # `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 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 scrub: + in_rect = lambda x, y: any(x0 <= x <= x1 and y0 <= y <= y1 + for x0, y0, x1, y1 in scrub) + donor = next((x, y) for y in range(H) for x in range(W) + if col[y][x] == GREY and not out[y][x] + and not in_rect(x, y)) + for y in range(H): + for x in range(W): + if in_rect(x, y): + col[y][x] = GREY + src[y][x] = src[donor[1]][donor[0]] + out[y][x] = False return dict(W=W, H=H, col=col, out=out, src=src, pal=pal) @@ -773,11 +1185,27 @@ def profile(sp, t): if x1 - x0 + 1 <= RECESS_MAX and y1 - y0 + 1 <= RECESS_MAX: recess.update(cells) + # The pane rule reads a LIGHT region sealed 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 one voxel off the face. `panes = False` says the drawing + # does not carry the rule's polarity, so the facade stays flush. + if not t.get("panes", True): + recess = set() + # Depth is the PLOT. For a whole-drawing building that is the grid # itself; `depth` (in tile rows) names it when the grid runs past the # plot onto ground the drawing merely stands its legs on. return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess, - inside=inside, D=t.get("depth", len(t["tiles"])) * 8, + inside=inside, + # `depth` names the plot in TILE ROWS, which is the right + # grain for a building. `depth_px` names it in voxels, for + # an object whose real depth is not a whole tile row -- the + # Bike Shop toolbox is a box in the middle of its own cell, + # not a thing that fills a plot. + D=t.get("depth_px") + or t.get("depth", len(t["tiles"])) * 8, ground=ground, W=W, H=H) @@ -813,87 +1241,369 @@ def build_desk_set(sp, pr, t): return nx return sx + def build_parts(plane): + for p in t["parts"]: + x0, x1 = p["x"] + if p["kind"] == "flat": + r0, r1 = p["rows"] + # `at` names the sheet's own height when it does not lie + # on the desk plane (the healing machine's keyboard is a + # shelf mounted on the cabinet's side); `thick` gives it + # a body -- layers below the sheet repeating each + # column's own texel, the same continuation rule every + # synthesized surface follows. + at_y = p.get("at", plane) + thick = p.get("thick", 1) + for sy in range(r0, r1 + 1): + # 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) + z = p.get("z", r0) + (sy - r0) + if not 0 <= z < D: + continue + for sx in range(x0, x1 + 1): + if inside(sx, sy): + for y in range(max(0, at_y - thick + 1), at_y + 1): + put(sx, y, z, sx, sy) + continue + if p["kind"] == "box": + # A BOX part is a drawn rect standing at its own drawn + # elevation -- equipment attached to the machine rather + # than an object on the desk plane. The rows are face-on + # art: the top row's drawn height IS the box's top + # (ground - 1 - r0, measured), and the box runs down to + # `base` (default the drawn extent; 0 continues it to + # the floor, the legs-continue rule). Height beyond the + # drawn rows fills the way a roof band does: rows before + # `cycle` map 1:1 from the top, rows after it 1:1 from + # the bottom -- the healing machine hoses' foot lands ON + # the floor -- and the cycle window repeats between. + r0, r1 = p["rows"] + c0, c1 = p.get("cycle", (r1, r1)) + pz = p.get("z", 0) + pd = p["depth"] + top = pr["ground"] - 1 - r0 + bot = p.get("base", pr["ground"] - 1 - r1) + n_top, n_bot = c0 - r0, r1 - c1 + for y in range(bot, top + 1): + k, j = top - y, y - bot + if k < n_top: + sy = r0 + k + elif j < n_bot: + sy = r1 - j + else: + sy = c0 + (k - n_top) % (c1 - c0 + 1) + for sx in range(x0, x1 + 1): + if not inside(sx, sy): + continue + ix = interior(sx, sy, x0, x1) + for z in range(pz, pz + pd): + if 0 <= z < D: + put(sx, y, z, + sx if z in (pz, pz + pd - 1) else ix, sy) + continue + if p["kind"] == "iso": + # 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, so the drawn top lands on the + # top, the screen on the screen-facing side and the flank + # on the flank -- nothing 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, 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. + pr0, pr1 = p["rows"] + rx = (x1 - x0 + 1) // 2 + rz = rx // 2 + plan = p.get("plan", rz) + oy = pr1 - rz + h = oy - rz - pr0 + for sx in range(x0, x1 + 1): + # doubled, so a rect of even width keeps its centre + # between two columns instead of limping one left + dx2 = 2 * sx - (x0 + x1) + for dz in range(-plan, plan + 1): + z = p["z"] + dz + d2 = abs(dx2) * plan + 2 * abs(dz) * rx + if not (0 <= z < D and d2 <= (2 * rx + 1) * plan): + continue + # the plan row scaled back into the drawn rhombus + dzs = (2 * dz * rz + plan) // (2 * plan) + for y in range(h + 1): + sy = oy + dzs - y + if pr0 <= sy <= pr1 and inside(sx, sy): + put(sx, plane + y, z, sx, sy) + continue + tr0, tr1 = p["top"] + fr0, fr1 = p["facade"] + pd = p["depth"] + # `rise` lifts a part off the desk's top plane. An object STANDING + # on the desk starts at the plane (rise 0, every table-top part); + # the healing machine's screen head is MOUNTED on the console's + # front instead, and the drawing states where -- its bottom row is + # two above the body's top, so rise is measured, not tuned. + base = plane + p.get("rise", 0) + # ...and `z` names its back-most depth row, the same field a flat + # part carries. A part on a desk starts at the plot's back (0); + # the healing machine's console stands in the FRONT map row of a + # grid whose back row is the wall band it leans against, so it + # starts where that wall ends. + pz = p.get("z", 0) + ytp = base + (fr1 - fr0) + # `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. + ins = p.get("inset") + for sx in range(x0, x1 + 1): + # 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 in range(pz, pz + pd): + if z == pz + pd - 1: + sy = fr0 + elif p.get("stretch"): + sy = min(tr0 + ((z - pz) * (tr1 - tr0 + 1)) + // (pd - 1), tr1) + else: + sy = min(tr0 + z - pz, tr1) + while sy <= tr1 and not inside(sx, sy): + sy += 1 + if sy > tr1 and not (z == pz + pd - 1 and inside(sx, fr0)): + continue + if not 0 <= z < D: # a part lives inside the plot + continue + put(sx, ytp, z, sx, fr0 if z == pz + pd - 1 else sy) + # the body: facade rows anchored to the part's own base + for sy in range(fr0 + 1, fr1 + 1): + y = base + (fr1 - sy) + if not inside(sx, sy): + continue + ix = interior(sx, sy, x0, x1) + for z in range(pz, pz + pd): + if not 0 <= z < D: + continue + if z == pz + pd - 1: + if ins and ins["x"][0] <= sx <= ins["x"][1] \ + and ins["rows"][0] <= sy <= ins["rows"][1]: + continue + if (sx, sy) not in pr["recess"]: + put(sx, y, z, sx, sy) + else: + put(sx, y, z, sx if z == pz else ix, sy) + + # 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.get("tray"): + tr = t["tray"] + top0, top1 = tr["top"] + fr0, fr1 = tr["front"] + bx0, bx1 = tr["x"] + ix0, ix1 = tr["inner"] + floor = tr.get("floor", 0) + 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. + lo, hi = top0 + 1, top1 - 1 # the drawn inside + span = max(1, D - 3) # interior depth rows - 1 + def tray_row(z): + if z == 0: + return top0 + if z == D - 1: + return fr0 + return lo + ((z - 1) * (hi - lo)) // span + + for sx in range(bx0, bx1 + 1): + for z in range(D): + hollow = ix0 <= sx <= ix1 and 0 < z < D - 1 + for y in range(0, (floor if hollow else plane - 1) + 1): + if hollow or y == plane - 1: + # 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 + sy = tray_row(z) + if not inside(sx, sy): + continue + put(sx, y, z, sx, sy) + 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. + sy = fr1 - y + if not inside(sx, sy): + continue + px = sx if z in (0, D - 1) else interior(sx, sy, + bx0, bx1) + put(sx, y, z, px, sy) + build_parts(plane) + return vox + + # 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.get("desk"): + build_parts(0) + return vox + f0, f1 = t["desk"]["fascia"] b0, b1 = t["desk"]["base"] plane = (b1 - b0 + 1) + (f1 - f0 + 1) # the desk's top plane + # 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 (`depth_px`) 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. + desk_d = t["desk"].get("depth_px") or t["desk"].get("depth", 0) * 8 or D + dz0 = t["desk"].get("z", 0) + dz1 = dz0 + desk_d - 1 + desk_g = b1 + 1 + # ...and the desk's COLUMNS (`x`), when the grid is wider than the + # desk: the healing machine's grid carries its flanking hoses and + # keyboard, and the cabinet is only the middle 16 columns. + dx0, dx1 = t["desk"].get("x", (0, W - 1)) + + # The WALL element: the band the machine backs onto, whose tiles this + # grid claims. The drawing shows it only as the stripe background + # around the tower (the same standing as the potted plants' floor), + # so the block cycles the drawing's own stripe unit -- real pixels of + # column `x`, rows `cycle` -- at wall-band height over the back plot, + # exactly what the neighbouring cells' `wall` pins render. + if t.get("wall"): + wl = t["wall"] + c0, c1 = wl["cycle"] + cn = c1 - c0 + 1 + wx = wl.get("x", 0) + for y in range(wl["h"]): + sy = c0 + (wl["h"] - 1 - y) % cn + for sx in range(W): + for z in range(wl["depth_px"]): + put(sx, y, z, wx, sy) + # the base band, extruded exactly like every lab table's for sy in range(b0, b1 + 1): - y = ground - 1 - sy - for sx in range(W): + y = desk_g - 1 - sy + for sx in range(dx0, dx1 + 1): if not inside(sx, sy): continue - ix = interior(sx, sy, 0, W - 1) - for z in range(D): - put(sx, y, z, sx if z in (0, D - 1) else ix, sy) + ix = interior(sx, sy, dx0, dx1) + for z in range(dz0, dz1 + 1): + put(sx, y, z, sx if z in (dz0, dz1) else ix, sy) for sx, sy in pr["recess"]: - if b0 <= sy <= b1: - vox.pop((sx, ground - 1 - sy, D - 1), None) + if b0 <= sy <= b1 and dx0 <= sx <= dx1: + vox.pop((sx, desk_g - 1 - sy, dz1), None) - # the slab: the fascia rows wrap every side; the lid is the one - # synthesized surface in the model -- the drawing never paints the - # tabletop (its objects cover it), so the lid continues the sibling - # tables' pattern in the drawing's own shades: black rim, white - # highlight courses along the north and west, grey field + # the slab: the fascia rows wrap every side for i, sy in enumerate(range(f0, f1 + 1)): y = plane - 1 - i - for sx in range(W): - for z in range(D): + for sx in range(dx0, dx1 + 1): + for z in range(dz0, dz1 + 1): put(sx, y, z, sx, sy) - field = WHITE if t["desk"].get("lid") == "white" else GREY - for sx in range(W): - for z in range(D): - if sx in (0, W - 1) or z in (0, D - 1): - shade = BLACK - elif sx == 1 or z == 1: - shade = WHITE - else: - shade = field - px = shade_px.get(shade) or shade_px[BLACK] - put(sx, plane - 1, z, px[0], px[1]) - for p in t["parts"]: - x0, x1 = p["x"] - if p["kind"] == "flat": - r0, r1 = p["rows"] - for sy in range(r0, r1 + 1): - # 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) - z = p.get("z", r0) + (sy - r0) - if not 0 <= z < D: - continue - for sx in range(x0, x1 + 1): - if inside(sx, sy): - put(sx, plane, z, sx, sy) - continue - tr0, tr1 = p["top"] - fr0, fr1 = p["facade"] - pd = p["depth"] - ytp = plane + (fr1 - fr0) - for sx in range(x0, x1 + 1): - # 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 in range(pd): - sy = fr0 if z == pd - 1 else min(tr0 + z, tr1) - while sy <= tr1 and not inside(sx, sy): - sy += 1 - if sy > tr1 and not (z == pd - 1 and inside(sx, fr0)): - continue - put(sx, ytp, z, sx, fr0 if z == pd - 1 else sy) - # the body: facade rows anchored to the desk's top plane - for sy in range(fr0 + 1, fr1 + 1): - y = plane + (fr1 - sy) - if not inside(sx, sy): - continue - ix = interior(sx, sy, x0, x1) - for z in range(pd): - if z == pd - 1: - if (sx, sy) not in pr["recess"]: - put(sx, y, z, sx, sy) + top_band = t["desk"].get("top") + if top_band: + # 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. + tr0, tr1 = top_band + for z in range(dz0, dz1 + 1): + sy = f0 if z == dz1 else min(tr0 + (z - dz0), tr1) + for sx in range(dx0, dx1 + 1): + px = sx + for p in t["parts"]: + px0, px1 = p["x"] + if p["kind"] in ("flat", "iso", "box"): + r0, r1 = p["rows"] else: - put(sx, y, z, sx if z == 0 else ix, sy) + r0, r1 = p["top"][0], p["facade"][1] + if px0 <= sx <= px1 and r0 <= sy <= r1: + px = px0 - 1 if sx - px0 < px1 - sx else px1 + 1 + px = max(dx0, min(dx1, px)) + break + put(sx, plane - 1, z, px, sy) + else: + # the lid is the one synthesized surface in the model -- the + # drawing never paints the tabletop clear of its objects, so the + # lid continues the sibling tables' pattern in the drawing's own + # shades: black rim, white highlight courses along the north and + # west, grey field + field = WHITE if t["desk"].get("lid") == "white" else GREY + for sx in range(dx0, dx1 + 1): + for z in range(dz0, dz1 + 1): + if sx in (dx0, dx1) or z in (dz0, dz1): + shade = BLACK + elif sx == dx0 + 1 or z == dz0 + 1: + shade = WHITE + else: + shade = field + px = shade_px.get(shade) or shade_px[BLACK] + put(sx, plane - 1, z, px[0], px[1]) + + build_parts(plane) return vox @@ -1024,16 +1734,22 @@ def verify(vox, sp, pr, t): ytop, top, slab = pr["ytop"], pr["top"], t["slab"] T = lambda x: ytop - top[max(0, min(W - 1, x))] + # A desk set answers its own asserts, and answers this first one + # BETTER -- per part rather than per drawing (nothing may stand above + # the part it belongs to). It also has to: a desk with an authored + # plane legitimately stands taller than its drawing's row count, + # because its height is the one thing the grid does not draw. + # A facade-only template (roof_rows == 0) has no roof band, so there + # is no surface to hold level or slope and no coverage to demand of + # it; everything with a roof band answers the full set. + if t.get("parts"): + verify_desk_set(vox, pr, t) + return shell_of(vox) + for (x, y, z), _ in vox.items(): assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}" - # A desk set answers its own asserts; a facade-only template (roof_rows - # == 0) has no roof band, so there is no surface to hold level or slope - # and no coverage to demand of it; everything with a roof band answers - # the full set. - if t.get("parts"): - verify_desk_set(vox, pr, t) - elif t["roof_rows"] == 0: + if t["roof_rows"] == 0: # its one geometric intent: a straight extrusion, so each column # of the drawing is solid from its lowest voxel to its top. NOT # from the ground -- the drawing's base band is inset like every @@ -1051,24 +1767,76 @@ def verify(vox, sp, pr, t): else: verify_roof(vox, pr, t) - shell = [k for k in vox if not all( + return shell_of(vox) + + +def shell_of(vox): + return [k for k in vox if not all( (k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)))] - return shell + + +def verify_tray(vox, pr, t): + """An open container: the walls stand unbroken to the rim, the floor + slab is solid under the whole opening, and -- the assert the whole + thing exists for -- the cavity between them is EMPTY. A tray that + fills in is just the extruded box again.""" + D = pr["D"] + tr = t["tray"] + fr0, fr1 = tr["front"] + bx0, bx1 = tr["x"] + ix0, ix1 = tr["inner"] + floor = tr.get("floor", 0) + plane = fr1 - fr0 + 1 + + for sx in range(bx0, bx1 + 1): + for z in range(D): + hollow = ix0 <= sx <= ix1 and 0 < z < D - 1 + if hollow: + for y in range(floor + 1): + assert (sx, y, z) in vox, f"tray floor hole {sx},{y},{z}" + for y in range(floor + 1, plane): + assert (sx, y, z) not in vox, \ + f"tray is not hollow at {sx},{y},{z}" + else: + for y in range(plane): + if (sx, y, z) not in vox: # a pane sank, or undrawn + continue + assert y < plane, f"wall above the rim at {sx},{y},{z}" + # and the walls actually enclose it: every opening column has a wall + # on all four sides + for sx in range(ix0, ix1 + 1): + assert (sx, plane - 1, 0) in vox and (sx, plane - 1, D - 1) in vox, \ + f"opening column {sx} is not walled front and back" + for z in range(1, D - 1): + assert (bx0, plane - 1, z) in vox and (bx1, plane - 1, z) in vox, \ + f"opening row {z} is not walled left and right" def verify_desk_set(vox, pr, t): W, D = pr["W"], pr["D"] - f0, f1 = t["desk"]["fascia"] - b0, b1 = t["desk"]["base"] - plane = (b1 - b0 + 1) + (f1 - f0 + 1) - - # the slab is a full solid under everything on it - for x in range(W): - for z in range(D): - for y in range(b1 - b0 + 1, plane): - assert (x, y, z) in vox, f"slab hole at {x},{y},{z}" + inside = pr["inside"] + if t.get("tray"): + return verify_tray(vox, pr, t) + # the slab is a full solid under everything on it, over the desk's + # own plot (which is the whole grid unless the grid holds something + # standing in front of the desk as well). A desk-less template (the + # house stool) has no slab to demand: its parts anchor to the floor, + # so the plane is 0 and only the per-part asserts below apply. + plane = 0 + dz0, desk_d = 0, 0 + if t.get("desk"): + f0, f1 = t["desk"]["fascia"] + b0, b1 = t["desk"]["base"] + plane = (b1 - b0 + 1) + (f1 - f0 + 1) + desk_d = t["desk"].get("depth_px") or t["desk"].get("depth", 0) * 8 or D + dz0 = t["desk"].get("z", 0) + dx0, dx1 = t["desk"].get("x", (0, W - 1)) + for x in range(dx0, dx1 + 1): + for z in range(dz0, dz0 + desk_d): + for y in range(b1 - b0 + 1, plane): + assert (x, y, z) in vox, f"slab hole at {x},{y},{z}" # each part stands where authored -- upright bodies solid from the # desk to their own drawn top (bar the front layer, whose panes @@ -1079,21 +1847,73 @@ def verify_desk_set(vox, pr, t): if p["kind"] == "flat": r0, r1 = p["rows"] z0 = p.get("z", r0) + at_y = p.get("at", plane) for x in range(x0, x1 + 1): for z in range(max(z0, 0), min(z0 + r1 - r0, D - 1) + 1): - tops[(x, z)] = max(tops.get((x, z), 0), plane) + tops[(x, z)] = max(tops.get((x, z), 0), at_y) + elif p["kind"] == "box": + # its one geometric intent: a solid column from `base` to the + # drawn top wherever the silhouette is drawn, at its own + # elevation -- never touching the desk plane machinery + r0, r1 = p["rows"] + pz = p.get("z", 0) + top = pr["ground"] - 1 - r0 + bot = p.get("base", pr["ground"] - 1 - r1) + for x in range(x0, x1 + 1): + for z in range(pz, pz + p["depth"]): + if not 0 <= z < D: + continue + tops[(x, z)] = max(tops.get((x, z), 0), top) + if z == pz + p["depth"] - 1: + continue + ys = [y for y in range(bot, top + 1) if (x, y, z) in vox] + assert ys == list(range(ys[0], ys[0] + len(ys))) \ + if ys else True, f"box column hole at {x},{z}" + elif p["kind"] == "iso": + # its one geometric intent: a diamond in plan, solid from the + # plane to a FLAT top -- an isometric box that came out + # stepped would mean the un-projection had drifted + pr0, pr1 = p["rows"] + rx = (x1 - x0 + 1) // 2 + rz = rx // 2 + plan = p.get("plan", rz) + h = (pr1 - rz) - rz - pr0 + for x in range(x0, x1 + 1): + dx2 = 2 * x - (x0 + x1) + for dz in range(-plan, plan + 1): + z = p["z"] + dz + d2 = abs(dx2) * plan + 2 * abs(dz) * rx + if not (0 <= z < D and d2 <= (2 * rx + 1) * plan): + continue + tops[(x, z)] = max(tops.get((x, z), 0), plane + h) + for y in range(plane, plane + h + 1): + assert (x, y, z) in vox, \ + f"iso box hole at {x},{y},{z}" else: fr0, fr1 = p["facade"] - ytp = plane + (fr1 - fr0) + ytp = plane + p.get("rise", 0) + (fr1 - fr0) + pz = p.get("z", 0) for x in range(x0, x1 + 1): - for z in range(p["depth"]): + for z in range(pz, pz + p["depth"]): tops[(x, z)] = max(tops.get((x, z), 0), ytp) - if z == p["depth"] - 1: + if z == pz + p["depth"] - 1: continue ys = [y for y in range(plane, ytp + 1) if (x, y, z) in vox] assert ys == list(range(ys[0], ys[0] + len(ys))) \ if ys else True, f"part column hole at {x},{z}" + # the wall element is a full solid at exactly its own height -- a + # hole is a gap in the room's back wall, and anything above it is + # the tower failure the element exists to prevent + if t.get("wall"): + wl = t["wall"] + for x in range(W): + for z in range(wl["depth_px"]): + for y in range(wl["h"]): + assert (x, y, z) in vox, f"wall hole at {x},{y},{z}" + floor_top = plane - 1 if dz0 <= z < dz0 + desk_d else 0 + tops[(x, z)] = max(tops.get((x, z), 0), wl["h"] - 1, + floor_top) for (x, y, z) in vox: assert y <= tops.get((x, z), plane - 1), \ f"voxel above its part at {x},{y},{z}" @@ -1189,7 +2009,8 @@ def main(): out = sys.argv[1] if len(sys.argv) > 1 else "." os.makedirs(out, exist_ok=True) for name, t in TEMPLATES.items(): - sp = sprite(t["tiles"], t.get("seal", ""), t.get("tileset", "overworld")) + sp = sprite(t["tiles"], t.get("seal", ""), t.get("tileset", "overworld"), + t.get("scrub")) pr = profile(sp, t) vox = build(sp, pr, t) shell = verify(vox, sp, pr, t)