DramaticShapeVoxelMod archival mirror · read-only
121c87b6294b3393f06c25781fa456a9cfcab678

fix back facade of buildings

DramaticShape · 2026-08-08 · parent dfe2f1ae1a

fix back facade of buildings

Changed files

CHANGELOG.md           |  22 ++++++++++++
 data/voxel_heights.lua |  26 +++++++++++++++
 lib/Buildings.lua      | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 lib/ChunkMesher.lua    |  44 +++++++++++++++++++++---
 4 files changed, 208 insertions(+), 9 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3425ca2..c468440 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -79,6 +79,28 @@
 
 ### Fixed
 
+- **A building's back no longer wears its own front door.** Every voxelized
+  building is its drawing extruded straight through the footprint, so the
+  far wall is the facade again -- and read from behind, the facade mirrored:
+  a door on the back of every house, a POKe sign readable backwards on
+  every Center, MART on every mart and GYM painted across the back of every
+  gym. Those tiles are now named per tileset (`frontOnly` in
+  `data/voxel_heights.lua` -- the doorways, the hanging shop signs and the
+  gyms' lettering) and every cell wearing one takes the art of the nearest
+  ordinary cell beside it in the same tile row instead. The donor is picked
+  per RUN, so a two-tile doorway comes out as two tiles of the same wall
+  rather than borrowing left from one side and right from the other, and
+  between the two neighbours the one that row uses more often wins -- which
+  is what reaches past a gable's sloped corner for the wall behind it. At
+  the base course the donor lifts one row with the model, because the
+  drawing's last row is the black threshold a door stands on and the wall
+  beside it does not paint; without that the doorway kept its own foot and
+  the back's bottom course had a notch in it. Windows are deliberately left
+  alone: a back wall with windows is right. The generic volume path folds
+  the same drawing up all four sides and had the same bug on its back AND
+  its flanks, so it takes the same substitution -- only the south face,
+  which IS the drawing, keeps every tile of it.
+
 - **B now actually runs from capture mode -- and A throws, and L/R switch
   balls.** The capture session read its button presses on the RENDER clock,
   along with everything else it does per frame. Button edges do not survive
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index 367b034..6af0a03 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -2976,6 +2976,32 @@ return {
     },
   },
 
+  -- Tiles that are FRONT art: they belong on the drawn facade and nowhere
+  -- else.  A building's back is the same drawing extruded straight through
+  -- the footprint (lib/Buildings.lua `model`, and the volume path's north
+  -- face in lib/ChunkMesher.lua), so without this every house wears a
+  -- second door on its far wall and every Center a POKe sign readable
+  -- backwards.  A cell wearing one of these ids takes the art of the
+  -- nearest ordinary cell beside it in the same tile row instead -- left or
+  -- right, whichever tile that row uses more, which is what reaches PAST a
+  -- gable's sloped corner for the wall behind it.
+  --
+  -- Windows are deliberately absent: a back wall with windows is right.
+  -- These are the doorways, the hanging shop signs and the painted GYM
+  -- lettering -- the three things a facade has that its back does not.
+  -- Ids are per tileset, indexing that tileset's own atlas.
+  frontOnly = {
+    -- doorway 11/12 over 27/28 (27 is the tileset's own doorTile); the
+    -- POKe (66/67) and MART (68/69) signs over their bracket row 74; the
+    -- GYM lettering 47/63 painted across the gyms' upper course.
+    OVERWORLD = { 11, 12, 27, 28, 47, 63, 66, 67, 68, 69, 74 },
+    -- the Indigo Plateau and Victory Road entrances: the same doorway
+    -- block, drawn into the cliff face.
+    PLATEAU = { 11, 12, 27, 28 },
+    -- the Safari Zone gate's double door, 42/43 over 58/59.
+    FOREST = { 42, 43, 58, 59 },
+  },
+
   -- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua,
   -- the pipeline in assets/docs/buidling_to_voxel/).  A building is matched by its
   -- exact tile grid -- the drawings are catalogued in assets/docs/buildings/ -- so
diff --git a/lib/Buildings.lua b/lib/Buildings.lua
index a21e515..f11d1ca 100644
--- a/lib/Buildings.lua
+++ b/lib/Buildings.lua
@@ -134,9 +134,112 @@ local function profile()
 end
 
 local models = {}          -- "<tileset>:<index>" -> prebuilt local quads
+local frontSets = {}       -- tileset id -> { [tile] = true } or false
+
+-- The tileset's front-only tiles as a set (data/voxel_heights.lua
+-- `frontOnly`): the doorways, shop signs and painted lettering that belong
+-- on a facade and on no other face of the same building. nil when the
+-- tileset names none, which is every indoor one.
+function Buildings.frontOnly(tilesetId)
+  local hit = frontSets[tilesetId]
+  if hit == nil then
+    local s = profile()
+    local list = s and s.frontOnly and s.frontOnly[tilesetId]
+    if list then
+      hit = {}
+      for _, id in ipairs(list) do hit[id] = true end
+    else
+      hit = false
+    end
+    frontSets[tilesetId] = hit
+  end
+  return hit or nil
+end
 
 -- ------------------------------------------------------------------ read --
 
