mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 08:11:11 +02:00
dde0879527
The hip ends of the gabled house and Oak's lab come out as black teeth marching down the slope instead of battens running with it. The depth->row map is authored for columns the drawing paints edge to edge. A tapered column starts further down the band, and the surface was clamped into its first DRAWN row to stay inside the silhouette -- but a column's first drawn row is its silhouette CAP, and the cap is outline black. On a tapered column the map spends most of the roof's depth above that cap (seven of the gabled house's fifty depth voxels land on it outright), so the clamp painted one outline pixel the length of the slope and roofCycle beat against it: black punctuated by the real batten colour every 4 rows on the house, every 8 on the lab. Lift to the column's first PAINTED row instead -- the same refusal to let outline black stand as a face that measure() already makes for the side faces, which the roof band was never given. Geometry is untouched: voxel, shell and recess counts are identical for all 50 templates. verify_roof now asserts no roof surface samples its column's cap, and tests/roof_cap_shots.lua shoots the two Pallet Town drawings A/B. The version bump and CHANGELOG entry are left out deliberately, so this does not collide with the release flow.
1391 lines
55 KiB
Lua
1391 lines
55 KiB
Lua
-- Voxel world mode: a building voxelized from its own sprite.
|
|
--
|
|
-- A Game Boy overworld building is a fake-3D projection that packs several
|
|
-- different 3D facings into one flat drawing: the roof is drawn as if seen
|
|
-- from above, the facade as if seen face-on, and the sloped ends as
|
|
-- diagonal silhouettes. Raising the whole footprint as one box (what the
|
|
-- generic volume path does) folds all three into a wall, so a house comes
|
|
-- out as a cube wearing its own elevation.
|
|
--
|
|
-- This module does the other thing: it classifies each BAND of the drawing
|
|
-- by the surface it depicts and applies the matching operation per band --
|
|
-- the pipeline written up in assets/docs/buidling_to_voxel/. Two rules govern it:
|
|
--
|
|
-- 1. Every visible voxel colour is a real texel of the drawing. Nothing
|
|
-- is invented but the geometry the sprite implies and never paints
|
|
-- (undersides, the depth behind the facade), and those wear the
|
|
-- drawing's own four shades.
|
|
-- 2. The sprite is ground truth, not the tile grid. The silhouette, the
|
|
-- taper rate, the eave height and every window are MEASURED off the
|
|
-- pixels; the profile only says which rows are roof and which are
|
|
-- facade.
|
|
--
|
|
-- The pipeline, per template (see data/voxel_heights.lua `buildings`):
|
|
--
|
|
-- read composite the building out of the atlas and flood its
|
|
-- silhouette in from the border through light pixels only --
|
|
-- the black outline and the #555 shading together are the
|
|
-- boundary, and a "not black" test eats the shaded flanks.
|
|
-- measure the topmost drawn row of each column IS the roof's elevation
|
|
-- profile (the drawn taper is the slope); the facade's panes
|
|
-- are the non-black regions its black frames seal off.
|
|
-- build facade rows extrude straight back over the footprint, the
|
|
-- awning band juts past them, panes sink one voxel, and the
|
|
-- roof lays the top-facing rows flat -- level over the
|
|
-- plateau, stepping down the drawn taper at the ends -- then
|
|
-- overwrites the walls it intersects.
|
|
-- emit cull to the shell and merge runs of texel-adjacent faces into
|
|
-- single quads, so a 90k-voxel house ships as ~2k quads.
|
|
--
|
|
-- One model is built per template and stamped at every placement: Red's
|
|
-- and Blue's houses are the same seven-placement drawing, so they cost one
|
|
-- build between them. mods/DRAMATIC_SHAPE/tools/building_voxels.py is the
|
|
-- reference implementation of the same algorithm and prints the voxel and
|
|
-- shell counts this one must agree with.
|
|
--
|
|
-- Purely presentational, like everything else in the mod: the tiles a
|
|
-- building claims keep the collision, warps and triggers they always had.
|
|
|
|
-- the mod namespace (see main.lua): V.data loads a shipped data file
|
|
local V = ...
|
|
|
|
local Budget = V.require("BuildBudget")
|
|
|
|
local Buildings = {}
|
|
|
|
-- The four GB shades, lightest first (same cutoffs as Structures.shadeClass,
|
|
-- which reasons about the same art).
|
|
local WHITE, GREY, DARK, BLACK = 0, 1, 2, 3
|
|
|
|
-- A pane is a window or a doorway: a non-black region the drawing seals
|
|
-- off behind its own black frame. Anything wider or taller than this is a
|
|
-- band of the facade itself -- a siding course, the awning's grey field --
|
|
-- and must stay flush.
|
|
local RECESS_MAX = 24
|
|
|
|
-- Face shades, matching the rest of the mod's objects: the south face is
|
|
-- the drawing itself and draws at full brightness.
|
|
local SHADE = { top = 0.95, south = 1.0, north = 0.68,
|
|
side = 0.78, bottom = 0.5 }
|
|
|
|
-- ------- how far a merged run may reach: the tile lattice
|
|
--
|
|
-- Merging is what keeps a 90k-voxel house down to ~2k quads, and under a
|
|
-- straight projection a run may be as long as it likes -- a straight line
|
|
-- is a straight line however finely it is cut. THE WORLD CURVE IS NOT
|
|
-- STRAIGHT. It drops every vertex by the square of its distance from the
|
|
-- focus (see WorldCurve), so a quad's interior is the CHORD of a parabola
|
|
-- its neighbours draw the arc of: a run of length L hangs k*L^2/4 below
|
|
-- the short quads butted against it, and the join tears open.
|
|
--
|
|
-- Nothing bounded a run's length before, and the runs that ran away were
|
|
-- the ones wearing a CONSTANT texel -- the roof's black eave outline, its
|
|
-- fascia, the shaded underside -- because a flat run has no art to break
|
|
-- it. Those reached 102px across a gym, which at V-CURVE 3 hangs some
|
|
-- three world pixels under the roof surface beside it: the eave tore off
|
|
-- the roof and the drop showed the building's dark interior through the
|
|
-- slot. (Strip runs, the drawing marching along the atlas, break at the
|
|
-- tileset's own boundaries and were never the problem.)
|
|
--
|
|
-- So a run stops at the next 8px lattice line. Buildings are stamped at
|
|
-- tx*8 (see stamp), so the model's lattice IS the map's: every quad in the
|
|
-- scene -- terrain, props, this -- now ends on the same lines, every join
|
|
-- is vertex-for-vertex, and the bend carries them together. What is left
|
|
-- is the sag WITHIN one cell, k*64/4, which is under a twentieth of a
|
|
-- world pixel at any rung.
|
|
--
|
|
-- It costs quads on a dense city map (Cerulean's object stream goes from
|
|
-- 35.7k to 41.6k, and its longest edge from 102px to 8px) and it costs them
|
|
-- whether the curve is on or not, which is the deliberate trade: the mesh
|
|
-- is cached per map and built asynchronously over seconds, so meshing for
|
|
-- the curve's sake only when the curve is on would mean rebuilding every
|
|
-- live map on a keypress.
|
|
local CELL = 8
|
|
|
|
-- How far a run starting at `a` may go before it crosses the next lattice
|
|
-- line. Floor-mod, so the awning's negative z lands on the same lines the
|
|
-- positive side does.
|
|
local function runCap(a)
|
|
return CELL - a % CELL
|
|
end
|
|
|
|
local function keyOf(tx, ty)
|
|
return (ty + 64) * 4096 + (tx + 64)
|
|
end
|
|
|
|
local function shadeOf(r, g, b, a)
|
|
if a == 0 then return WHITE end
|
|
local v = math.min(r, g, b)
|
|
if v <= 0.25 then return BLACK end
|
|
if v <= 0.55 then return DARK end
|
|
if v <= 0.85 then return GREY end
|
|
return WHITE
|
|
end
|
|
|
|
-- The shape profile ships with the mod; absent or broken simply means no
|
|
-- building templates, and every building falls back to the volume path.
|
|
local spec = nil
|
|
local function profile()
|
|
if spec == nil then
|
|
local ok, s = pcall(V.data, "voxel_heights")
|
|
spec = (ok and type(s) == "table") and s or false
|
|
end
|
|
return spec or nil
|
|
end
|
|
|
|
local models = {} -- "<tileset>:<index>" -> prebuilt local quads
|
|
|
|
-- ------------------------------------------------------------------ read --
|
|
|
|
-- Composite the template out of the atlas and flood the silhouette in from
|
|
-- the border. Returns flat arrays indexed y * W + x.
|
|
--
|
|
-- `topRows`, when a template carries it, is extra drawing rows composited
|
|
-- ABOVE the matched grid: rows of the same drawing that are not on the
|
|
-- map this template places on. The Pokemon Tower is the case that needs
|
|
-- it -- the drawing straddles the LAVENDER_TOWN / ROUTE_10 boundary, its
|
|
-- roof band and top window courses standing in the route's last rows, so
|
|
-- no single map's grid holds the whole building. The matcher never sees
|
|
-- 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 tiles = t.tiles
|
|
if t.topRows then
|
|
tiles = {}
|
|
for _, row in ipairs(t.topRows) do tiles[#tiles + 1] = row end
|
|
for _, row in ipairs(t.tiles) do tiles[#tiles + 1] = row end
|
|
end
|
|
local bh, bw = #tiles, #t.tiles[1]
|
|
local W, H = bw * 8, bh * 8
|
|
local col, ax, ay = {}, {}, {}
|
|
for sy = 0, H - 1 do
|
|
Budget.tick()
|
|
local row = tiles[math.floor(sy / 8) + 1]
|
|
for sx = 0, W - 1 do
|
|
local tile = row[math.floor(sx / 8) + 1]
|
|
local px = (tile % perRow) * 8 + sx % 8
|
|
local py = math.floor(tile / perRow) * 8 + sy % 8
|
|
local i = sy * W + sx
|
|
ax[i], ay[i] = px, py
|
|
local r, g, b, a = data:getPixel(px, py)
|
|
col[i] = shadeOf(r, g, b, a)
|
|
end
|
|
end
|
|
|
|
local outside = {}
|
|
local queue, n = {}, 0
|
|
local function seed(x, y)
|
|
local i = y * W + x
|
|
if not outside[i] and col[i] <= GREY then
|
|
outside[i] = true
|
|
n = n + 1
|
|
queue[n] = i
|
|
end
|
|
end
|
|
-- The flood comes in from the border, which assumes the drawing is
|
|
-- bounded by its own outline on every side. A drawing trimmed flush to
|
|
-- its art -- one whose base course is a row of brick rather than the
|
|
-- black threshold every other building stands on -- names the sides it
|
|
-- runs off in `seal`, and the flood does not seed there. Without it the
|
|
-- flood climbs in through the light mortar and hollows the wall out.
|
|
local seal = t.seal or ""
|
|
local function sealed(side) return string.find(seal, side, 1, true) ~= nil end
|
|
for x = 0, W - 1 do
|
|
if not sealed("n") then seed(x, 0) end
|
|
if not sealed("s") then seed(x, H - 1) end
|
|
end
|
|
for y = 0, H - 1 do
|
|
if not sealed("w") then seed(0, y) end
|
|
if not sealed("e") then seed(W - 1, y) end
|
|
end
|
|
while n > 0 do
|
|
local i = queue[n]
|
|
n = n - 1
|
|
local x, y = i % W, math.floor(i / W)
|
|
if x + 1 < W then seed(x + 1, y) end
|
|
if x > 0 then seed(x - 1, y) end
|
|
if y + 1 < H then seed(x, y + 1) end
|
|
if y > 0 then seed(x, y - 1) end
|
|
end
|
|
|
|
local inside = {}
|
|
for i = 0, W * H - 1 do inside[i] = not outside[i] end
|
|
|
|
-- `scrub` names pixel rects where the drawing paints an object standing
|
|
-- ON the surface (Red's potted plant on the dining tabletop). The object
|
|
-- keeps its own standee -- the template's `keep` leaves its tiles
|
|
-- unclaimed -- so the band beneath it is the one surface the drawing
|
|
-- implies but never paints clear: every rect pixel takes the field
|
|
-- shade, sourced from the first field texel outside the rects, and the
|
|
-- model's top comes out as the plain surface the object sat on.
|
|
if t.scrub then
|
|
local function inRect(x, y)
|
|
for _, r in ipairs(t.scrub) do
|
|
if x >= r[1] and x <= r[3] and y >= r[2] and y <= r[4] then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
local donor = nil
|
|
for i = 0, W * H - 1 do
|
|
if col[i] == GREY and inside[i]
|
|
and not inRect(i % W, math.floor(i / W)) then
|
|
donor = i
|
|
break
|
|
end
|
|
end
|
|
for i = 0, W * H - 1 do
|
|
if inRect(i % W, math.floor(i / W)) then
|
|
col[i] = GREY
|
|
ax[i], ay[i] = ax[donor], ay[donor]
|
|
inside[i] = true
|
|
end
|
|
end
|
|
end
|
|
return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside }
|
|
end
|
|
|
|
-- --------------------------------------------------------------- measure --
|
|
|
|
local function measure(sp, t)
|
|
local W, H = sp.W, sp.H
|
|
local roofRows = t.roofRows
|
|
|
|
-- The drawn taper IS the slope: the first drawn row of a column is how
|
|
-- far the roof has stepped down by the time it reaches that column.
|
|
local top = {}
|
|
for x = 0, W - 1 do
|
|
local r = roofRows
|
|
for y = 0, roofRows - 1 do
|
|
if sp.inside[y * W + x] then r = y break end
|
|
end
|
|
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
|
|
-- legs stop two rows short of its grid -- and extruding against H
|
|
-- would float it that far above its own plot.
|
|
local ground = roofRows
|
|
for sy = H - 1, roofRows, -1 do
|
|
local drawn = false
|
|
for sx = 0, W - 1 do
|
|
if sp.inside[sy * W + sx] then drawn = true break end
|
|
end
|
|
if drawn then
|
|
ground = sy + 1
|
|
break
|
|
end
|
|
end
|
|
|
|
local wallH = ground - roofRows
|
|
local ytop = wallH - 1 + t.slab
|
|
|
|
-- Side faces must not come out as slabs of outline black: where the
|
|
-- drawing's own pixel is the outline, walk inward for the first painted
|
|
-- colour, which is what the flanks of the real thing would show.
|
|
local interior = {}
|
|
for sy = roofRows, H - 1 do
|
|
for sx = 0, W - 1 do
|
|
local i = sy * W + sx
|
|
local src = i
|
|
if sp.inside[i] and sp.col[i] == BLACK then
|
|
local step = sx < W / 2 and 1 or -1
|
|
for d = 1, 3 do
|
|
local nx = sx + step * d
|
|
if nx >= 0 and nx < W then
|
|
local ni = sy * W + nx
|
|
if sp.inside[ni] and sp.col[ni] ~= BLACK then
|
|
src = ni
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
interior[i] = src
|
|
end
|
|
end
|
|
|
|
-- Panes: the facade's non-black pixels split into regions across the
|
|
-- black frames, and a region small enough to be a window or a doorway
|
|
-- sinks a voxel. Frames stay proud, so the pane behind them reads as
|
|
-- glass set into the wall -- and a nested frame (the door's own little
|
|
-- window) layers for free.
|
|
local recess, seen = {}, {}
|
|
for sy = roofRows, H - 1 do
|
|
for sx = 0, W - 1 do
|
|
local i0 = sy * W + sx
|
|
if not seen[i0] and sp.inside[i0] and sp.col[i0] ~= BLACK then
|
|
local cells, stack = {}, { i0 }
|
|
seen[i0] = true
|
|
local x0, x1, y0, y1 = sx, sx, sy, sy
|
|
local function step(nx, ny)
|
|
if nx < 0 or nx >= W or ny < roofRows or ny >= H then return end
|
|
local ni = ny * W + nx
|
|
if not seen[ni] and sp.inside[ni] and sp.col[ni] ~= BLACK then
|
|
seen[ni] = true
|
|
stack[#stack + 1] = ni
|
|
end
|
|
end
|
|
while #stack > 0 do
|
|
local i = table.remove(stack)
|
|
cells[#cells + 1] = i
|
|
local cx, cy = i % W, math.floor(i / W)
|
|
if cx < x0 then x0 = cx end
|
|
if cx > x1 then x1 = cx end
|
|
if cy < y0 then y0 = cy end
|
|
if cy > y1 then y1 = cy end
|
|
step(cx + 1, cy)
|
|
step(cx - 1, cy)
|
|
step(cx, cy + 1)
|
|
step(cx, cy - 1)
|
|
end
|
|
if x1 - x0 < RECESS_MAX and y1 - y0 < RECESS_MAX then
|
|
for _, i in ipairs(cells) do recess[i] = true end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- The pane rule reads a LIGHT region the drawing seals behind a BLACK
|
|
-- frame. A drawing built the other way round -- the healing machine's
|
|
-- dark screens sealed behind their own white bezels -- inverts under
|
|
-- it: every lit edge sinks and the black panes stand proud, a black
|
|
-- lattice a voxel off the face. `panes = false` says the drawing does
|
|
-- not carry the rule's polarity, so the facade stays flush.
|
|
if t.panes == false then recess = {} end
|
|
|
|
-- One representative texel per shade, taken from the building's own art:
|
|
-- the roof's fascia and its undersides are geometry the drawing implies
|
|
-- but never paints, and they must still wear its palette (and pick up
|
|
-- whatever SGB recolouring the atlas carries).
|
|
local shadeTexel = {}
|
|
for i = 0, sp.W * sp.H - 1 do
|
|
if sp.inside[i] and not shadeTexel[sp.col[i]] then
|
|
shadeTexel[sp.col[i]] = i
|
|
end
|
|
end
|
|
for s = WHITE, BLACK do
|
|
shadeTexel[s] = shadeTexel[s] or shadeTexel[BLACK] or 0
|
|
end
|
|
|
|
-- Depth is the MATCHED footprint, not the sprite height. The two are
|
|
-- the same number for every whole-drawing template (the sprite is
|
|
-- built from `tiles` alone), but a template with `topRows` has a
|
|
-- sprite taller than its footprint -- the tower's 16-row drawing
|
|
-- stands on the 8 rows of it that are actually on the map, and D = H
|
|
-- would have pushed its body 64px south into the town plaza.
|
|
-- `depth` (in tile rows) names the plot when the grid runs PAST it
|
|
-- onto ground the drawing merely stands its legs on: the lab table's
|
|
-- third row is the walkable cell the player faces it from, and the
|
|
-- full-grid depth would stand the model in their path.
|
|
-- `depth` names the plot in TILE ROWS, which is the right grain for a
|
|
-- 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, surfaceTop = surfaceTop, ytop = ytop,
|
|
D = t.depthPx or ((t.depth or #t.tiles) * 8),
|
|
ground = ground,
|
|
recess = recess, interior = interior, shadeTexel = shadeTexel }
|
|
end
|
|
|
|
-- ----------------------------------------------------------------- build --
|
|
|
|
-- A desk with separately-classified objects on it (a template's `parts`
|
|
-- list): the methodology's region classification at part granularity.
|
|
-- Upright parts anchor their drawn bottom row to the desk's top plane
|
|
-- and wear their own drawn tops as lids; flat parts (a keyboard, a
|
|
-- sheet of paper) lie one voxel proud at drawn row = depth row -- the
|
|
-- same 1:1 the tabletop itself is drawn with, so an object's height ON
|
|
-- the drawing is its position ON the desk. The desk is the lab-table
|
|
-- slab + base; its lid is the one synthesized surface in the model
|
|
-- (the objects cover every drawn pixel of the tabletop), continued
|
|
-- from the sibling tables' pattern in the drawing's own shades.
|
|
-- tools/building_voxels.py `build_desk_set` is the reference twin.
|
|
local function deskSetModel(sp, pr, t)
|
|
local W, H, D = sp.W, sp.H, pr.D
|
|
local ground = pr.ground
|
|
local col, inside = sp.col, sp.inside
|
|
local vox = {}
|
|
local function key(x, y, z) return (y * D + z) * W + x end
|
|
local function put(x, y, z, i) vox[key(x, y, z)] = i end
|
|
|
|
-- de-outline walk bounded to the part, so a part's side faces show
|
|
-- its own material and never the neighbour's (the sprite-wide walk
|
|
-- the facade path uses would cross the black seam between units)
|
|
local function interiorAt(sx, sy, lo, hi)
|
|
local i = sy * W + sx
|
|
if col[i] ~= BLACK then return sx end
|
|
local step = sx < math.floor((lo + hi) / 2) and 1 or -1
|
|
for d = 1, 3 do
|
|
local nx = sx + step * d
|
|
if nx >= lo and nx <= hi then
|
|
local ni = sy * W + nx
|
|
if inside[ni] and col[ni] ~= BLACK then return nx end
|
|
end
|
|
end
|
|
return sx
|
|
end
|
|
|
|
-- The parts list, shared by every base piece: a desk plane or an
|
|
-- open tray rim alike, `plane` is simply the height they ride.
|
|
local ytop = 0
|
|
local function buildParts(plane)
|
|
for _, p in ipairs(t.parts) do
|
|
Budget.tick()
|
|
local x0, x1 = p.x[1], p.x[2]
|
|
if p.kind == "flat" then
|
|
-- drawn row = depth row by default; `z` renames the origin when
|
|
-- the flat sits below the desk's own drawn top span (the Center
|
|
-- PC's keyboard). `at` names the sheet's own height when it does
|
|
-- not lie on the desk plane (the healing machine's keyboard is a
|
|
-- shelf mounted on the cabinet's side); `thick` gives it a body
|
|
-- -- layers below the sheet repeating each column's own texel,
|
|
-- the same continuation rule every synthesized surface follows.
|
|
local r0 = p.rows[1]
|
|
local z0 = p.z or r0
|
|
local atY = p.at or plane
|
|
local thick = p.thick or 1
|
|
if atY > ytop then ytop = atY end
|
|
for sy = r0, p.rows[2] do
|
|
local z = z0 + (sy - r0)
|
|
if z >= 0 and z < D then
|
|
for sx = x0, x1 do
|
|
if inside[sy * W + sx] then
|
|
for y = math.max(0, atY - thick + 1), atY do
|
|
put(sx, y, z, sy * W + sx)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
elseif p.kind == "box" then
|
|
-- A BOX part is a drawn rect standing at its own drawn
|
|
-- elevation -- equipment attached to the machine rather than an
|
|
-- object on the desk plane. The rows are face-on art: the top
|
|
-- row's drawn height IS the box's top (ground - 1 - r0,
|
|
-- measured), and the box runs down to `base` (default the drawn
|
|
-- extent; 0 continues it to the floor, the legs-continue rule).
|
|
-- Height beyond the drawn rows fills the way a roof band does:
|
|
-- rows before `cycle` map 1:1 from the top, rows after it 1:1
|
|
-- from the bottom -- the healing machine hoses' foot lands ON
|
|
-- the floor -- and the cycle window repeats between.
|
|
local r0, r1 = p.rows[1], p.rows[2]
|
|
local c0 = p.cycle and p.cycle[1] or r1
|
|
local c1 = p.cycle and p.cycle[2] or r1
|
|
local pz = p.z or 0
|
|
local pd = p.depth
|
|
local top = pr.ground - 1 - r0
|
|
local bot = p.base or (pr.ground - 1 - r1)
|
|
local nTop, nBot = c0 - r0, r1 - c1
|
|
if top > ytop then ytop = top end
|
|
for y = bot, top do
|
|
local k, j = top - y, y - bot
|
|
local sy
|
|
if k < nTop then
|
|
sy = r0 + k
|
|
elseif j < nBot then
|
|
sy = r1 - j
|
|
else
|
|
sy = c0 + (k - nTop) % (c1 - c0 + 1)
|
|
end
|
|
for sx = x0, x1 do
|
|
local i = sy * W + sx
|
|
if inside[i] then
|
|
local ix = interiorAt(sx, sy, x0, x1)
|
|
for z = pz, pz + pd - 1 do
|
|
if z >= 0 and z < D then
|
|
local px = (z == pz or z == pz + pd - 1) and sx or ix
|
|
put(sx, y, z, sy * W + px)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
elseif p.kind == "iso" then
|
|
-- An ISO part is drawn in 2:1 isometric -- a box TURNED 45
|
|
-- degrees to the map, so one rhombus carries its top, its front
|
|
-- and its side at once and no band or facade split can reach
|
|
-- them. Un-projecting it is that projection run backwards: the
|
|
-- box stands as a real diamond in plan and every voxel wears the
|
|
-- texel the drawing paints where that voxel projects TO. The
|
|
-- drawn top lands on the top, the screen on the screen-facing
|
|
-- side and the flank on the flank, and nothing is segmented by
|
|
-- hand -- which is the only way to get this right, because the
|
|
-- three faces meet on a diagonal no rectangle can name.
|
|
--
|
|
-- Everything but the depth centre falls out of the drawn rect,
|
|
-- because the projection fixes it: the half-width is the drawn
|
|
-- rhombus's x radius, HALF that again its z radius (2:1 is what
|
|
-- makes it isometric), the near corner's drawn row is the base
|
|
-- rhombus's front tip, and whatever drawn height is left once
|
|
-- that rhombus is accounted for is the box's own height. Bill's
|
|
-- computer: rx 6, rz 3, base centre row 10, and 6 voxels tall --
|
|
-- which puts its left corner's vertical edge at drawn rows
|
|
-- 4..10, exactly where the drawing paints one.
|
|
--
|
|
-- `plan` is the one thing the drawing CANNOT state: 2:1 is the
|
|
-- projection, not the object, so reading rz as the plan radius
|
|
-- too builds a box half as deep as it is wide -- a slab, not the
|
|
-- cube the drawing depicts. `plan` names the real z radius and
|
|
-- the drawn row is scaled into it, so a cube is `plan = rx` and
|
|
-- the drawing still lands on it pixel for pixel.
|
|
local pr0, pr1 = p.rows[1], p.rows[2]
|
|
local rx = math.floor((x1 - x0 + 1) / 2)
|
|
local rz = math.floor(rx / 2)
|
|
local plan = p.plan or rz
|
|
local oy = pr1 - rz
|
|
local h = oy - rz - pr0
|
|
local ytp = plane + h
|
|
if ytp > ytop then ytop = ytp end
|
|
for sx = x0, x1 do
|
|
-- doubled, so a rect of even width keeps its centre between
|
|
-- two columns instead of limping one to the left
|
|
local dx2 = 2 * sx - (x0 + x1)
|
|
for dz = -plan, plan do
|
|
local z = p.z + dz
|
|
local d2 = math.abs(dx2) * plan + 2 * math.abs(dz) * rx
|
|
if z >= 0 and z < D and d2 <= (2 * rx + 1) * plan then
|
|
-- the plan row scaled back into the drawn rhombus
|
|
local dzs = math.floor((2 * dz * rz + plan) / (2 * plan))
|
|
for y = 0, h do
|
|
local sy = oy + dzs - y
|
|
local i = sy * W + sx
|
|
if sy >= pr0 and sy <= pr1 and inside[i] then
|
|
put(sx, plane + y, z, i)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
else
|
|
local tr0, tr1 = p.top[1], p.top[2]
|
|
local fr0, fr1 = p.facade[1], p.facade[2]
|
|
local pd = p.depth
|
|
-- `rise` lifts a part off the desk's top plane and `z` names its
|
|
-- back-most depth row (the field a flat part already carries). An
|
|
-- object STANDING on a desk needs neither: it starts on the plane
|
|
-- at the plot's back. The healing machine's console needs both --
|
|
-- it stands in the FRONT map row of a grid whose back row is the
|
|
-- wall band it leans against, and its screen head is MOUNTED on
|
|
-- the console's front two voxels above the body's top. Both come
|
|
-- off the drawing, not off taste.
|
|
local base = plane + (p.rise or 0)
|
|
local pz = p.z or 0
|
|
local ytp = base + (fr1 - fr0)
|
|
if ytp > ytop then ytop = ytp end
|
|
-- `inset` sinks an authored pane one voxel: the pane rule
|
|
-- applied by hand, for a part whose screen IS sealed behind its
|
|
-- own black frame while the template's `panes = false` (set for
|
|
-- the polarity-inverted panel elsewhere in the same drawing)
|
|
-- blocks the global pass. Same mechanism as a recess: the front
|
|
-- voxel is simply not placed.
|
|
local ins = p.inset
|
|
for sx = x0, x1 do
|
|
-- the lid: the part's drawn top laid across its depth from the
|
|
-- back, last row continuing forward; the front lid row is the
|
|
-- facade's own top row -- the drawn front-top edge. `stretch`
|
|
-- maps the drawn band over the whole depth instead, the tray's
|
|
-- rule: for a part authored DEEPER than its drawing (the house
|
|
-- stool grown past its drawn seat), clamping would print the
|
|
-- last row as a long smear off the back band's edge.
|
|
for z = pz, pz + pd - 1 do
|
|
local front = z == pz + pd - 1
|
|
local sy
|
|
if front then
|
|
sy = fr0
|
|
elseif p.stretch then
|
|
sy = math.min(tr0 + math.floor((z - pz) * (tr1 - tr0 + 1)
|
|
/ (pd - 1)), tr1)
|
|
else
|
|
sy = math.min(tr0 + z - pz, tr1)
|
|
end
|
|
while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end
|
|
local ok = sy <= tr1 or (front and inside[fr0 * W + sx])
|
|
if ok and z >= 0 and z < D then
|
|
put(sx, ytp, z, (front and fr0 or sy) * W + sx)
|
|
end
|
|
end
|
|
-- the body: facade rows anchored to the part's own base
|
|
for sy = fr0 + 1, fr1 do
|
|
local y = base + (fr1 - sy)
|
|
local i = sy * W + sx
|
|
if inside[i] then
|
|
local ix = interiorAt(sx, sy, x0, x1)
|
|
for z = pz, pz + pd - 1 do
|
|
if z >= 0 and z < D then
|
|
if z == pz + pd - 1 then
|
|
local sunk = ins and sx >= ins.x[1] and sx <= ins.x[2]
|
|
and sy >= ins.rows[1] and sy <= ins.rows[2]
|
|
if not sunk and not pr.recess[i] then put(sx, y, z, i) end
|
|
elseif z == pz then
|
|
put(sx, y, z, i)
|
|
else
|
|
put(sx, y, z, sy * W + ix)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- A TRAY is an open container -- the drawing looks down INTO it, so its
|
|
-- top-view band is not a lid but the inside of the box, and the model
|
|
-- has to be hollow. Bands, all measured 1:1 like any other band table:
|
|
-- `top` is the opening (drawn row -> depth row), `front` the near wall
|
|
-- seen face-on (drawn row -> elevation), `x` the box's outer span and
|
|
-- `inner` the opening's, so the difference between them is the wall.
|
|
-- Four walls stand to the rim, the floor slab lies `floor` voxels thick
|
|
-- under the opening, and the cavity between them is left as AIR -- which
|
|
-- is the whole point, and what an extruded facade can never be. Parts (a
|
|
-- standing lid) then ride the rim like any object on a desk's plane.
|
|
if t.tray then
|
|
local tr = t.tray
|
|
local top0 = tr.top[1]
|
|
local fr0, fr1 = tr.front[1], tr.front[2]
|
|
local bx0, bx1 = tr.x[1], tr.x[2]
|
|
local ix0, ix1 = tr.inner[1], tr.inner[2]
|
|
local floor = tr.floor or 0
|
|
local plane = fr1 - fr0 + 1 -- the rim: the wall's height
|
|
-- Which drawn row lies at depth z. The far rim is the band's first
|
|
-- row and the near rim the front wall's own, and the drawn inside
|
|
-- STRETCHES over whatever depth is between them: a box deeper than
|
|
-- its drawing has rows to spare is the ordinary case once the plot
|
|
-- stops being the grid, and the alternative -- running out of rows
|
|
-- and repeating the last one -- would print the wrench twice.
|
|
local lo, hi = top0 + 1, tr.top[2] - 1 -- the drawn inside
|
|
local span = math.max(1, D - 3) -- interior depth rows - 1
|
|
local function trayRow(z)
|
|
if z == 0 then return top0 end
|
|
if z == D - 1 then return fr0 end
|
|
return lo + math.floor((z - 1) * (hi - lo) / span)
|
|
end
|
|
for sx = bx0, bx1 do
|
|
Budget.tick()
|
|
for z = 0, D - 1 do
|
|
local hollow = sx >= ix0 and sx <= ix1 and z > 0 and z < D - 1
|
|
for y = 0, (hollow and floor or plane - 1) do
|
|
if hollow or y == plane - 1 then
|
|
-- the opening seen from above: the tray's own floor and
|
|
-- whatever lies in it -- and the rim is the same band where
|
|
-- the wall meets it
|
|
local i = trayRow(z) * W + sx
|
|
if inside[i] then put(sx, y, z, i) end
|
|
else
|
|
-- the wall below the rim: the front band folded up it, the
|
|
-- drawn face on the front and back layers and the de-outlined
|
|
-- interior between, exactly as a facade extrudes.
|
|
--
|
|
-- NO recess pass here, and it must stay that way: a pane sinks
|
|
-- by DELETING its front voxel so the one behind becomes the
|
|
-- pane, and a container's wall is one voxel thick -- there is
|
|
-- nothing behind it, so the front panel simply opened a hole
|
|
-- straight into the box and you could see the wrench through it.
|
|
local sy = fr1 - y
|
|
local i = sy * W + sx
|
|
if inside[i] then
|
|
local px = (z == 0 or z == D - 1) and sx
|
|
or interiorAt(sx, sy, bx0, bx1)
|
|
put(sx, y, z, sy * W + px)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if plane > ytop then ytop = plane end
|
|
buildParts(plane)
|
|
return { at = function(x, y, z)
|
|
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then
|
|
return nil
|
|
end
|
|
return vox[key(x, y, z)]
|
|
end,
|
|
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
|
|
end
|
|
|
|
-- No base piece at all: the drawing IS its parts (the house stool -- a
|
|
-- seat and its legs, nothing under them but floor). The plane the parts
|
|
-- anchor to is the ground itself.
|
|
if not t.desk then
|
|
buildParts(0)
|
|
return { at = function(x, y, z)
|
|
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then
|
|
return nil
|
|
end
|
|
return vox[key(x, y, z)]
|
|
end,
|
|
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
|
|
end
|
|
|
|
-- The desk's top plane. Usually the drawing states it: the fascia and
|
|
-- base rows it paints below the objects ARE the front face, and their
|
|
-- row count is the height. Bill's desk paints neither inside its grid
|
|
-- -- its apron is drawn into the WALKABLE cell in front, and that cell
|
|
-- is left out on purpose so the chair standing there keeps its own
|
|
-- tiles -- so `plane` names the height directly and the body below the
|
|
-- lid is synthesized: the band table's own rim treatment, a shaded box
|
|
-- closed by the outline where it meets the floor, in the drawing's
|
|
-- shades via shadeTexel.
|
|
local f0, f1 = t.desk.fascia[1], t.desk.fascia[2]
|
|
local b0, b1 = t.desk.base[1], t.desk.base[2]
|
|
local plane = (b1 - b0 + 1) + (f1 - f0 + 1)
|
|
|
|
-- The desk's own PLOT, when the grid holds more than the desk. Bill's
|
|
-- grid runs on into the walkable cell, because the drawing puts the
|
|
-- desk's apron AND the chair pushed up to it in the same tiles -- so
|
|
-- the desk box has to stop at its own cell (`depth`) and stand on its
|
|
-- own ground line rather than the grid's, which the chair's feet set
|
|
-- eight rows lower. The base band's last row IS that ground line by
|
|
-- definition, and for every desk drawn inside its own grid it is the
|
|
-- measured one to the row (lab table, lab computers, Center PC, the
|
|
-- Bike Shop toolbox), so this changes nothing for them.
|
|
-- ...and in voxels (`depthPx`) plus a back origin (`z`) when the desk
|
|
-- is shallower than a tile row and leans against something: the
|
|
-- healing machine's cabinet is 10 deep -- its drawn top band's 9 rows
|
|
-- plus the front edge -- standing against the wall band, so its box
|
|
-- runs z 16..25 of a 32-deep plot.
|
|
local deskD = t.desk.depthPx or (t.desk.depth and t.desk.depth * 8) or D
|
|
local dz0 = t.desk.z or 0
|
|
local dz1 = dz0 + deskD - 1
|
|
local deskG = b1 + 1
|
|
-- ...and the desk's COLUMNS (`x`), when the grid is wider than the
|
|
-- desk: the healing machine's grid carries its flanking hoses and
|
|
-- keyboard, and the cabinet is only the middle 16 columns.
|
|
local dx0 = t.desk.x and t.desk.x[1] or 0
|
|
local dx1 = t.desk.x and t.desk.x[2] or W - 1
|
|
|
|
-- The WALL element: the band the machine backs onto, whose tiles this
|
|
-- grid claims. The drawing shows it only as the stripe background
|
|
-- around the tower (the same standing as the potted plants' floor),
|
|
-- so the block cycles the drawing's own stripe unit -- real pixels of
|
|
-- column `x`, rows `cycle` -- at wall-band height over the back plot,
|
|
-- exactly what the neighbouring cells' `wall` pins render.
|
|
if t.wall then
|
|
local wl = t.wall
|
|
local c0, c1 = wl.cycle[1], wl.cycle[2]
|
|
local cn = c1 - c0 + 1
|
|
local wx = wl.x or 0
|
|
for y = 0, wl.h - 1 do
|
|
Budget.tick()
|
|
local sy = c0 + (wl.h - 1 - y) % cn
|
|
for sx = 0, W - 1 do
|
|
for z = 0, wl.depthPx - 1 do
|
|
put(sx, y, z, sy * W + wx)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- the base band, extruded exactly like every lab table's
|
|
for sy = b0, b1 do
|
|
Budget.tick()
|
|
local y = deskG - 1 - sy
|
|
for sx = dx0, dx1 do
|
|
if inside[sy * W + sx] then
|
|
local ix = interiorAt(sx, sy, dx0, dx1)
|
|
for z = dz0, dz1 do
|
|
local px = (z == dz0 or z == dz1) and sx or ix
|
|
put(sx, y, z, sy * W + px)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
for i in pairs(pr.recess) do
|
|
local sy = math.floor(i / W)
|
|
local sx = i % W
|
|
if sy >= b0 and sy <= b1 and sx >= dx0 and sx <= dx1 then
|
|
vox[key(sx, deskG - 1 - sy, dz1)] = nil
|
|
end
|
|
end
|
|
|
|
-- the slab: fascia rows wrap every side
|
|
for sy = f0, f1 do
|
|
Budget.tick()
|
|
local y = plane - 1 - (sy - f0)
|
|
for sx = dx0, dx1 do
|
|
for z = dz0, dz1 do put(sx, y, z, sy * W + sx) end
|
|
end
|
|
end
|
|
|
|
if t.desk.top then
|
|
-- The lid wears the desk's own drawn top band -- the drawing DOES
|
|
-- paint this tabletop (the healing machine's white top face with
|
|
-- its lit west and shaded east strips), so nothing is synthesized
|
|
-- where it is visible: band rows map back-to-front, the first
|
|
-- fascia row is the drawn front-top edge, same rule as an upright
|
|
-- part's lid. Where a part's drawing occludes the band (the monitor
|
|
-- standing on it), the lid continues the nearest strip BESIDE the
|
|
-- part -- still the drawing's own pixels, the same sibling-pattern
|
|
-- rule every synthesized lid follows.
|
|
local tr0, tr1 = t.desk.top[1], t.desk.top[2]
|
|
for z = dz0, dz1 do
|
|
Budget.tick()
|
|
local sy = z == dz1 and f0 or math.min(tr0 + (z - dz0), tr1)
|
|
for sx = dx0, dx1 do
|
|
local px = sx
|
|
for _, p in ipairs(t.parts) do
|
|
local px0, px1 = p.x[1], p.x[2]
|
|
local r0, r1
|
|
if p.kind == "flat" or p.kind == "iso" or p.kind == "box" then
|
|
r0, r1 = p.rows[1], p.rows[2]
|
|
else
|
|
r0, r1 = p.top[1], p.facade[2]
|
|
end
|
|
if sx >= px0 and sx <= px1 and sy >= r0 and sy <= r1 then
|
|
px = (sx - px0 < px1 - sx) and (px0 - 1) or (px1 + 1)
|
|
px = math.max(dx0, math.min(dx1, px))
|
|
break
|
|
end
|
|
end
|
|
put(sx, plane - 1, z, sy * W + px)
|
|
end
|
|
end
|
|
else
|
|
-- the lid continues the sibling tables' top -- black rim, white
|
|
-- highlight courses along the north and west, grey field
|
|
local field = t.desk.lid == "white" and WHITE or GREY
|
|
for sx = dx0, dx1 do
|
|
for z = dz0, dz1 do
|
|
local shade = field
|
|
if sx == dx0 or sx == dx1 or z == dz0 or z == dz1 then
|
|
shade = BLACK
|
|
elseif sx == dx0 + 1 or z == dz0 + 1 then
|
|
shade = WHITE
|
|
end
|
|
put(sx, plane - 1, z, pr.shadeTexel[shade])
|
|
end
|
|
end
|
|
end
|
|
|
|
if plane > ytop then ytop = plane end
|
|
buildParts(plane)
|
|
|
|
return { at = function(x, y, z)
|
|
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then return nil end
|
|
return vox[key(x, y, z)]
|
|
end,
|
|
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
|
|
end
|
|
|
|
-- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite
|
|
-- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
|
|
-- order -- roof first, so it overwrites the walls it intersects, and walls
|
|
-- are trimmed to its underside so nothing pokes through the surface.
|
|
local function model(sp, pr, t)
|
|
if t.parts then return deskSetModel(sp, pr, t) end
|
|
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
|
|
-- rim treatment belongs to the outermost drawn columns instead of the
|
|
-- box edge.
|
|
local x0d, x1d
|
|
for x = 0, W - 1 do
|
|
if top[x] < roofRows then
|
|
x0d = x0d or x
|
|
x1d = x
|
|
end
|
|
end
|
|
local ledge0, ledge1 = nil, nil
|
|
if t.ledge then ledge0, ledge1 = t.ledge[1], t.ledge[2] end
|
|
|
|
local rz0, rz1 = 0, D - 1 + (t.frontEave or 0)
|
|
local back, front = t.roofBack, t.roofFront
|
|
local cyc0, cyc1 = t.roofCycle[1], t.roofCycle[2]
|
|
local cycN = cyc1 - cyc0 + 1
|
|
|
|
-- Which drawn row lies at depth z. The drawing looks at the roof from
|
|
-- the north, so its top rows ARE the far edge and its bottom rows the
|
|
-- eave over the facade. The band is shallower than the building, so the
|
|
-- rims map one row per voxel and the middle cycles a run whose period is
|
|
-- the course rhythm -- picked up where the north rim left off, which
|
|
-- continues both the course lines and the roof texture seamlessly.
|
|
local roofSy = {}
|
|
for z = rz0, rz1 do
|
|
local df, db = z - rz0, rz1 - z -- from the north / south edge
|
|
if df < back then
|
|
roofSy[z] = df
|
|
elseif db < front then
|
|
roofSy[z] = roofRows - 1 - db
|
|
else
|
|
roofSy[z] = cyc0 + (df - cyc0) % cycN
|
|
end
|
|
end
|
|
|
|
local T = {}
|
|
for x = 0, W - 1 do T[x] = ytop - top[x] end
|
|
|
|
local function at(x, y, z)
|
|
if x < 0 or x >= W then return nil end
|
|
local tx = T[x]
|
|
|
|
-- roof: a solid of constant thickness following the elevation profile
|
|
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. 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 < surfaceTop[x] then sy = surfaceTop[x] end
|
|
return sy * W + x
|
|
end
|
|
-- The rim reproduces the eave the drawing itself paints under the
|
|
-- roof: a black outline, a shaded fascia, closed by the outline
|
|
-- again. (A GREY fascia band -- what the first cut had -- comes out
|
|
-- WHITE once the atlas is recoloured and turns every sloped end
|
|
-- into a black-and-white zip.) Under the surface it is all shadow.
|
|
local outer = x == x0d or x == x1d or z == rz0 or z == rz1
|
|
if not outer then return pr.shadeTexel[DARK] end
|
|
if y == tx or y == tx - slab + 1 then return pr.shadeTexel[BLACK] end
|
|
return pr.shadeTexel[DARK]
|
|
end
|
|
|
|
-- trimmed: under the slope. A column with no roof over it has no
|
|
-- underside to trim to, and must not be cut away by a profile the
|
|
-- drawing never set.
|
|
if top[x] < roofRows and y > tx - slab then return nil end
|
|
|
|
-- the awning: the band juts two voxels past the walls, front and back
|
|
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
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- the facade, extruded straight back over the footprint. Rows map
|
|
-- against the measured ground line, not the grid's last row: the two
|
|
-- differ only for furniture standing on open floor (see measure).
|
|
if z < 0 or z >= D then return nil end
|
|
local sy = ground - 1 - y
|
|
local i = sy * W + x
|
|
if y == 0 and not sp.inside[i] and sy > 0 and sp.inside[i - W] then
|
|
-- the drawing's last row is the ground the building stands on, so
|
|
-- its base course is one row up; without this the walls float a
|
|
-- voxel over their own plot
|
|
sy, i = sy - 1, i - W
|
|
end
|
|
if not sp.inside[i] then return nil end
|
|
if z == D - 1 then
|
|
if pr.recess[i] then return nil end
|
|
return i
|
|
end
|
|
if z == 0 then return i end
|
|
return pr.interior[i]
|
|
end
|
|
|
|
return { at = at, W = W, ytop = ytop,
|
|
zmin = ledge0 and -2 or 0,
|
|
zmax = math.max(rz1, ledge0 and (D + 1) or 0) }
|
|
end
|
|
|
|
-- ------------------------------------------------------------------ emit --
|
|
|
|
-- Cull to the shell and merge. A run of faces collapses into one quad when
|
|
-- its texels are the SAME (a flat-coloured strip, which is most of a side
|
|
-- face) or ADJACENT IN THE ATLAS along the run (the drawing continuing
|
|
-- across the face, which is most of a front face or a roof top). Both keep
|
|
-- every texel exactly where the sprite put it.
|
|
local function emit(m, sp, atlasW, atlasH)
|
|
local W = m.W
|
|
local quads = { voxels = 0, shell = 0 }
|
|
local cell = {} -- (y, z, x) -> sprite pixel index
|
|
|
|
local zmin, zmax, ytop = m.zmin, m.zmax, m.ytop
|
|
local zn = zmax - zmin + 1
|
|
local function ci(x, y, z)
|
|
if x < 0 or x >= W or y < 0 or y > ytop or z < zmin or z > zmax then
|
|
return nil
|
|
end
|
|
return cell[(y * zn + (z - zmin)) * W + x]
|
|
end
|
|
for y = 0, ytop do
|
|
Budget.tick()
|
|
for z = zmin, zmax do
|
|
local base = (y * zn + (z - zmin)) * W
|
|
for x = 0, W - 1 do
|
|
local v = m.at(x, y, z)
|
|
cell[base + x] = v
|
|
if v then quads.voxels = quads.voxels + 1 end
|
|
end
|
|
end
|
|
end
|
|
-- the shell: what survives hidden-face culling. Counted here rather than
|
|
-- derived from the quads because it is the number
|
|
-- tools/building_voxels.py checks this build against.
|
|
for y = 0, ytop do
|
|
Budget.tick()
|
|
for z = zmin, zmax do
|
|
for x = 0, W - 1 do
|
|
if ci(x, y, z) and not (ci(x + 1, y, z) and ci(x - 1, y, z)
|
|
and ci(x, y + 1, z) and ci(x, y - 1, z)
|
|
and ci(x, y, z + 1) and ci(x, y, z - 1)) then
|
|
quads.shell = quads.shell + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- u/v of a run: `n` texels starting at sprite pixel `i`, stepping along
|
|
-- the atlas when the run is a strip and standing still when it is flat.
|
|
local function uvOf(i, strip, n)
|
|
local x0 = sp.ax[i]
|
|
local y0 = sp.ay[i]
|
|
local x1 = strip and (x0 + n) or (x0 + 1)
|
|
return (x0 + 0.05) / atlasW, (x1 - 0.05) / atlasW,
|
|
(y0 + 0.05) / atlasH, (y0 + 1 - 0.05) / atlasH
|
|
end
|
|
|
|
local function put(c1, c2, c3, c4, uv, shade)
|
|
quads[#quads + 1] = { c1, c2, c3, c4, uv = uv, shade = shade }
|
|
end
|
|
|
|
-- How far a run of exposed faces reaches from `x`, and whether it is a
|
|
-- strip (texels marching along the atlas) or flat (one texel repeated).
|
|
local function runX(y, z, dx, dy, dz, x)
|
|
local i0 = ci(x, y, z)
|
|
local strip, n = nil, 1
|
|
local cap = runCap(x)
|
|
while n < cap do
|
|
local nx = x + n
|
|
local i = ci(nx, y, z)
|
|
if not i or ci(nx + dx, y + dy, z + dz) then break end
|
|
local prev = ci(nx - 1, y, z)
|
|
if sp.ay[i] ~= sp.ay[prev] then break end
|
|
local d = sp.ax[i] - sp.ax[prev]
|
|
if d == 1 then
|
|
if strip == false then break end
|
|
strip = true
|
|
elseif d == 0 then
|
|
if strip == true then break end
|
|
strip = false
|
|
else
|
|
break
|
|
end
|
|
n = n + 1
|
|
end
|
|
return i0, strip == true, n
|
|
end
|
|
|
|
-- ---- faces along +-Z (the facade, the roof's rims): merge along x ----
|
|
for _, d in ipairs({ 1, -1 }) do
|
|
local shade = d == 1 and SHADE.south or SHADE.north
|
|
for y = 0, ytop do
|
|
Budget.tick()
|
|
for z = zmin, zmax do
|
|
local x = 0
|
|
while x < W do
|
|
if ci(x, y, z) and not ci(x, y, z + d) then
|
|
local i, strip, n = runX(y, z, 0, 0, d, x)
|
|
local u0, u1, v0, v1 = uvOf(i, strip, n)
|
|
local zf = d == 1 and (z + 1) or z
|
|
if d == 1 then
|
|
put({ x, y, zf }, { x + n, y, zf },
|
|
{ x + n, y + 1, zf }, { x, y + 1, zf },
|
|
{ { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, shade)
|
|
else
|
|
put({ x + n, y, zf }, { x, y, zf },
|
|
{ x, y + 1, zf }, { x + n, y + 1, zf },
|
|
{ { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } }, shade)
|
|
end
|
|
x = x + n
|
|
else
|
|
x = x + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- faces along +-Y (roof surfaces, undersides): merge along x ----
|
|
for _, d in ipairs({ 1, -1 }) do
|
|
local shade = d == 1 and SHADE.top or SHADE.bottom
|
|
for y = 0, ytop do
|
|
Budget.tick()
|
|
-- the underside of the bottom layer is the ground it stands on
|
|
if not (d == -1 and y == 0) then
|
|
for z = zmin, zmax do
|
|
local x = 0
|
|
while x < W do
|
|
if ci(x, y, z) and not ci(x, y + d, z) then
|
|
local i, strip, n = runX(y, z, 0, d, 0, x)
|
|
local u0, u1, v0, v1 = uvOf(i, strip, n)
|
|
local yf = d == 1 and (y + 1) or y
|
|
if d == 1 then
|
|
put({ x, yf, z }, { x + n, yf, z },
|
|
{ x + n, yf, z + 1 }, { x, yf, z + 1 },
|
|
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } }, shade)
|
|
else
|
|
put({ x, yf, z + 1 }, { x + n, yf, z + 1 },
|
|
{ x + n, yf, z }, { x, yf, z },
|
|
{ { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, shade)
|
|
end
|
|
x = x + n
|
|
else
|
|
x = x + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- faces along +-X (the flanks): merge along z, one texel each ----
|
|
for _, d in ipairs({ 1, -1 }) do
|
|
for y = 0, ytop do
|
|
for x = 0, W - 1 do
|
|
local z = zmin
|
|
while z <= zmax do
|
|
local i = ci(x, y, z)
|
|
if i and not ci(x + d, y, z) then
|
|
local n, cap = 1, runCap(z)
|
|
while n < cap and z + n <= zmax do
|
|
local j = ci(x, y, z + n)
|
|
if j ~= i or ci(x + d, y, z + n) then break end
|
|
n = n + 1
|
|
end
|
|
local u0, u1, v0, v1 = uvOf(i, false, n)
|
|
local xf = d == 1 and (x + 1) or x
|
|
if d == 1 then
|
|
put({ xf, y, z + n }, { xf, y, z },
|
|
{ xf, y + 1, z }, { xf, y + 1, z + n },
|
|
{ { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
|
|
SHADE.side)
|
|
else
|
|
put({ xf, y, z }, { xf, y, z + n },
|
|
{ xf, y + 1, z + n }, { xf, y + 1, z },
|
|
{ { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
|
|
SHADE.side)
|
|
end
|
|
z = z + n
|
|
else
|
|
z = z + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return quads
|
|
end
|
|
|
|
-- ------------------------------------------------------------- placement --
|
|
|
|
-- Does the template's tile grid sit at (tx, ty)?
|
|
local function matches(S, t, tx, ty)
|
|
local tiles = t.tiles
|
|
for r = 1, #tiles do
|
|
local row = tiles[r]
|
|
for c = 1, #row do
|
|
if S.tileAt[keyOf(tx + c - 1, ty + r - 1)] ~= row[c] then
|
|
return false
|
|
end
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Find every placement of every template for this map's tileset, build one
|
|
-- model per template, and stamp it. Returns nothing; the quads land in
|
|
-- S.objectQuads and the tiles are claimed so the volume path never boxes a
|
|
-- building this module has already modelled.
|
|
function Buildings.build(S, map, data, perRow)
|
|
if not data then return end
|
|
local tileset = map.tileset
|
|
local s = profile()
|
|
local list = s and s.buildings and s.buildings[tileset.id]
|
|
if not list then return end
|
|
|
|
local atlasW = tileset.imageWidth or 128
|
|
local atlasH = tileset.imageHeight or 48
|
|
local tw, th = map.def.width * 4, map.def.height * 4
|
|
local quads = S.objectQuads
|
|
|
|
for index, t in ipairs(list) do
|
|
if type(t.tiles) == "table" and #t.tiles > 0 then
|
|
local bh, bw = #t.tiles, #t.tiles[1]
|
|
local first = t.tiles[1][1]
|
|
local built = nil
|
|
for ty = 0, th - bh do
|
|
Budget.tick()
|
|
for tx = 0, tw - bw do
|
|
-- A placement never stamps into cells another template already
|
|
-- claimed. Templates are matched independently, and one
|
|
-- drawing can satisfy two grids: the Pokemon Tower's upper
|
|
-- twelve rows on ROUTE_10 are a standard 6-cell block tile for
|
|
-- tile, so `gabled_block_6x6` matched there and stood a whole
|
|
-- second building behind the tower. First claim wins, so the
|
|
-- list order below is the priority order -- the tower's own
|
|
-- templates come first precisely so they take those cells.
|
|
local free = S.tileAt[keyOf(tx, ty)] == first
|
|
if free then
|
|
for r = 0, bh - 1 do
|
|
for c = 0, bw - 1 do
|
|
if S.skip[keyOf(tx + c, ty + r)] then
|
|
free = false
|
|
break
|
|
end
|
|
end
|
|
if not free then break end
|
|
end
|
|
end
|
|
if free and matches(S, t, tx, ty) then
|
|
if not built then
|
|
local key = tileset.id .. ":" .. index
|
|
if not models[key] then
|
|
if t.claimOnly then
|
|
-- claim the cells, stamp nothing: the drawing here is
|
|
-- the off-map half of a building another map models in
|
|
-- full (the tower's roof rows on ROUTE_10 -- Lavender's
|
|
-- placement composites them via topRows). Left to the
|
|
-- detector they stood as a second half-building.
|
|
models[key] = {}
|
|
else
|
|
local sp = read(t, data, perRow)
|
|
local pr = measure(sp, t)
|
|
models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH)
|
|
end
|
|
end
|
|
built = models[key]
|
|
end
|
|
Buildings.stamp(S, map, built, tx, ty, bw, bh, t)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- One placement: claim its tiles (so the detector leaves them alone and
|
|
-- the mesher paints ground under them) and copy the model into place.
|
|
--
|
|
-- Two template fields alter what a claim means, for a drawing that
|
|
-- carries a STANDEE on its surface (Red's potted plant on the dining
|
|
-- table). `keep` names tile ids the stamp must NOT claim: their authored
|
|
-- pins stay live, so the standee scan still stands the object exactly as
|
|
-- it always did. `support` is the model's top plane in voxels: the claim
|
|
-- shape carries it as its height, which is what tells that scan the
|
|
-- standee's shelf -- a plain claim stays at h = 0, and Structures treats
|
|
-- a building claim with height as a full model (skip, never a second
|
|
-- box; see its support branches).
|
|
function Buildings.stamp(S, map, quads, tx, ty, bw, bh, t)
|
|
local shape = { class = "building", h = (t and t.support) or 0,
|
|
art = "building", flat = false, authored = true }
|
|
local keep = nil
|
|
if t and t.keep then
|
|
keep = {}
|
|
for _, id in ipairs(t.keep) do keep[id] = true end
|
|
end
|
|
|
|
-- the ground the building stands on: the commonest flat tile around its
|
|
-- feet, so a house on a path keeps its path
|
|
local votes, best, bestN = {}, nil, 0
|
|
local function vote(x, y)
|
|
local k = keyOf(x, y)
|
|
local ns = S.shapeAt[k]
|
|
if ns and ns.flat and ns.class ~= "void" then
|
|
local tile = S.tileAt[k]
|
|
votes[tile] = (votes[tile] or 0) + 1
|
|
if votes[tile] > bestN then best, bestN = tile, votes[tile] end
|
|
end
|
|
end
|
|
for c = 0, bw - 1 do
|
|
vote(tx + c, ty - 1)
|
|
vote(tx + c, ty + bh)
|
|
end
|
|
for r = 0, bh - 1 do
|
|
vote(tx - 1, ty + r)
|
|
vote(tx + bw, ty + r)
|
|
end
|
|
|
|
for r = 0, bh - 1 do
|
|
for c = 0, bw - 1 do
|
|
local k = keyOf(tx + c, ty + r)
|
|
if keep and keep[S.tileAt[k]] then
|
|
-- unclaimed by request: the tile keeps its pin (the plant's
|
|
-- cutout pool) and the standee scan finds it there. Only the
|
|
-- ground is set now, so the scan's own claim of these tiles has
|
|
-- the building's floor to paint when no flat tile touches a
|
|
-- cluster ringed by its own furniture.
|
|
S.ground[k] = best or false
|
|
else
|
|
S.shapeAt[k] = shape
|
|
S.skip[k] = true
|
|
S.ground[k] = best or false
|
|
end
|
|
end
|
|
end
|
|
|
|
local mx, mz = tx * 8, ty * 8
|
|
local out = S.objectQuads
|
|
for _, q in ipairs(quads) do
|
|
out[#out + 1] = {
|
|
{ q[1][1] + mx, q[1][2], q[1][3] + mz },
|
|
{ q[2][1] + mx, q[2][2], q[2][3] + mz },
|
|
{ q[3][1] + mx, q[3][2], q[3][3] + mz },
|
|
{ q[4][1] + mx, q[4][2], q[4][3] + mz },
|
|
uv = q.uv, shade = q.shade,
|
|
-- placements only ever scan the BODY, so a building is always this
|
|
-- map's own structure: the mesher's edge keep-rules must not eat
|
|
-- the parts that poke past the boundary (an edge-row house's eave
|
|
-- juts frontEave voxels into the neighbour's airspace, and the
|
|
-- neighbour-body mask read that overhang as a ring scrap -- which
|
|
-- opened the roof rim into the sky from across the seam)
|
|
own = true,
|
|
}
|
|
end
|
|
end
|
|
|
|
-- What the models built so far cost, keyed "<tileset>:<index>": the voxel
|
|
-- and shell counts tools/building_voxels.py checks this implementation
|
|
-- against (Stage 5 of the methodology), and the quad count that ships.
|
|
function Buildings.stats()
|
|
local out = {}
|
|
for key, quads in pairs(models) do
|
|
out[key] = { voxels = quads.voxels, shell = quads.shell,
|
|
quads = #quads }
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- Drop the prebuilt models (hot reload, or a mod shadowing the profile).
|
|
function Buildings.invalidate()
|
|
spec = nil
|
|
models = {}
|
|
end
|
|
|
|
return Buildings
|