Merge branch 'worktree-viridian-forest' into 20260726_various_fixes

# Conflicts:
#	data/voxel_heights.lua
This commit is contained in:
DramaticShape
2026-07-27 02:57:30 -04:00
5 changed files with 247 additions and 69 deletions
+179 -53
View File
@@ -519,15 +519,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)
@@ -535,18 +537,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)
@@ -555,16 +557,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
@@ -573,23 +575,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
@@ -604,9 +631,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)
@@ -629,10 +656,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
@@ -643,7 +670,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
@@ -651,7 +678,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
@@ -661,28 +688,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)
@@ -694,12 +739,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)
@@ -707,20 +752,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
@@ -732,8 +777,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 } },
@@ -753,11 +798,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)
@@ -768,7 +813,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
@@ -797,7 +842,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)
@@ -807,7 +865,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 },
@@ -855,23 +913,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