+-- Which sprite pixel a BACK-facing voxel shows, where that is not the one
+-- the front shows. The facade extrudes straight through the footprint, so
+-- the far wall is the drawing again -- and read from behind it is the
+-- drawing mirrored, doorway, shop sign, GYM lettering and all. Those tiles
+-- are named per tileset in data/voxel_heights.lua `frontOnly`; every cell
+-- wearing one takes the art of an ordinary cell beside it in the same tile
+-- row.
+--
+-- The donor is chosen per RUN of front-only cells, not per cell, so a
+-- two-tile doorway comes out as two tiles of the SAME wall rather than
+-- borrowing left from one side and right from the other. Between the two
+-- neighbours the one whose tile the row uses more often wins, which is
+-- what reaches past a gable's sloped corner (a 4x2 house draws its door
+-- against the slope: the corner is unique to the row, the wall beside it
+-- is not) for the wall the back should actually wear.
+--
+-- Returns a SPARSE map, sprite index -> sprite index, empty entries meaning
+-- "unchanged"; nil when the drawing has no front-only tile at all, which is
+-- most of them.
+local function backMap(tiles, bw, bh, W, inside, frontOnly)
+  if not frontOnly then return nil end
+  local donor, any = {}, false
+  for r = 1, bh do
+    local row = tiles[r]
+    local freq = {}
+    for c = 1, bw do
+      local id = row[c]
+      if not frontOnly[id] then freq[id] = (freq[id] or 0) + 1 end
+    end
+    local c = 1
+    while c <= bw do
+      if frontOnly[row[c]] then
+        local c1 = c
+        while c1 < bw and frontOnly[row[c1 + 1]] do c1 = c1 + 1 end
+        local l, rt = c - 1, c1 + 1
+        local pick = nil
+        if l >= 1 and rt <= bw then
+          pick = ((freq[row[rt]] or 0) > (freq[row[l]] or 0)) and rt or l
+        elseif l >= 1 then
+          pick = l
+        elseif rt <= bw then
+          pick = rt
+        end
+        -- a row that is front-only end to end has no donor; it keeps its
+        -- own art rather than inventing one
+        if pick then
+          for k = c, c1 do donor[(r - 1) * bw + (k - 1)] = pick - 1 end
+          any = true
+        end
+        c = c1 + 1
+      else
+        c = c + 1
+      end
+    end
+  end
+  if not any then return nil end
+
+  local back = {}
+  for cell, dc in pairs(donor) do
+    local r, c = math.floor(cell / bw), cell % bw
+    for oy = 0, 7 do
+      local sy = r * 8 + oy
+      for ox = 0, 7 do
+        local i = sy * W + c * 8 + ox
+        local j = sy * W + dc * 8 + ox
+        -- The donor must be DRAWN, or the substitution would hand the wall
+        -- a texel from outside the silhouette. One row up is tried first,
+        -- because the drawing's last row is the black threshold the
+        -- building stands on: the doorway paints it (a door sits on the
+        -- ground) and the wall beside it does not, so at the base course
+        -- the same row of the donor column is off the shape. The model
+        -- lifts that column's foot by exactly one row for the same reason
+        -- (see `at`), and lifting the donor with it is what makes the back
+        -- wall's bottom course continuous.
+        if not inside[j] then j = j - W end
+        if j >= 0 and inside[j] then back[i] = j end
+      end
+    end
+  end
+  return back
+end
+
 -- Composite the template out of the atlas and flood the silhouette in from
 -- the border. Returns flat arrays indexed y * W + x.
 --
@@ -149,7 +252,7 @@ local models = {}          -- "<tileset>:<index>" -> prebuilt local quads
 -- topRows (placement is still by `tiles` alone); they exist so the MODEL
 -- is built from the complete drawing and the tower rises to its real
 -- height instead of folding as two half-buildings.
-local function read(t, data, perRow)
+local function read(t, data, perRow, frontOnly)
   local tiles = t.tiles
   if t.topRows then
     tiles = {}
@@ -244,7 +347,8 @@ local function read(t, data, perRow)
       end
     end
   end
-  return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside }
+  return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside,
+           back = backMap(tiles, bw, bh, W, inside, frontOnly) }
 end
 
 -- --------------------------------------------------------------- measure --
@@ -1034,6 +1138,11 @@ local function model(sp, pr, t)
   local T = {}
   for x = 0, W - 1 do T[x] = ytop - top[x] end
 
