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