diff --git a/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md b/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md index 40bea60..faa8597 100644 --- a/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md +++ b/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md @@ -61,6 +61,10 @@ cues generalize: | Band containing window/door frames | Vertical facade | Straight extrusion | | Full-width band with a black underline sitting above an inset band | Ledge / awning overhang | Extrusion + protrusion | | Dark `#555` runs beside a facade under a taper | Shadow on the wall beneath an eave | Leave as wall — the geometry above produces the shadow's meaning | +| Scattered light shapes on a dark field, bracketed by TWO full-width black rims, shallow band below the lower rim | The **inside of an open container** seen from above, with contents lying in it | Hollow tray: walls to the rims, floor slab, air between — never an extrusion | +| Ellipse drawn wider than tall (e.g. 9x5) | A horizontal circle seen from above — a mouth, a lid, a pot rim | Cut face of a round hull; the aspect ratio is the proof of the top view | +| Arcs above/below a round object's straight flanks, lowest point at the centre column, often a 1px #555 halo outside | The SAME circles seen curving — ground contact and mouth back-edge, i.e. depth, not narrowing | Strip them from the revolve; run the last body row's disc to the floor | +| A side band shearing sideways as it descends (¾-view) | The projection sliding a receding wall, not the wall's position | Un-project: the wall goes where the plan says | The band table for Red's house, which Blue's house shares verbatim: @@ -156,9 +160,12 @@ Tooling: `voxel_build_verify.py` (builds, asserts, renders previews). 1. Obtain the sprite; sample to native resolution via block centers. 2. Extract palette + silhouette (light-only flood fill, threshold 130); - review the ASCII mask. -3. Segment rows into bands using the Stage-2 cues; write the band table - before writing any geometry code. + review the ASCII mask — rendered large, not hand-counted. +3. Name the real object first (including whether it is hollow, round or + thin — see "Beyond the house"), then segment rows into bands using the + Stage-2 cues; write the band table as prose, one line per row range + with where each band lands, before writing any geometry code. The + correct reading makes the row arithmetic land exactly. 4. Measure taper rates from the mask; derive `T(x)`, `YTOP`, overhangs, `D`. 5. Build: extrude verticals (de-outlined interiors) → ledges → recesses → flat top (mid-row cycling) → sloped solids (overwrite, then trim) → @@ -214,3 +221,75 @@ right for the raw GB palette but comes out white once the atlas is recoloured, turning every sloped end into a black-and-white zip. The drawing's own eave is black / `#555` / black, and using that reads correctly under every palette. + +## Beyond the house: the forms later objects added + +The house is all solid masses — every band either lies flat or extrudes. +Later objects forced the taxonomy open, and each addition came from the +same root move: **name the real 3D form first, then ask which surfaces the +drawing shows.** The recurring failure at every step was the *extruded +picture* — and it has a second-order form that survives re-segmentation. +The Bike Shop's toolbox was re-read from "a prop" into "a cabinet with a +pump beside it": named parts, correct plot, de-outlined sides, and still +wrong, because the region read as a cabinet *front* was the inside of an +open box seen from above. Naming the parts is not enough; every REGION +must answer "what surface of the real object is this?" The reliable +arbiter is arithmetic: the correct reading makes the drawn row counts land +exactly (the toolbox: 1 back-wall rim + 6 interior rows + 1 front rim = 8 += the one-tile plot depth). Forcing rows to fit means the reading is wrong. + +**Hollow forms.** An open container is the one shape whose model must +contain AIR, which no band table or extrusion can produce. The tray +treatment builds four walls to the drawn rims, lays the top-view band on +the floor of the cavity (its contents — a wrench — come along free, since +they are just pixels of that band), and leaves the space between empty. +Two rules only containers hit: the pane-recess pass must never run on a +one-voxel wall (it deletes the front voxel to expose the one behind, and +there is nothing behind — the wall becomes a hole), and the hollowness +needs its own verification assert, because a later change that refills the +cavity leaves every count looking plausible. + +**Round forms.** A drawn ellipse wider than tall is a horizontal circle +seen from above — that one aspect-ratio measurement settles the whole +reading. Straight flanks give diameter and height at once (round in plan, +so drawn width IS depth — the one depth never authored). The arcs above +and below the straight run are the same top and base circles seen curving: +ground contact and mouth edge, not narrowing — revolving them puts the +object on a stem. The hull's chord representation stores one z-interval +per column/row, so a taper is expressible (re-cut the chords, squeeze the +art into the narrowed span so the rim outline survives) but a hollow ring +needs a second chord. Voxel resolution bounds taste: on an 11-wide object +a one-step taper reads as damage and two steps as a cone; pick the step +count and derive the amount. + +**Thin forms.** A line drawing cannot be thick. The air inside a bicycle's +frame is what makes it read as a bicycle; extrude each stroke 5 voxels and +the side faces of neighbouring strokes close every gap off-axis — six +bikes become one dark mass. Standee thickness is a vocabulary +(`PINNED_DEPTH`: 1 for paper, 2 for plates and side-on vehicles, 5 for +silhouettes, 10 for objects with a body), and when a standee looks wrong +the first move is to dump the detector's mask — if the mask is a clean +object, thickness is the problem, not segmentation. + +**Authored masks.** When a drawing shares its tiles and shades with what +it is painted into, nothing automatic can separate them; the profile +carries a pixel mask instead. A person becomes a `figures` card (flat, +leaning with the camera, standing on its feet — because GB character art +is face-on iconography); an object becomes a `mounted` slab (fixed in the +world, holding the wall's plane, keeping its drawn elevation — because a +side-on drawing is a plane parallel to the wall). And when the backdrop is +a *regular* pattern, the mask should be MEASURED, not hand-drawn: +composite the plain backdrop tile over the same grid and flood from the +border through pixels that still match it — what the flood cannot reach is +the object, sprite-pure and exact. + +**Verification, extended.** Isometric previews miss what only the game +shows: shoot in-game at both the ¾ rung and the low rung (front-face holes +and proportion errors are invisible from above), crop and NEAREST-upscale +before judging, and remember the flat rung renders no model at all. Two +cheap renders beat argument: the front-most voxel per (x, y) laid beside +the composited drawing catches anchoring and texel leaks instantly, and +the same render with sunk voxels flagged turns the recess pass into +something you look at. When shared builder code moves, a saved count +baseline diffed after every edit (mind the line endings) is what proves a +generalization is an identity for every model that already shipped. diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 55c075c..3b4c904 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -197,7 +197,25 @@ return { -- cutouts both read wrong for them; the cylinder archetype carves -- one voxel ball per 16x16 cell from the canopy's darkest-pixel -- outline, round in depth, so tree rows become rows of real canopies - cylinder = { 42, 43, 58, 59, 64, 65, 80, 81 }, + -- + -- The cuttable tree ($2D/$2E/$3D/$3E, the four tiles Cut deletes) + -- joins them: it is Celadon Gym's little tree redrawn pixel-for- + -- pixel on this atlas (same 146/256 silhouette, only canopy + -- highlight texels differ), and takes the identical hull -- the + -- scraggly canopy revolves into a gapped ball, the 2-4px trunk + -- into a thin round column, the root flare into a round mound. + -- It used to sit in the `prop` pool as a 5-voxel standee; see + -- GYM's cylinder entry for the reading. Scanned: the 2x2 grid + -- occurs 32 times on this atlas across the towns and routes, and + -- per-tile counts equal the grid count (32 hits for $2D alone), + -- so no stray occurrence renders as a lone hull. The same four + -- ids form grids on FOREST (8, Safari Zone ground art), HOUSE, + -- MANSION, SHIP_PORT and REDS_HOUSE_2 -- different drawings, id + -- collisions, none of this entry's business. Cut itself only + -- swaps the map block to plain grass, so the pin never sees a + -- cut stump. + cylinder = { 42, 43, 58, 59, 64, 65, 80, 81, + 45, 46, 61, 62 }, -- the town sign (blockset 8's SE cell): a standing per-pixel slab -- 2 voxels thin, transparency respected -- never a solid box signpost = { 70, 71, 86, 87 }, @@ -220,15 +238,11 @@ return { -- instead -- see there.) wall = { 2, 36 }, - -- the cuttable bush ($2D/$2E/$3D/$3E, the four tiles Cut deletes - -- -- across the whole tileset they appear only in the five - -- cut-tree blocks): a standing per-pixel cutout 5 voxels deep, - -- black-outline segmented with the pixels the outline encloses - -- kept, its drawn grass dither flooding away as background - prop = { 45, 46, 61, 62 }, - -- the ground painted under those pinned props, by the prop tile's - -- own id: the bush stands on plain grass ($2C) -- the very tile - -- Cut leaves behind (field.cutTreeSwaps' after-blocks) -- rather + -- (the cuttable tree $2D/$2E/$3D/$3E moved to the cylinder hull + -- above) + -- the ground painted under the claimed tree cells, by tile id: + -- the tree stands on plain grass ($2C) -- the very tile Cut + -- leaves behind (field.cutTreeSwaps' after-blocks) -- rather -- than whatever flat tile its neighbours vote prop_ground = { [45] = 44, [46] = 44, [61] = 44, [62] = 44 }, }, @@ -343,8 +357,25 @@ return { -- -- 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. + -- Celadon's three little trees ($40/$41 canopy over $50/$51 + -- trunk) round out the same family: a scraggly canopy over a + -- 2-4px trunk flaring into a round root mound. Every drawn row + -- states its own width, which is exactly what the hull revolves + -- -- the canopy turns into a ball with its drawn gaps kept, the + -- trunk into a thin round column, the mound into a round foot -- + -- so the tree stands beside the hedge balls as a solid object + -- of the same construction. It used to sit in the `prop` pool + -- ("a trunk is not round"), but the standee rendered as the + -- whole 16x16 cell extruded, background and all -- the extruded + -- picture -- and the trunk IS round; the hull reads it right. + -- Scanned: the 2x2 grid occurs 3 times on this atlas and only + -- in CELADON_GYM -- cells (2,4), (7,5), (5,7) -- and per-tile + -- counts equal the grid count (3 hits for $40 alone), so no + -- stray occurrence renders as a lone hull. DOJO shares gym.png + -- and places none, so the pin is not copied there. cylinder = { 44, 45, 46, 47, - 7, 8, 23, 24 }, + 7, 8, 23, 24, + 64, 65, 80, 81 }, -- 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 @@ -405,13 +436,9 @@ return { 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 statues. (Celadon's trees $40/$41/$50/$51 lived here + -- too until they moved to the cylinder hull above.) + prop = { 2, 56, 18, 19 }, -- The Hall of Fame's recording machine, the one piece of real -- furniture in the tileset. It is drawn 32px wide and THREE tile -- rows tall against the north band, and the detector made a mess @@ -685,7 +712,15 @@ return { -- per-cell hulls rather than boxes canopy = { 4 }, cylinder = { 5, 6, 7, 21, 22, 23, - 35, 36, 37, 38, 39, 53, 54 }, + 35, 36, 37, 38, 39, 53, 54, + -- the Safari Zone's small round trees ($54/$55/$56/$57, + -- one cell, 376 placements across the four safari maps + -- and nowhere else on this tileset): drawn as a canopy + -- ball like the overworld's lone tree, and the detector + -- was boxing them into 16px dither-textured crates. + -- One voxel ball per cell, the same hull the big trees' + -- quarter tiles degrade to + 84, 85, 86, 87 }, -- the stumps ($02/$03/$12/$13): a hull whose drawn top is a CUT -- FACE. The body builds from the bark rows alone, and the drawn -- ellipse of growth rings projects onto the hull's round flat @@ -1488,9 +1523,14 @@ return { -- terrace: their north rim (9/25 = $09/$19) and the -- pedestal course at the south (85/86/87). -- - -- THE ROUND TABLES in full, because the shape is a compromise. The - -- drawing (block 29, and the same four rows split across blocks 45 - -- and 49 in the diner) is + -- THE ROUND TABLES in full, because the shape is a compromise. + -- (The `diner_round_table` template under `buildings` below now + -- models all four placements in full -- octagonal top on its + -- pedestal -- by matching the whole 4x4 grid, which is what a + -- per-tile pin can never do. Everything here stays as its + -- degradation path and as the record of why the pins look the + -- way they do.) The drawing (block 29, and the same four rows + -- split across blocks 45 and 49 in the diner) is -- $09 $27 $27 $19 an octagonal top seen from above, with -- $36 $37 $37 $39 a pedestal drawn below its southern -- $46 $37 $37 $47 rim @@ -1511,7 +1551,7 @@ return { -- 8px is the FAR rim (9/25, and the shared 39/54/57) and the -- pedestal (85/86/87): the far rim is occluded by the 16px top in -- front of it, and the pedestal is meant to sit low. 16px is also - -- the right height against the 8px `stool` chairs drawn around it + -- the right height against the seat-high `stool` chairs around it -- -- a terrace table you sit at, not a footstool. counter = { 9, 21, 25, 36, 37, 38, 39, 41, 48, 49, 52, 53, 54, 57, 85, 86, 87 }, @@ -1541,7 +1581,15 @@ return { -- drawing carries a full black outline with the floor dither -- showing at all four corners, so the standee segments cleanly -- -- and `stool` keeps its own pool, apart from anything it touches. + -- The `diner_stool` template (see `buildings` below) now models + -- every placement in full, like the house stool it copies; these + -- pins are its degradation path. stool = { 7, 8, 23, 24 }, + -- the `diner_stool` template stands 5 voxels (the drawn + -- elevation: the seat's front edge at row 10 over legs 11-14), + -- as the house stool does: whoever sits on a stool cell rides + -- this height, not the 8px class default + heights = { stool = 5 }, -- Deliberately NOT pinned: -- $37 (55) is three different things -- the light half of the -- checkerboard floor, the interior of the round tables, and @@ -4169,6 +4217,26 @@ return { roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 }, slab = 3, frontEave = 0, ledge = nil, }, + -- F07b: the SQUARE table of CELADON_MANSION_1F cells (0,6):(1,7) + -- (1 placement, scan.lua) -- the long table (F07) at two cells + -- wide, the same drawing to the tile everywhere but the interior + -- column count, and the same read to the row: 0-23 the tabletop + -- seen from above, 24-26 the slab's black/#555/black front edge, + -- 27 the #555 shadow that closes it (slab = 3, folded into the + -- band), 28-30 the base with the legs stopping one row short of + -- the grid. Family numbers unchanged; the `table` pin stays as + -- the degradation path, neutralized where this stamps. + { + id = "mansion_square_table", + tiles = { + { 38, 39, 39, 41 }, + { 54, 55, 55, 57 }, + { 54, 55, 55, 57 }, + { 60, 58, 58, 59 }, + }, + roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 }, + slab = 3, frontEave = 0, ledge = nil, + }, }, HOUSE = { @@ -4480,5 +4548,93 @@ return { }, }, }, + + LOBBY = { + -- F09 on the lobby atlas: the Celadon department store's stools + -- -- the diner's chairs, the roof terrace's, the Game Corner's + -- six rows and the four on 1F (58 placements, the scan's only + -- matches on this atlas). A DIFFERENT drawing from the house + -- stool -- it sits one row HIGHER in the tile (seat top rows 4-9 + -- over its front edge at 10 and the legs at 11-14, with a clear + -- floor row below) and its leg detail differs -- but the same + -- object band for band, so it takes the house part table with + -- the bands shifted up one row. The measured ground line (15 + -- here, 16 in the house) shifts with them, so the stand is the + -- same 5 voxels, and the tileset's `stool = 5` height override + -- keeps whoever sits here ON the seat. The old stool standee + -- pins stay as the degradation path. + { + id = "diner_stool", + tiles = { + { 7, 8 }, + { 23, 24 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, + panes = false, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 4, 9 }, + facade = { 10, 14 }, z = 3, depth = 11, + stretch = true }, -- the stool + }, + }, + -- F11: the ROUND TABLE of the diner and the roof terrace -- 4 + -- placements, all on this atlas (CELADON_DINER cells (0,2) and + -- (0,5), CELADON_MART_ROOF (4,2) and (8,4); scan + -- "9,39,39,25;54,55,55,57;70,55,55,71;85,86,87,55" matches + -- nowhere else). This is the drawing the long `counter` note + -- above calls a compromise -- its four interior tiles are $37, + -- unpinnable, so the flat treatment let the whole top BE a 16px + -- disc. The template matches the exact 4x4 grid instead, which + -- is what a per-tile pin can never do, and un-projects the three + -- facings: rows 0-23 the OCTAGONAL top seen from above (24 + -- top-view rows = 24 depth rows, so the plan is the silhouette + -- itself, a `plan` slab 32x24), rows 24-25 the slab's #555/black + -- fascia folded down its rim, rows 26-31 the PEDESTAL seen under + -- the front edge -- two flattened circles, i.e. horizontal discs: + -- the base (diameter 16, drawn cols 8-23, side rows 30-31, its + -- top wearing the drawn shadow-and-white rows 26-29) and the dark + -- column (diameter 6, cols 13-18, rows 26-27 repeating up the + -- shaft), both on the drawn centre x 16 / plan centre z 12. + -- MEASURED: plan, diameters, centres, slab 3. AUTHORED: tabletop + -- plane 8 -- counter height, developer-tuned (the first cut + -- stood it at the flat compromise's 16px and it read too tall) + -- -- plus base height 2 and the 3-voxel column between. `depth` + -- 3 keeps the plot to the drawn plan; the grid's 4th tile row is + -- the pedestal's own drawing plus one floor tile ($37 again, at + -- the southeast corner), which the claim paints as ground. + -- `scrub` repoints the top's interior field -- the four $37 + -- tiles, ALSO the checkerboard floor's light half, which carry + -- the floor's palette in a colorized atlas -- at the same grey + -- sourced from the rim's own field, so the whole top wears the + -- table's palette (the drawn field there is uniform grey; + -- nothing drawn is lost). The old wall/counter pins on the rim + -- tiles stay as the degradation path, and `support` carries the + -- top plane so anything the standee scan finds on these cells + -- rides the tabletop. + { + id = "diner_round_table", + tiles = { + { 9, 39, 39, 25 }, + { 54, 55, 55, 57 }, + { 70, 55, 55, 71 }, + { 85, 86, 87, 55 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 3, + panes = false, support = 8, + scrub = { { 8, 8, 23, 23 } }, + parts = { + { kind = "disc", cx2 = 32, cz2 = 24, r = 8, rise = 0, h = 2, + side = { rows = { 30, 31 }, x = { 13, 18 } }, + cap = { rows = { 26, 29 }, x = { 9, 22 } } }, -- the base + { kind = "disc", cx2 = 32, cz2 = 24, r = 3, rise = 2, h = 3, + side = { rows = { 26, 27 }, x = { 14, 17 } } }, -- the column + { kind = "plan", x = { 0, 31 }, rows = { 0, 23 }, + fascia = { 24, 25 }, fasciaX = { 8, 23 }, + rise = 5 }, -- the top + }, + }, + }, }, } diff --git a/lib/Buildings.lua b/lib/Buildings.lua index def6c81..9b72578 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -436,7 +436,8 @@ local function deskSetModel(sp, pr, t) local function buildParts(plane) for _, p in ipairs(t.parts) do Budget.tick() - local x0, x1 = p.x[1], p.x[2] + local x0 = p.x and p.x[1] or 0 + local x1 = p.x and p.x[2] or (W - 1) if p.kind == "flat" then -- drawn row = depth row by default; `z` renames the origin when -- the flat sits below the desk's own drawn top span (the Center @@ -561,6 +562,94 @@ local function deskSetModel(sp, pr, t) end end end + elseif p.kind == "plan" then + -- A PLAN part is a slab whose plan IS the drawn top view: the + -- band's silhouette becomes the footprint pixel for pixel + -- (drawn row = depth row, the same 1:1 every tabletop is drawn + -- with), so an octagonal top stands as an octagon rather than + -- the box no rectangular band can escape. The top layer wears + -- the band itself, outline and all; the rim layers below wear + -- the drawn fascia rows folded down the edge (x clamped into + -- the drawn fascia's span), and the slab's unseen interior the + -- field's dark texel. + local r0, r1 = p.rows[1], p.rows[2] + local f0, f1 = p.fascia[1], p.fascia[2] + local fx0, fx1 = p.fasciaX[1], p.fasciaX[2] + local rise = p.rise or 0 + local h = (f1 - f0 + 1) + 1 + if rise + h > ytop then ytop = rise + h end + local function drawn(sx, z) + return sx >= x0 and sx <= x1 and z >= 0 and z <= r1 - r0 + and inside[(r0 + z) * W + sx] + end + for z = 0, r1 - r0 do + if z >= 0 and z < D then + local sy = r0 + z + for sx = x0, x1 do + if inside[sy * W + sx] then + put(sx, rise + h - 1, z, sy * W + sx) + local edge = not (drawn(sx - 1, z) and drawn(sx + 1, z) + and drawn(sx, z - 1) and drawn(sx, z + 1)) + for y = rise, rise + h - 2 do + if edge then + local fsx = math.max(fx0, math.min(fx1, sx)) + put(sx, y, z, (f0 + (rise + h - 2 - y)) * W + fsx) + else + put(sx, y, z, pr.shadeTexel[DARK]) + end + end + end + end + end + end + elseif p.kind == "disc" then + -- A DISC part is ROUND IN PLAN -- the pedestal column and base + -- the projection can only draw from the front. Centre and + -- radius are measured off the drawn widths (a flattened arc is + -- a horizontal circle seen from above); the circular footprint + -- is synthesized like any continued geometry, and every voxel + -- still wears the drawing: the side folds the drawn face-on + -- rows around the hull (x clamped into the drawn span, rows + -- repeating up the height), and `cap` lays the drawn top-view + -- rows over the top layer's interior, drawn north rows to the + -- plan's north. `cx2`/`cz2` are DOUBLED plan centres, so an + -- even diameter keeps its centre between two voxels instead of + -- limping one off. + local r, rise, h = p.r, p.rise or 0, p.h + local s0, s1 = p.side.rows[1], p.side.rows[2] + local sa0, sa1 = p.side.x[1], p.side.x[2] + local sn = s1 - s0 + 1 + if rise + h > ytop then ytop = rise + h end + local function inDisc(x, z) + local dx = 2 * x + 1 - p.cx2 + local dz = 2 * z + 1 - p.cz2 + return dx * dx + dz * dz <= 4 * r * r + end + local zlo = math.floor((p.cz2 - 2 * r) / 2) + for x = math.floor((p.cx2 - 2 * r) / 2), + math.floor((p.cx2 + 2 * r) / 2) do + for z = math.max(0, zlo), + math.min(D - 1, math.floor((p.cz2 + 2 * r) / 2)) do + if inDisc(x, z) then + local edge = not (inDisc(x - 1, z) and inDisc(x + 1, z) + and inDisc(x, z - 1) and inDisc(x, z + 1)) + for y = rise, rise + h - 1 do + local sx, sy + if p.cap and y == rise + h - 1 and not edge then + local c0, c1 = p.cap.rows[1], p.cap.rows[2] + sy = math.min(c1, c0 + math.floor((z - zlo) + * (c1 - c0 + 1) + / (2 * r))) + sx = math.max(p.cap.x[1], math.min(p.cap.x[2], x)) + else + sy = s0 + (rise + h - 1 - y) % sn + sx = math.max(sa0, math.min(sa1, x)) + end + put(x, y, z, sy * W + sx) + end + end + end + end else local tr0, tr1 = p.top[1], p.top[2] local fr0, fr1 = p.facade[1], p.facade[2] diff --git a/tools/building_voxels.py b/tools/building_voxels.py index c8718c0..0dd1002 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -680,6 +680,23 @@ TEMPLATES = { roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23), slab=3, front_eave=0, ledge=None, tileset="mansion", ), + # F07b: the SQUARE table of CELADON_MANSION_1F cells (0,6):(1,7) + # (1 placement) -- the long table (F07) at two cells wide, the same + # drawing to the tile everywhere but the interior column count, and + # the same read to the row: 0-23 the tabletop seen from above, 24-26 + # the slab's black/#555/black front edge, 27 the #555 shadow that + # closes it (slab = 3, folded into the band), 28-30 the base with the + # legs stopping one row short of the grid. Family numbers unchanged. + "mansion_square_table": dict( + tiles=[ + [38, 39, 39, 41], + [54, 55, 55, 57], + [54, 55, 55, 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="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 @@ -853,6 +870,79 @@ TEMPLATES = { z=3, depth=11, stretch=True), # the stool ], ), + # F09 on the lobby atlas: the Celadon department store's stools -- + # the diner's chairs, the roof terrace's, the Game Corner's six + # rows and the four on 1F (58 placements). A DIFFERENT drawing from + # the house stool -- it sits one row HIGHER in the tile (seat top + # rows 4-9 over front edge 10 and legs 11-14, with a clear floor + # row below) and its leg detail differs -- but the same object band + # for band, so it takes the same part table with the bands shifted + # up one row. The measured ground line (15 here, 16 in the house) + # shifts with them, so the stand is the same 5 voxels. + "diner_stool": dict( + tiles=[ + [7, 8], + [23, 24], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="lobby", + panes=False, + parts=[ + dict(kind="upright", x=(2, 13), top=(4, 9), facade=(10, 14), + z=3, depth=11, stretch=True), # the stool + ], + ), + # F11: the ROUND TABLE of the Celadon diner and the roof terrace -- + # 4 placements, all on the lobby atlas (CELADON_DINER cells (0,2) + # and (0,5), CELADON_MART_ROOF (4,2) and (8,4); scan + # "9,39,39,25;54,55,55,57;70,55,55,71;85,86,87,55", no matches on + # any other atlas). The drawing packs three facings no band split + # can reach: rows 0-23 are the OCTAGONAL top seen from above (24 + # top-view rows = 24 depth rows, the same 1:1 every tabletop is + # drawn with -- so the plan is the silhouette itself, 32 wide by 24 + # deep), rows 24-25 the slab's own fascia (#555 over black, folded + # down the rim under the band's drawn outline -> slab 3), and rows + # 26-31 the PEDESTAL seen under the front edge: the base's top + # surface with the dark column rising from its middle (26-27), its + # lit south half (28-29), and its front arc curving to the floor + # (30-31). The flattened arcs are horizontal CIRCLES seen from + # above -- depth, not narrowing -- so the pedestal is two discs: + # base diameter 16 (drawn cols 8-23), column diameter 6 (the dark + # blob's cols 13-18), both centred on the drawn centre x=16, plan + # centre z=12. MEASURED: plan, diameters, centre, slab 3. AUTHORED: + # tabletop plane 8 -- counter height, developer-tuned (the first + # cut stood it at the flat compromise's 16px and it read too tall) + # -- plus base height 2 and the 3-voxel column between (the + # projection cannot state either; the drawn base arc suggests a + # low disc). depth 3 keeps the plot to the drawn plan's 24 rows; + # the grid's 4th tile row is the pedestal's own drawing and the + # floor tile at its southeast corner, which the claim paints as + # ground. `scrub` repoints the top's interior field -- the four + # $37 tiles, which are ALSO the checkerboard floor's light half + # and carry the floor's palette in a colorized atlas -- at the + # same grey sourced from the rim's own field, so the whole top + # wears the table's palette (the drawn field there is uniform + # grey; nothing drawn is lost). + "diner_round_table": dict( + tiles=[ + [9, 39, 39, 25], + [54, 55, 55, 57], + [70, 55, 55, 71], + [85, 86, 87, 55], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="lobby", depth=3, + panes=False, scrub=[(8, 8, 23, 23)], + parts=[ + dict(kind="disc", cx2=32, cz2=24, r=8, rise=0, h=2, + side=dict(rows=(30, 31), x=(13, 18)), + cap=dict(rows=(26, 29), x=(9, 22))), # the base + dict(kind="disc", cx2=32, cz2=24, r=3, rise=2, h=3, + side=dict(rows=(26, 27), x=(14, 17))), # the column + dict(kind="plan", x=(0, 31), rows=(0, 23), + fascia=(24, 25), fascia_x=(8, 23), rise=5), # the top + ], + ), # 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 @@ -1333,7 +1423,7 @@ def build_desk_set(sp, pr, t): def build_parts(plane): for p in t["parts"]: - x0, x1 = p["x"] + x0, x1 = p.get("x", (0, W - 1)) if p["kind"] == "flat": r0, r1 = p["rows"] # `at` names the sheet's own height when it does not lie @@ -1445,6 +1535,91 @@ def build_desk_set(sp, pr, t): if pr0 <= sy <= pr1 and inside(sx, sy): put(sx, plane + y, z, sx, sy) continue + if p["kind"] == "plan": + # A PLAN part is a slab whose plan IS the drawn top view: + # the band's silhouette becomes the footprint pixel for + # pixel (drawn row = depth row, the same 1:1 every + # tabletop is drawn with), so an octagonal top stands as + # an octagon rather than the box no rectangular band can + # escape. The top layer wears the band itself, outline + # and all; the rim layers below wear the drawn fascia + # rows folded down the edge (x clamped into the drawn + # fascia's span), and the slab's unseen interior the + # field's dark texel. + r0, r1 = p["rows"] + f0, f1 = p["fascia"] + fx0, fx1 = p["fascia_x"] + rise = p.get("rise", 0) + h = (f1 - f0 + 1) + 1 + dark = shade_px.get(DARK) or shade_px[BLACK] + + def drawn(sx, z): + return (x0 <= sx <= x1 and 0 <= z <= r1 - r0 + and inside(sx, r0 + z)) + + for z in range(r1 - r0 + 1): + if not 0 <= z < D: + continue + sy = r0 + z + for sx in range(x0, x1 + 1): + if not inside(sx, sy): + continue + put(sx, rise + h - 1, z, sx, sy) + edge = not (drawn(sx - 1, z) and drawn(sx + 1, z) + and drawn(sx, z - 1) and drawn(sx, z + 1)) + for y in range(rise, rise + h - 1): + if edge: + put(sx, y, z, max(fx0, min(fx1, sx)), + f0 + (rise + h - 2 - y)) + else: + put(sx, y, z, dark[0], dark[1]) + continue + if p["kind"] == "disc": + # A DISC part is ROUND IN PLAN -- the pedestal column + # and base the projection can only draw from the front. + # Centre and radius are measured off the drawn widths + # (a flattened arc is a horizontal circle seen from + # above); the circular footprint is synthesized like any + # continued geometry, and every voxel still wears the + # drawing: the side folds the drawn face-on rows around + # the hull (x clamped into the drawn span, rows + # repeating up the height), and `cap` lays the drawn + # top-view rows over the top layer's interior, drawn + # north rows to the plan's north. `cx2`/`cz2` are + # DOUBLED plan centres, so an even diameter keeps its + # centre between two voxels instead of limping one off. + r, rise, h = p["r"], p.get("rise", 0), p["h"] + s0, s1 = p["side"]["rows"] + sa0, sa1 = p["side"]["x"] + sn = s1 - s0 + 1 + cap = p.get("cap") + + def in_disc(x, z): + dx = 2 * x + 1 - p["cx2"] + dz = 2 * z + 1 - p["cz2"] + return dx * dx + dz * dz <= 4 * r * r + + zlo = (p["cz2"] - 2 * r) // 2 + for x in range((p["cx2"] - 2 * r) // 2, + (p["cx2"] + 2 * r) // 2 + 1): + for z in range(max(0, zlo), + min(D - 1, (p["cz2"] + 2 * r) // 2) + 1): + if not in_disc(x, z): + continue + edge = not (in_disc(x - 1, z) and in_disc(x + 1, z) + and in_disc(x, z - 1) + and in_disc(x, z + 1)) + for y in range(rise, rise + h): + if cap and y == rise + h - 1 and not edge: + c0, c1 = cap["rows"] + sy = min(c1, c0 + ((z - zlo) + * (c1 - c0 + 1)) // (2 * r)) + sx = max(cap["x"][0], min(cap["x"][1], x)) + else: + sy = s0 + (rise + h - 1 - y) % sn + sx = max(sa0, min(sa1, x)) + put(x, y, z, sx, sy) + continue tr0, tr1 = p["top"] fr0, fr1 = p["facade"] pd = p["depth"] @@ -1933,7 +2108,7 @@ def verify_desk_set(vox, pr, t): # sink) -- and nothing stands anywhere else tops = {} for p in t["parts"]: - x0, x1 = p["x"] + x0, x1 = p.get("x", (0, W - 1)) if p["kind"] == "flat": r0, r1 = p["rows"] z0 = p.get("z", r0) @@ -1979,6 +2154,37 @@ def verify_desk_set(vox, pr, t): for y in range(plane, plane + h + 1): assert (x, y, z) in vox, \ f"iso box hole at {x},{y},{z}" + elif p["kind"] == "plan": + # its one geometric intent: the plan IS the drawn band's + # silhouette -- a solid slab column wherever the band draws + r0, r1 = p["rows"] + h = (p["fascia"][1] - p["fascia"][0] + 1) + 1 + rise = p.get("rise", 0) + for x in range(x0, x1 + 1): + for z in range(min(r1 - r0 + 1, D)): + if not pr["inside"](x, r0 + z): + continue + tops[(x, z)] = max(tops.get((x, z), 0), rise + h - 1) + for y in range(rise, rise + h): + assert (x, y, z) in vox, \ + f"plan slab hole at {x},{y},{z}" + elif p["kind"] == "disc": + # its one geometric intent: a solid circle in plan at every + # layer, symmetric about the authored centre + r, rise, h = p["r"], p.get("rise", 0), p["h"] + in_disc = lambda x, z: ((2 * x + 1 - p["cx2"]) ** 2 + + (2 * z + 1 - p["cz2"]) ** 2 + <= 4 * r * r) + for x in range((p["cx2"] - 2 * r) // 2, + (p["cx2"] + 2 * r) // 2 + 1): + for z in range(D): + if not in_disc(x, z): + continue + assert in_disc(p["cx2"] - 1 - x, z), \ + f"disc asymmetric at {x},{z}" + tops[(x, z)] = max(tops.get((x, z), 0), rise + h - 1) + for y in range(rise, rise + h): + assert (x, y, z) in vox, f"disc hole at {x},{y},{z}" else: fr0, fr1 = p["facade"] ytp = plane + p.get("rise", 0) + (fr1 - fr0)