diff --git a/lib/Buildings.lua b/lib/Buildings.lua index def6c81..c466965 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -264,6 +264,27 @@ local function measure(sp, t) top[x] = r end + -- The row a column's roof SURFACE may sink to. `top[x]` is the + -- silhouette cap -- the black the drawing closes its shape with -- and + -- the depth map spends most of a tapered column's depth above it, so + -- clamping onto `top[x]` paints that one outline pixel the length of + -- the slope and the courses beat against it. The surface belongs on the + -- first PAINTED row instead: the same refusal to let the outline stand + -- as a face that the side faces already make below. + local surfaceTop = {} + for x = 0, W - 1 do + local y = top[x] + while y < roofRows and sp.inside[y * W + x] + and sp.col[y * W + x] == BLACK do + y = y + 1 + end + if y < roofRows and sp.inside[y * W + x] then + surfaceTop[x] = y + else + surfaceTop[x] = top[x] + end + end + -- The drawing's own ground line: the row after the last drawn one. A -- building ends on the black threshold row it stands on (ground == H), -- but furniture is drawn standing on open floor -- the lab table's @@ -386,7 +407,7 @@ local function measure(sp, t) -- building. `depthPx` names it in voxels, for an object whose real -- depth is not a whole tile row -- the Bike Shop toolbox is a box -- standing in the middle of its own cell, not a thing that fills a plot. - return { top = top, ytop = ytop, + return { top = top, surfaceTop = surfaceTop, ytop = ytop, D = t.depthPx or ((t.depth or #t.tiles) * 8), ground = ground, recess = recess, interior = interior, shadeTexel = shadeTexel } @@ -882,6 +903,7 @@ local function model(sp, pr, t) local W, H, D = sp.W, sp.H, pr.D local slab, roofRows = t.slab, t.roofRows local top, ytop, ground = pr.top, pr.ytop, pr.ground + local surfaceTop = pr.surfaceTop -- The roof's drawn span. A sprite inset from its box (B03) leaves outer -- columns undrawn in the roof band; they carry no roof at all, and the @@ -931,11 +953,12 @@ local function model(sp, pr, t) if top[x] < roofRows and y > tx - slab and y <= tx and z >= rz0 and z <= rz1 then if y == tx and x > x0d and x < x1d and z > rz0 and z < rz1 then - -- the surface itself. Clamping the row into the column's first - -- drawn row keeps the flank battens running down the slope - -- instead of falling off the silhouette. + -- the surface itself. Lifting the row into the column's first + -- PAINTED row keeps the flank battens running down the slope + -- instead of falling off the silhouette -- and off its cap, which + -- is outline black and belongs to the rim, not to the surface. local sy = roofSy[z] - if sy < top[x] then sy = top[x] end + if sy < surfaceTop[x] then sy = surfaceTop[x] end return sy * W + x end -- The rim reproduces the eave the drawing itself paints under the diff --git a/tests/roof_cap_shots.lua b/tests/roof_cap_shots.lua new file mode 100644 index 0000000..854f040 --- /dev/null +++ b/tests/roof_cap_shots.lua @@ -0,0 +1,83 @@ +-- Driver: the two sloped-roof drawings in Pallet Town, shot at the orbit +-- rungs, to see what the roof surface wears down a TAPERED flank. +-- +-- A tapered column's first drawn row is its silhouette cap, and the cap is +-- outline black. The depth map spends most of the roof's depth above that +-- cap, so a surface clamped onto it paints the outline the length of the +-- slope and the course cycle beats against it -- black teeth marching down +-- the hip. The surface belongs on the column's first PAINTED row instead. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_cap_shots.lua \ +-- SHOT_DIR=.scratchpad/roofcap AB_TAG=before lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/roofcap") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[cap] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(30) + end + + -- B07 (the gabled house: Red's and Blue's) and B31 (Oak's lab) are the + -- two tapered roof bands standing on one map -- 16 drawn rows and 32, + -- the two groups every other sloped building borrows its band table + -- from. Standing south of each puts the hip end across the frame. + local SCENES = { + { map = "PALLET_TOWN", x = 5, y = 6, face = "up", label = "reds_house" }, + { map = "PALLET_TOWN", x = 13, y = 3, face = "up", label = "blues_house" }, + { map = "PALLET_TOWN", x = 12, y = 12, face = "up", label = "oaks_lab" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, s.map, s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(8) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[cap] capture missed: " .. path) end + end + end + print(("[cap] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tools/building_voxels.py b/tools/building_voxels.py index c8718c0..bc25643 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -1246,6 +1246,22 @@ def profile(sp, t): wall_h = ground - t["roof_rows"] ytop = wall_h - 1 + t["slab"] + # The row a column's roof SURFACE may sink to. `top[x]` is the + # silhouette cap -- the black the drawing closes its shape with -- and + # the depth map spends most of a tapered column's depth above it, so a + # clamp onto `top[x]` paints that one outline pixel across the slope + # and the courses beat against it. The surface belongs on the first + # PAINTED row instead, which is the same thing the side faces already + # do when the drawing's own pixel is the outline. + surface_top = [] + for x in range(W): + y = top[x] + while (y < t["roof_rows"] and inside(x, y) + and sp["col"][y][x] == BLACK): + y += 1 + surface_top.append(y if y < t["roof_rows"] and inside(x, y) + else top[x]) + # Recesses: the panes the art seals behind a black frame. Non-black # pixels of the facade split into components across the black outline; # a component small enough to be a window or a doorway sinks one voxel. @@ -1287,7 +1303,8 @@ def profile(sp, t): # Depth is the PLOT. For a whole-drawing building that is the grid # itself; `depth` (in tile rows) names it when the grid runs past the # plot onto ground the drawing merely stands its legs on. - return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess, + return dict(top=top, surface_top=surface_top, + wall_h=wall_h, ytop=ytop, recess=recess, inside=inside, # `depth` names the plot in TILE ROWS, which is the right # grain for a building. `depth_px` names it in voxels, for @@ -1697,6 +1714,29 @@ def build_desk_set(sp, pr, t): return vox +def roof_surface_row(pr, t): + """Which drawn row the roof surface wears at column x, depth z. + + The drawing looks at the roof from the north, so its rows ARE depth: the + rims map one row per voxel and the middle cycles a run whose period is + the course rhythm. A tapered column's band starts lower down the + drawing, and the row it wears is lifted to stay inside it.""" + z0, z1 = 0, pr["D"] - 1 + t["front_eave"] + back, front = t["roof_back"], t["roof_front"] + c0, c1 = t["roof_cycle"] + rows, floor = t["roof_rows"], pr["surface_top"] + + def depth_row(z): + df, db = z - z0, z1 - z # from the north / the south edge + if df < back: + return df # north rim: the drawing's top rows + if db < front: + return rows - 1 - db # south rim: fascia and eave course + return c0 + (df - c0) % (c1 - c0 + 1) + + return (lambda x, z: max(depth_row(z), floor[x])), z0, z1 + + def build(sp, pr, t): """The voxel model. Order is load-bearing: walls, ledge, recesses, then the roof solid overwrites what it intersects and the walls are trimmed @@ -1775,17 +1815,7 @@ def build(sp, pr, t): vox.pop((sx, pr["ground"] - 1 - sy, D - 1), None) # ---- roof: flat top over the plateau, stepped diagonal ends - z0, z1 = 0, D - 1 + t["front_eave"] - back, front = t["roof_back"], t["roof_front"] - c0, c1 = t["roof_cycle"] - - def roof_sy(z): - df, db = z - z0, z1 - z # from the north / the south edge - if df < back: - return df # north rim: the drawing's top rows - if db < front: - return t["roof_rows"] - 1 - db # south rim: fascia and eave course - return c0 + (df - c0) % (c1 - c0 + 1) + surface_row, z0, z1 = roof_surface_row(pr, t) shade_px = {} for sy in range(H): @@ -1797,10 +1827,7 @@ def build(sp, pr, t): tt = T(x) for z in range(z0, z1 + 1): outer = x == x0d or x == x1d or z == z0 or z == z1 - # the slope's texture is the drawing's own: clamping into the - # column's first drawn row keeps flank battens running down the - # slope instead of falling off the silhouette - sy = max(roof_sy(z), pr["top"][x]) + sy = surface_row(x, z) for y in range(tt - slab + 1, tt + 1): if y == tt and not outer: put(x, y, z, x, sy) @@ -2043,6 +2070,19 @@ def verify_roof(vox, pr, t): # drawing's own corner rounding assert all(ytop - v <= 1 for v in prof), "the flat roof is not level" + # The surface is surface, not outline. `top[x]` is the column's own + # silhouette cap -- the black the drawing closes the shape with, the + # same black measure() already refuses to let stand as a wall's side + # face -- so a surface that samples it promotes a boundary decoration + # into texture. A tapered column's cap sits well down the band, where + # the depth map spends most of the roof's depth above it. + surface_row, sz0, sz1 = roof_surface_row(pr, t) + for x in roofed[1:-1]: + for z in range(sz0 + 1, sz1): + assert surface_row(x, z) != top[x], \ + f"roof surface wears the silhouette cap at {x},{z} " \ + f"(row {top[x]})" + # every wall column carries roof over it -- and a column the roof never # reaches carries nothing at all, rather than being silently trimmed away over = set(roofed)