+  -- the back layer's texel: the drawing again, minus what only the front
+  -- may wear (see backMap)
+  local back = sp.back
+  local function backOf(i) return (back and back[i]) or i end
+
   local function at(x, y, z)
     if x < 0 or x >= W then return nil end
     local tx = T[x]
@@ -1070,7 +1179,11 @@ local function model(sp, pr, t)
     if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then
       local sy = ground - 1 - y
       if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
-        return sy * W + x
+        -- z < 0 is the awning's NORTH end: same substitution the wall
+        -- behind it makes, so a band that carries a sign does not carry
+        -- it round the back
+        local i = sy * W + x
+        return z < 0 and backOf(i) or i
       end
       return nil
     end
@@ -1092,7 +1205,7 @@ local function model(sp, pr, t)
       if pr.recess[i] then return nil end
       return i
     end
-    if z == 0 then return i end
+    if z == 0 then return backOf(i) end
     return pr.interior[i]
   end
 
@@ -1363,7 +1476,8 @@ function Buildings.build(S, map, data, perRow)
                   -- detector they stood as a second half-building.
                   models[key] = {}
                 else
-                  local sp = read(t, data, perRow)
+                  local sp = read(t, data, perRow,
+                                  Buildings.frontOnly(tileset.id))
                   local pr = measure(sp, t)
                   models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH)
                 end
@@ -1474,6 +1588,7 @@ end
 function Buildings.invalidate()
   spec = nil
   models = {}
+  frontSets = {}
 end
 
 return Buildings
diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua
index 532c3d4..6344b90 100644
--- a/lib/ChunkMesher.lua
+++ b/lib/ChunkMesher.lua
@@ -52,6 +52,7 @@ local V = ...
 
 local Assets = require("src.render.Assets")
 local Structures = V.require("Structures")
+local Buildings = V.require("Buildings")
 local TileShape = V.require("TileShape")
 local Voxel3D = V.require("Voxel3D")
 local Budget = V.require("BuildBudget")
@@ -102,6 +103,12 @@ local SIDES = {
   { 0, -1, 6 },   -- -Z north
 }
 
+-- How far sideways a face reaches for ordinary wall when the column it
+-- stands over draws a doorway or a sign (see wallTile), nearest ring
+-- first. Left before right at each distance is arbitrary and only decides
+-- symmetric cases.
+local SPAN = { { -1, 1 }, { -2, 2 } }
+
 local function keyOf(tx, ty)
   return (ty + 64) * 4096 + (tx + 64)
 end
@@ -248,6 +255,32 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
     return s and s.h or 0
   end
 
+  -- The tiles that belong on a DRAWN FACADE and nowhere else -- doorways,
+  -- shop signs, the gyms' lettering (data/voxel_heights.lua `frontOnly`).
+  -- A volume folds its column's drawing up all four sides, so without this
+  -- a house's back and flanks each carry their own copy of its front door.
+  -- The face keeps the same map row and reaches sideways for an ordinary
+  -- column instead, which is the neighbouring course of the same wall.
+  -- The search stays inside the structure -- a neighbour column with no run
+  -- of its own is the ground beside the building, and a doorway that
+  -- borrowed grass would be a hole. Two columns is as far as it needs to
+  -- reach: every doorway in the game is two tiles wide.
+  local frontOnly = Buildings.frontOnly(tileset.id)
+  local function wallTile(tx, ty)
+    local tile = map:tileAt(tx, ty)
+    if not (frontOnly and frontOnly[tile]) then return tile end
+    for d = 1, 2 do
+      for _, nx in ipairs(SPAN[d]) do
+        nx = tx + nx
+        if S.runs[keyOf(nx, ty)] then
+          local n = map:tileAt(nx, ty)
+          if not frontOnly[n] then return n end
+        end
+      end
+    end
+    return tile
+  end
+
   -- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8
   local function uvRect(tile, vTop, vBot)
     local ax = (tile % perRow) * 8
@@ -605,13 +638,16 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
                   -- drawing itself (full brightness); the other sides wear
                   -- the same rows darkened, so a building's flank matches
                   -- its face instead of smearing one tile
+                  local sy
                   if d == 6 then
-                    src = map:tileAt(tx, math.min(run.front,
-                                                  run.north + band))
+                    sy = math.min(run.front, run.north + band)
                   else
-                    src = map:tileAt(tx, math.max(run.north,
-                                                  run.front - band))
+                    sy = math.max(run.north, run.front - band)
                   end
+                  -- the south face IS the drawing and keeps every tile of
+                  -- it; the back and the flanks are the same wall seen from
+                  -- somewhere the door and the sign are not
+                  src = (d == 5) and map:tileAt(tx, sy) or wallTile(tx, sy)
                   if d == 5 then shade = 1 end
                 elseif s.art == "upright" then
                   -- profile-authored upright (a pinned wall or furniture