DramaticShapeVoxelMod archival mirror · read-only
5712f854b539dc91f6e9591955ab1d22e39cfba5

one tall hull per forest tree; project stump ring faces onto round tops

DramaticShape · 2026-07-27 · parent d94b4dcf75

one tall hull per forest tree; project stump ring faces onto round tops

The trees' 2x2-cell drawings unfolded onto the ground as quarter-hull
pancakes -- each tree read as cut in half. The carver is now parametric
over its canvas: a `canopy` anchor tile carves the whole 2x2-cell
drawing as ONE 32px hull, and stamps carry their footprint radius. The
stumps' drawn tops are a cut face, not body: the `stump` class builds
the hull from the bark rows and projects the ring ellipse across the
round flat top, near arc south (stump_cap = the ellipse's drawn rows).

Changed files

CHANGELOG.md           |  14 +++++
 data/voxel_heights.lua |  27 +++++---
 lib/ChunkMesher.lua    |  14 +++--
 lib/Structures.lua     | 232 +++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
 lib/TileShape.lua      |  11 ++++
 5 files changed, 229 insertions(+), 69 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 074c15e..9fcdf78 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,20 @@
   box. The whole map now resolves to hulls, signs, ledges and ground --
   a detector sweep finds no stray boxed column anywhere.
 
+  Two refinements over the first cut, both new hull-builder abilities.
+  A tree's drawing spans 2x2 CELLS, and per-cell hulls unfolded it onto
+  the ground -- the ball's top half sat one cell north of its bottom
+  half at the same elevation, reading as a tree cut in half. The new
+  `canopy` class pins the drawing's corner tile as a group anchor and
+  the whole 2x2-cell drawing carves as ONE 32px hull, so every tree is
+  a single tall round canopy (the carver is now parametric over its
+  canvas size, and hull stamps carry their footprint radius). And the
+  stumps' drawn tops are a CUT FACE -- an ellipse of growth rings seen
+  at an angle, not body: the new `stump` class builds the hull from the
+  bark rows alone and projects the ellipse across the round flat top,
+  near arc to the south (`stump_cap` names the ellipse's drawn height),
+  so the rings ride the round part in perspective.
+
 - **Flowers stand up, and keep swaying.** The animated meadow tile
   ($03, the one tile the overworld animates by frame rewrite) now
   renders as a billboard one voxel deep: the drawing's darkest tones
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index 16b3352..622c56a 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -187,17 +187,24 @@ return {
     -- lip in the middle of the woods.
     FOREST = {
       ledge = { 46 },
-      -- the big trees -- the ball ($05/$06/$15/$16/$25/$26), its rim
-      -- wisps ($04/$07/$17/$23/$24/$27) and its feet ($35/$36) -- and
-      -- the stumps ($02/$03/$12/$13): one voxel hull per 16x16 cell,
-      -- cut from the cell's own art, the treatment the overworld border
-      -- forest already wears.  A tree spans 2x2 cells, so its four
-      -- quarter-hulls tile into one big lumpy canopy.  Every tile of
-      -- the drawing is listed: the cell-rounding is per TILE, and a rim
-      -- tile left out keeps its detector reading -- which is exactly
-      -- the ragged 0/8/16/32px box collar the trees wore
-      cylinder = { 2, 3, 4, 5, 6, 7, 18, 19, 21, 22, 23,
+      -- the big trees: the whole 2x2-CELL drawing carves as ONE 32px
+      -- voxel hull, so a tree is a single tall canopy instead of four
+      -- ground-level quarter-pancakes ("cut in half", as the first
+      -- attempt read).  `canopy` pins the drawing's top-left corner
+      -- tile ($04) as the group anchor; every other tile of the
+      -- drawing stays `cylinder` so the group build can verify and
+      -- claim its cells -- and so a stray partial drawing degrades to
+      -- per-cell hulls rather than boxes
+      canopy = { 4 },
+      cylinder = { 5, 6, 7, 21, 22, 23,
                    35, 36, 37, 38, 39, 53, 54 },
+      -- 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
+      -- top -- its bottom arc to the south, the way the 2D art means
+      -- it.  stump_cap is the ellipse's height in art rows
+      stump = { 2, 3, 18, 19 },
+      stump_cap = 7,
       -- the white sparkle filler inside the tree masses: flat, not an
       -- invisible zero-height box
       ground = { 0 },
diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua
index 1f22c02..f17ea51 100644
--- a/lib/ChunkMesher.lua
+++ b/lib/ChunkMesher.lua
@@ -703,15 +703,17 @@ local function runGeometry(map, bodyOnly, masks, sink)
 
   -- round-tree stamps: the shared hull template translated per cell,
   -- through reusable scratch corners so expansion allocates nothing.
-  -- A hull spans at most its own 16px cell, so one rect test usually
-  -- answers for the whole stamp: strictly interior stamps keep every
-  -- quad, ring stamps buried under a neighbour body (or, body-only, ring
-  -- stamps full stop) skip without touching their quads. Only stamps
-  -- crossing a boundary walk quad by quad.
+  -- A hull spans at most its own footprint -- one 16px cell unless the
+  -- stamp carries a wider radius (the 2x2-cell canopy groups) -- so one
+  -- rect test usually answers for the whole stamp: strictly interior
+  -- stamps keep every quad, ring stamps buried under a neighbour body
+  -- (or, body-only, ring stamps full stop) skip without touching their
+  -- quads. Only stamps crossing a boundary walk quad by quad.
   local sc = { { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } }
   for _, st in ipairs(S.roundStamps or {}) do
     local mx, mz = st.mx, st.mz
-    local sx0, sz0, sx1, sz1 = mx - 8, mz - 8, mx + 8, mz + 8
+    local sr = st.r or 8
+    local sx0, sz0, sx1, sz1 = mx - sr, mz - sr, mx + sr, mz + sr
     local interior = sx0 > 0 and sx1 < bw and sz0 > 0 and sz1 < bh
     local overBody = sx1 > 0 and sx0 < bw and sz1 > 0 and sz0 < bh
     local keepAll, skipAll
diff --git a/lib/Structures.lua b/lib/Structures.lua
index 858f636..bd62a2d 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -508,15 +508,17 @@ end
 local ROUND_SHADE = { front = 1.0, back = 0.68, side = 0.78,
                       top = 1.0, bottom = 0.55 }
 
-local function roundTemplate(S, map, data, cx, cy, groundTiles)
+local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
+  N = N or 16                  -- art canvas: 16 = one cell, 32 = 2x2 cells
+  local N2 = N / 2
   local perRow = map.tileset.tilesPerRow or 16
   local atlasW = map.tileset.imageWidth or 128
   local atlasH = map.tileset.imageHeight or 48
 
-  -- cell-space art access (16x16, row 0 = top)
+  -- cell-space art access (NxN, row 0 = top), anchored at cell (cx, cy)
   local function tileOf(px, py)
-    return S.tileAt[keyOf(cx * 2 + (px >= 8 and 1 or 0),
-                          cy * 2 + (py >= 8 and 1 or 0))]
+    return S.tileAt[keyOf(cx * 2 + math.floor(px / 8),
+                          cy * 2 + math.floor(py / 8))]
   end
   local function texel(px, py)
     local tile = tileOf(px, py)
@@ -524,18 +526,18 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
            math.floor(tile / perRow) * 8 + py % 8
   end
 
-  -- shade class of every cell pixel, indexed py * 16 + px
+  -- shade class of every canvas pixel, indexed py * N + px
   local cls = {}
-  for py = 0, 15 do
-    for px = 0, 15 do
+  for py = 0, N - 1 do
+    for px = 0, N - 1 do
       local ax, ay = texel(px, py)
       local r, g, b, a = data:getPixel(ax, ay)
-      cls[py * 16 + px] = a == 0 and "off"
-                          or Structures.shadeClass(math.min(r, g, b))
+      cls[py * N + px] = a == 0 and "off"
+                         or Structures.shadeClass(math.min(r, g, b))
     end
   end
 
-  -- 4-connected flood from the cell border through `passable` classes
+  -- 4-connected flood from the canvas border through `passable` classes
   local function floodOutside(passable)
     local out, stack = {}, {}
     local function seed(i)
@@ -544,16 +546,16 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
         stack[#stack + 1] = i
       end
     end
-    for i = 0, 15 do
-      seed(i); seed(240 + i); seed(i * 16); seed(i * 16 + 15)
+    for i = 0, N - 1 do
+      seed(i); seed(N * (N - 1) + i); seed(i * N); seed(i * N + N - 1)
     end
     while #stack > 0 do
       local i = table.remove(stack)
-      local px = i % 16
+      local px = i % N
       if px > 0 then seed(i - 1) end
-      if px < 15 then seed(i + 1) end
-      if i >= 16 then seed(i - 16) end
-      if i < 240 then seed(i + 16) end
+      if px < N - 1 then seed(i + 1) end
+      if i >= N then seed(i - N) end
+      if i < N * (N - 1) then seed(i + N) end
     end
     return out
   end
@@ -562,23 +564,48 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
   local out = floodOutside({ off = true, dark = true,
                              light = true, white = true })
   local mask, enclosed = {}, 0
-  for i = 0, 255 do
+  for i = 0, N * N - 1 do
     if not out[i] then
       mask[i] = true
       if cls[i] ~= "black" then enclosed = enclosed + 1 end
     end
   end
-  if enclosed < 32 then
+  if enclosed < N * N / 8 then
     out = floodOutside({ off = true, light = true, white = true })
     mask = {}
-    for i = 0, 255 do
+    for i = 0, N * N - 1 do
       if not out[i] and cls[i] ~= "off" then mask[i] = true end
     end
   end
   local any = nil
-  for i = 0, 255 do any = any or mask[i] end
+  for i = 0, N * N - 1 do any = any or mask[i] end
   if not any then return {} end
 
+  -- a CAPPED hull (the stump): the top capRows rows of the mask are the
+  -- drawn cut face -- a surface seen at an angle, not body. Strip them
+  -- from the mask and remember their art span; the top-face quads below
+  -- project that ellipse across the round cap.
+  local capY0, capY1 = nil, nil
+  if capRows and capRows > 0 then
+    local top = nil
+    for iy = 0, N - 1 do
+      for ix = 0, N - 1 do
+        if mask[iy * N + ix] then top = iy break end
+      end
+      if top then break end
+    end
+    if top then
+      capY0 = top
+      capY1 = math.min(top + capRows - 1, N - 2)
+      for iy = capY0, capY1 do
+        for ix = 0, N - 1 do mask[iy * N + ix] = nil end
+      end
+      any = nil
+      for i = 0, N * N - 1 do any = any or mask[i] end
+      if not any then return {} end
+    end
+  end
+
   -- the ground the ball stands on: the drawing's own background names
   -- it. Score every flat ground tile the map places against the cell's
   -- unmasked light pixels and keep the closest -- mid-forest trees have
@@ -593,9 +620,9 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
       local ox = (t % perRow) * 8
       local oy = math.floor(t / perRow) * 8
       local score, n = 0, 0
-      for py = 0, 15 do
-        for px = 0, 15 do
-          local i = py * 16 + px
+      for py = 0, N - 1 do
+        for px = 0, N - 1 do
+          local i = py * N + px
           local c = cls[i]
           if not mask[i] and (c == "light" or c == "white") then
             local ax, ay = texel(px, py)
@@ -618,10 +645,10 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
   local z0, z1, src = {}, {}, {}
   local loRow, hiRow = {}, {}
   local yBot = nil
-  for iy = 0, 15 do
+  for iy = 0, N - 1 do
     local lo, hi = nil, nil
-    for ix = 0, 15 do
-      if mask[iy * 16 + ix] then
+    for ix = 0, N - 1 do
+      if mask[iy * N + ix] then
         lo = lo or ix
         hi = ix
       end
@@ -632,7 +659,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
       local c = (lo + hi + 1) / 2
       local hw = (hi - lo + 1) / 2
       for ix = lo, hi do
-        local i = iy * 16 + ix
+        local i = iy * N + ix
         if mask[i] then
           local dx = ix + 0.5 - c
           local n = 1
@@ -640,7 +667,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
             n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx)
                                        + 0.5))
           end
-          z0[i] = math.floor(8 - n / 2 + 0.5)
+          z0[i] = math.floor(N2 - n / 2 + 0.5)
           z1[i] = z0[i] + n
           src[i] = iy
         end
@@ -650,28 +677,46 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
 
   -- foot: rows under the mask repeat the bottom row's discs, wearing the
   -- bottom row's (outline-dark) pixels
-  for iy = yBot + 1, 15 do
+  for iy = yBot + 1, N - 1 do
     loRow[iy], hiRow[iy] = loRow[yBot], hiRow[yBot]
     for ix = loRow[yBot], hiRow[yBot] do
-      local b = yBot * 16 + ix
+      local b = yBot * N + ix
       if z0[b] then
-        local i = iy * 16 + ix
+        local i = iy * N + ix
         z0[i], z1[i], src[i] = z0[b], z1[b], yBot
       end
     end
   end
 
+  -- the round cap's top row and z extent, for the stump's ring
+  -- projection below
+  local capTopRow, capZ0, capZ1 = nil, nil, nil
+  if capY0 then
+    for iy = 0, N - 1 do
+      if loRow[iy] then capTopRow = iy break end
+    end
+    if capTopRow then
+      for ix = loRow[capTopRow], hiRow[capTopRow] do
+        local i = capTopRow * N + ix
+        if z0[i] then
+          capZ0 = math.min(capZ0 or z0[i], z0[i])
+          capZ1 = math.max(capZ1 or z1[i], z1[i])
+        end
+      end
+    end
+  end
+
   local function solidAt(ix, iy, iz)
-    if ix < 0 or ix > 15 or iy < 0 or iy > 15 then return false end
-    local i = iy * 16 + ix
+    if ix < 0 or ix > N - 1 or iy < 0 or iy > N - 1 then return false end
+    local i = iy * N + ix
     return z0[i] ~= nil and iz >= z0[i] and iz < z1[i]
   end
 
   -- cap interiors sample the canopy a couple of rows below the rim,
   -- skipping outline-dark pixels
   local function deepTexel(ix, iy)
-    for iy2 = iy + 2, math.min(15, iy + 4) do
-      local i = iy2 * 16 + ix
+    for iy2 = iy + 2, math.min(N - 1, iy + 4) do
+      local i = iy2 * N + ix
       if mask[i] and cls[i] ~= "black" then return texel(ix, iy2) end
     end
     return texel(ix, iy)
@@ -683,12 +728,12 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
   -- ball paints solid black the moment the camera turns. The foot rows
   -- stay dark on purpose: their whole source row is outline-black.
   local function sideTexel(ix, iy)
-    local r = src[iy * 16 + ix]
+    local r = src[iy * N + ix]
     local dir = ix + ix < loRow[iy] + hiRow[iy] and 1 or -1
     for step = 0, 3 do
       local x2 = ix + dir * step
-      local i2 = r * 16 + x2
-      if x2 < 0 or x2 > 15 or not mask[i2] then break end
+      local i2 = r * N + x2
+      if x2 < 0 or x2 > N - 1 or not mask[i2] then break end
       if cls[i2] ~= "black" then return texel(x2, r) end
     end
     return texel(ix, r)
@@ -696,20 +741,20 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
 
   local quads = {}
 
-  for iy = 0, 15 do
+  for iy = 0, N - 1 do
     if loRow[iy] then
-      local yB, yT = 15 - iy, 16 - iy
+      local yB, yT = N - 1 - iy, N - iy
 
       -- front and back: the drawing per-pixel, columns merged where they
       -- share a chord plane; a run never crosses the 8px atlas tile seam
       -- (its u range must interpolate inside one tile)
       local ix = loRow[iy]
       while ix <= hiRow[iy] do
-        local i = iy * 16 + ix
+        local i = iy * N + ix
         if z0[i] then
           local ix2 = ix
           while ix2 + 1 <= hiRow[iy] do
-            local j = iy * 16 + ix2 + 1
+            local j = iy * N + ix2 + 1
             if z0[j] == z0[i] and z1[j] == z1[i]
                and math.floor((ix2 + 1) / 8) == math.floor(ix / 8) then
               ix2 = ix2 + 1
@@ -721,8 +766,8 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
           local ax1 = (texel(ix2, src[i]))
           local u0, u1 = (ax0 + 0.05) / atlasW, (ax1 + 0.95) / atlasW
           local v0, v1 = (ay + 0.05) / atlasH, (ay + 0.95) / atlasH
-          local x0, x1 = ix - 8, ix2 - 7
-          local zF, zB = z1[i] - 8, z0[i] - 8
+          local x0, x1 = ix - N2, ix2 - N2 + 1
+          local zF, zB = z1[i] - N2, z0[i] - N2
           quads[#quads + 1] = {
             { x0, yB, zF }, { x1, yB, zF }, { x1, yT, zF }, { x0, yT, zF },
             uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
@@ -742,11 +787,11 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
       -- sides, steps, undersides: constant-texel quads over the z runs a
       -- neighbour doesn't cover
       for ix = loRow[iy], hiRow[iy] do
-        local i = iy * 16 + ix
+        local i = iy * N + ix
         if z0[i] then
           local ax, ay = texel(ix, src[i])
           local u, v = (ax + 0.5) / atlasW, (ay + 0.5) / atlasH
-          local x0, x1 = ix - 8, ix - 7
+          local x0, x1 = ix - N2, ix - N2 + 1
 
           -- exposed z pieces against one neighbouring column
           local function pieces(nx, ny, emit)
@@ -757,7 +802,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
                 while iz2 + 1 < z1[i] and not solidAt(nx, ny, iz2 + 1) do
                   iz2 = iz2 + 1
                 end
-                emit(iz - 8, iz2 - 7, iz, iz2)
+                emit(iz - N2, iz2 - N2 + 1, iz, iz2)
                 iz = iz2 + 1
               else
                 iz = iz + 1
@@ -786,7 +831,20 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
                 u = tu, v = tv, shade = ROUND_SHADE.top,
               }
             end
-            if izA == z0[i] and izB == z1[i] - 1 and izB - izA >= 2 then
+            if capTopRow and iy == capTopRow and capZ1 then
+              -- the CUT FACE (a capped hull's top): project the drawn
+              -- ellipse across the round cap voxel row by voxel row --
+              -- its top arc at the cap's north rim, its bottom arc at
+              -- the south, the perspective the 2D art already implies
+              for iz = izA, izB do
+                local t = capZ1 - 1 > capZ0
+                          and (iz - capZ0) / (capZ1 - 1 - capZ0) or 0
+                local ry = capY0 + math.floor(t * (capY1 - capY0) + 0.5)
+                local cax, cay = texel(ix, ry)
+                top(iz - N2, iz - N2 + 1,
+                    (cax + 0.5) / atlasW, (cay + 0.5) / atlasH)
+              end
+            elseif izA == z0[i] and izB == z1[i] - 1 and izB - izA >= 2 then
               -- the dome cap: outline on the rim cells, canopy inside
               local du, dv = deepTexel(ix, iy)
               top(zA, zA + 1, u, v)
@@ -796,7 +854,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
               top(zA, zB, u, v)
             end
           end)
-          if iy < 15 then
+          if iy < N - 1 then
             pieces(ix, iy + 1, function(zA, zB)
               quads[#quads + 1] = {
                 { x0, yB, zB }, { x1, yB, zB }, { x1, yB, zA }, { x0, yB, zA },
@@ -844,23 +902,91 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
   end
   local tsid = tostring(map.tileset.id or map.tileset.image or "?")
 
+  -- the stump class's drawn-ellipse height, hand-authored per tileset
+  -- (the profile's stump_cap, in art rows)
+  local stumpCap = 6
+  do
+    local okP, prof = pcall(V.data, "voxel_heights")
+    local entry = okP and type(prof) == "table" and prof.tilesets
+                  and prof.tilesets[map.tileset.id]
+    if entry and type(entry.stump_cap) == "number" then
+      stumpCap = entry.stump_cap
+    end
+  end
+
+  -- cells consumed by a 2x2 `canopy` group; the scan runs north to
+  -- south, west to east, so an anchor always claims its partners
+  -- before they are visited
+  local grouped = {}
   for cy = math.floor(y0 / 2), math.floor(y1 / 2) do
     for cx = math.floor(x0 / 2), math.floor(x1 / 2) do
       Budget.tick()
+      local ckey = cy * 8192 + cx
       local k = keyOf(cx * 2, cy * 2)
-      local s = S.shapeAt[k]
+      local s = (not grouped[ckey]) and S.shapeAt[k] or nil
       local near = cx * 2 >= -ROUND_RING and cx * 2 < tw + ROUND_RING
                and cy * 2 >= -ROUND_RING and cy * 2 < th + ROUND_RING
-      if s and s.art == "cylinder" and near then
+      if s and s.art == "canopy" and near then
+        -- ONE 32px hull over the 2x2-cell drawing. The partner cells
+        -- must be round-pinned too, or the drawing is partial (a map
+        -- edit, a mod's stray anchor tile) and the anchor is left
+        -- alone rather than carved into a half-empty giant.
+        local whole = true
+        for _, d in ipairs({ { 1, 0 }, { 0, 1 }, { 1, 1 } }) do
+          local ps = S.shapeAt[keyOf((cx + d[1]) * 2, (cy + d[2]) * 2)]
+          if not (ps and (ps.art == "cylinder" or ps.art == "canopy")) then
+            whole = false
+          end
+        end
+        if whole then
+          local ground = false
+          if data then
+            local ids = {}
+            for dy = 0, 3 do
+              for dx = 0, 3 do
+                ids[#ids + 1] = S.tileAt[keyOf(cx * 2 + dx, cy * 2 + dy)]
+              end
+            end
+            local sig = tsid .. "|g32|" .. gsig .. "|"
+                        .. table.concat(ids, ":")
+            local tpl = roundCache[sig]
+            if not tpl then
+              local tq, tbg = roundTemplate(S, map, data, cx, cy,
+                                            groundTiles, 32)
+              tpl = { quads = tq, bg = tbg }
+              roundCache[sig] = tpl
+            end
+            ground = tpl.bg or false
+            S.roundStamps[#S.roundStamps + 1] =
+              { quads = tpl.quads, mx = cx * 16 + 16, mz = cy * 16 + 16,
+                r = 16 }
+          end
+          for dy = 0, 3 do
+            for dx = 0, 3 do
+              local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
+              S.skip[tk] = true
+              S.ground[tk] = ground
+            end
+          end
+          grouped[ckey + 1] = true
+          grouped[ckey + 8192] = true
+          grouped[ckey + 8193] = true
+        end
+      elseif s and s.art == "cylinder" and near then
+        -- a `stump`-class cell is the same hull with a cut face: its
+        -- top capRows of drawing project onto the round top
+        local cap = s.class == "stump" and stumpCap or nil
         local ground = false
         if data then
-          local sig = tsid .. "|" .. gsig .. "|" .. table.concat({
+          local sig = tsid .. (cap and ("|c" .. cap) or "") .. "|"
+            .. gsig .. "|" .. table.concat({
             S.tileAt[k], S.tileAt[keyOf(cx * 2 + 1, cy * 2)],
             S.tileAt[keyOf(cx * 2, cy * 2 + 1)],
             S.tileAt[keyOf(cx * 2 + 1, cy * 2 + 1)] }, ":")
           local tpl = roundCache[sig]
           if not tpl then
-            local tq, tbg = roundTemplate(S, map, data, cx, cy, groundTiles)
+            local tq, tbg = roundTemplate(S, map, data, cx, cy,
+                                          groundTiles, 16, cap)
             tpl = { quads = tq, bg = tbg }
             roundCache[sig] = tpl
           end
diff --git a/lib/TileShape.lua b/lib/TileShape.lua
index 5384105..c9b15fa 100644
--- a/lib/TileShape.lua
+++ b/lib/TileShape.lua
@@ -56,6 +56,15 @@ local FALLBACK_HEIGHTS = {
   tree = 16,
   roof = 28,
   cylinder = 16,
+  -- big round scenery: a 2x2-CELL drawing carved as ONE 32px voxel hull
+  -- (Viridian Forest's trees). The class pins only the drawing's
+  -- top-left corner tile; the other cells stay `cylinder` and are
+  -- claimed by the group build (see Structures.buildCylinders)
+  canopy = 32,
+  -- a cylinder hull whose drawn top is a CUT FACE (tree stumps): the
+  -- body builds from the bark rows and the drawn ellipse projects onto
+  -- the hull's round top
+  stump = 16,
   billboard = 16,
   signpost = 16,
   post = 16,
@@ -104,6 +113,8 @@ local ART = {
   fence = "upright",
   sign = "upright",
   cylinder = "cylinder",
+  canopy = "canopy",
+  stump = "cylinder",
   billboard = "billboard",
   -- signposts share the billboard treatment but as their own pool at a
   -- 2-voxel depth: a sign is a thin plate on a stick, and the standard