update voxelizations of celadon diner stools and tables. Update square table voxelization in celadon mansion. Update mansion computer desks
DramaticShape · 2026-08-06 ·
parent 442e9d26d5
update voxelizations of celadon diner stools and tables. Update square table voxelization in celadon mansion. Update mansion computer desks
Changed files
assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md | 85 ++++++++++++-
data/voxel_heights.lua | 202 +++++++++++++++++++++++++----
lib/Buildings.lua | 91 +++++++++++++-
tools/building_voxels.py | 210 ++++++++++++++++++++++++++++++-
4 files changed, 559 insertions(+), 29 deletions(-)
Diff
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)