DramaticShapeVoxelMod archival mirror · read-only
91d9a37e8f25adb257e6909313f5b9fdc6471d1b

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)