Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 96 additions & 83 deletions autoceiling.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,51 +10,41 @@
-- Configuration defaults
-------------------------
local CONFIG = {
MAX_FILL_TILES = 4000, -- safety limit
ALLOW_DIAGONALS = false -- can be overridden by parameter
MAX_FILL_TILES = 2000, -- positive integer; safety limit
ALLOW_DIAGONALS = false, -- set true to allow 8-way fill
MAX_LIMIT_HARD = 4000 -- hard clamp to avoid runaway fills
}

-------------------------
-- Utilities and guards
-------------------------
local function err(msg) qerror('AutoCeiling: ' .. tostring(msg)) end

local function try_require(modname)
local ok, mod = pcall(require, modname)
if ok and mod then return mod end
return nil
local function xyz2pos(x, y, z)
return { x = x, y = y, z = z }
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the reason I mentioned xyz2pos() is that it's a standard API call.

You've basically reimplemented it, which is fine. Just saying you didn't need to.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicating code that already exists in the code library is a no-no and will result in a review rejection, so you might as well fix this nw before i get around to finding it myself when i do the review. Don't Repeat Yourself also covers repeating someone else


-- Cache frequently used modules/tables for readability
local maps = dfhack.maps
local constructions = dfhack.constructions
local buildings = dfhack.buildings
local tattrs = df.tiletype.attrs

-------------------------
-- World and map helpers
-------------------------
local W = df.global.world
local XMAX, YMAX, ZMAX = W.map.x_count, W.map.y_count, W.map.z_count

local function in_bounds(x, y, z)
return x >= 0 and y >= 0 and z >= 0 and x < XMAX and y < YMAX and z < ZMAX
end

local function get_block(x, y, z)
return dfhack.maps.getTileBlock(x, y, z)
return maps.isValidTilePos(x, y, z)
end

local function get_tiletype(x, y, z)
local b = get_block(x, y, z)
if not b then return nil end
return b.tiletype[x % 16][y % 16]
return maps.getTileType(x, y, z)
end

local function tile_shape(tt)
if not tt then return nil end
local a = df.tiletype.attrs[tt]
return a and a.shape or nil
end

local function tile_material(tt)
if not tt then return nil end
local a = df.tiletype.attrs[tt]
return a and a.material or nil
local a = tattrs[tt]
return (a and a.shape ~= df.tiletype_shape.NONE) and a.shape or nil
end

-------------------------
Expand All @@ -72,57 +62,51 @@ local function is_walkable_dug(tt)
end

local function is_constructed_tile(x, y, z)
local tt = get_tiletype(x, y, z)
local mat = tile_material(tt)
return mat == df.tiletype_material.CONSTRUCTION
return constructions.findAtTile(x, y, z) ~= nil
end

local function has_any_building(x, y, z)
-- Also detects in-progress constructions as buildings
return dfhack.buildings.findAtTile({ x = x, y = y, z = z }) ~= nil
return buildings.findAtTile(xyz2pos(x, y, z)) ~= nil
end

-------------------------
-- Flood fill
-------------------------
local function push_if_ok(q, visited, x, y, z)
if not in_bounds(x, y, z) then return end
local key = x .. ',' .. y
if visited[key] then return end
local tt = get_tiletype(x, y, z)
if is_walkable_dug(tt) then
visited[key] = true
q[#q + 1] = { x, y }
end
end

local function flood_fill_footprint(seed_x, seed_y, z0)
local footprint = {}
local visited = {}
local q = { { seed_x, seed_y } }
local queue = { { seed_x, seed_y } }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you're special-casing the very first queue[] element and visited[] element. I think that's fine,
but it would be more elegant if you could use push_if_ok(seed values) instead.

I'm not going to game out whether that would work with your current code. Just something to consider.

Maybe something like this would work":

local queue = {}
local queue_pos = 1

local function push_if_ok()
...

push_if_ok(seed_x, seed_y)

Again, I didn't test this.

visited[seed_x .. ',' .. seed_y] = true
local head = 1
while head <= #q and #footprint < CONFIG.MAX_FILL_TILES do
local x, y = table.unpack(q[head]); head = head + 1
footprint[#footprint + 1] = { x = x, y = y }
local queue_pos = 1

local function push_if_ok(x, y)
if not in_bounds(x, y, z0) then return end
local key = x .. ',' .. y
if visited[key] then return end
local tt = get_tiletype(x, y, z0)
if is_walkable_dug(tt) then
visited[key] = true
table.insert(queue, { x, y })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if your queue could directly store pos elements. i.e.
table.insert(queue, xyz2pos(x, y, z0))

Then you could pass the current queue element directly to your functions or to API calls.

You could also just copy a queue element directly into footprint[].

I mean, your code clearly works, but doing it that way seems like it would be more elegant.

Let's see, you still couldn't use a pos as a vistied[] key. You would still need to manually build key so you can test it in one call as an associative array/dictionary.

(There is an API call same_xyz(pos1, pos2) but you can't probe an associative array for the values of a table, while you can test for existence of string key in one probe.)

(Although... if it's the exact same table instead of a copy or a newly-constructed table, an existence probe would work. I think.)

end
end

while queue_pos <= #queue and #footprint < CONFIG.MAX_FILL_TILES do
local x, y = table.unpack(queue[queue_pos])
queue_pos = queue_pos + 1
table.insert(footprint, { x = x, y = y })
push_if_ok(x + 1, y)
push_if_ok(x - 1, y)
push_if_ok(x, y + 1)
push_if_ok(x, y - 1)
if CONFIG.ALLOW_DIAGONALS then
push_if_ok(q, visited, x + 1, y, z0)
push_if_ok(q, visited, x - 1, y, z0)
push_if_ok(q, visited, x, y + 1, z0)
push_if_ok(q, visited, x, y - 1, z0)
push_if_ok(q, visited, x + 1, y + 1, z0)
push_if_ok(q, visited, x + 1, y - 1, z0)
push_if_ok(q, visited, x - 1, y + 1, z0)
push_if_ok(q, visited, x - 1, y - 1, z0)
else
push_if_ok(q, visited, x + 1, y, z0)
push_if_ok(q, visited, x - 1, y, z0)
push_if_ok(q, visited, x, y + 1, z0)
push_if_ok(q, visited, x, y - 1, z0)
push_if_ok(x + 1, y + 1)
push_if_ok(x + 1, y - 1)
push_if_ok(x - 1, y + 1)
push_if_ok(x - 1, y - 1)
end
end

if #q > CONFIG.MAX_FILL_TILES then
if #queue > CONFIG.MAX_FILL_TILES then
dfhack.printerr(('AutoCeiling: flood fill truncated at %d tiles'):format(CONFIG.MAX_FILL_TILES))
end
return footprint
Expand All @@ -131,53 +115,82 @@ end
-------------------------
-- Placement strategies
-------------------------
local function place_planned(bp, x, y, z)
local function place_planned(bp, pos)
local ok, bld = pcall(function()
return dfhack.buildings.constructBuilding{
type = df.building_type.Construction,
subtype = df.construction_type.Floor,
pos = { x = x, y = y, z = z }
pos = pos
}
end)
if not ok or not bld then return false, 'construct-error' end
pcall(function() bp.addPlannedBuilding(bld) end)
return true
end

local function place_native(cons, x, y, z)
if not cons or not cons.designate then return false, 'no-constructions-api' end
local ok, derr = pcall(function()
cons.designate{ pos = { x = x, y = y, z = z }, type = df.construction_type.Floor }
local function place_native(cons, pos)
if not cons or not cons.designateNew then return false, 'no-constructions-api' end

local ok, res = pcall(function()
return cons.designateNew(pos, df.construction_type.Floor, -1, -1)
end)
if not ok then return false, 'designate-error' end
return true
if ok and res then return true end

local ok2, res2 = pcall(function()
return cons.designateNew(pos, df.construction_type.Floor, df.item_type.BOULDER, -1)
end)
if ok2 and res2 then return true end

return false, 'designate-error'
end

-------------------------
-- Main
-------------------------
local utils = require('utils')

local function main(...)
local args = {...}
-- Allow user to set diagonals with parameter 't' or 'true'
if #args > 0 and (args[1] == 't' or args[1] == 'true') then
CONFIG.ALLOW_DIAGONALS = true

for _, raw in ipairs(args) do
local s = tostring(raw):lower()
local num = tonumber(s)
if num then
if num < 1 then err('MAX_FILL_TILES must be >= 1') end
if num > CONFIG.MAX_LIMIT_HARD then
dfhack.printerr(('clamping MAX_FILL_TILES from %d to %d'):format(num, CONFIG.MAX_LIMIT_HARD))
num = CONFIG.MAX_LIMIT_HARD
end
CONFIG.MAX_FILL_TILES = math.floor(num)
elseif s == 't' or s == 'true' then
CONFIG.ALLOW_DIAGONALS = true
elseif s == 'h' or s == 'help' then
print('Usage: autoceiling [t] [<max_fill_tiles>]')
print(' t: enable diagonal flood fill')
print(' <max_fill_tiles>: positive integer, default ' .. CONFIG.MAX_FILL_TILES)
return
elseif s ~= '' then
err('unknown argument: ' .. tostring(raw))
end
end

-- Validate cursor and tile
local cur = df.global.cursor
local cur = utils.clone(df.global.cursor)
if cur.x == -30000 then err('cursor not set. Move to a dug tile and run again.') end
local z0 = cur.z
local seed_tt = get_tiletype(cur.x, cur.y, z0)
if not is_walkable_dug(seed_tt) then err('cursor tile is not dug/open interior') end

-- Discover footprint and target surface level
local footprint = flood_fill_footprint(cur.x, cur.y, z0)
if #footprint == 0 then
print('AutoCeiling: nothing to do — no connected dug tiles found at cursor')
return
end
local z_surface = z0 + 1

-- Load optional DFHack helpers
local bp = try_require('plugins.buildingplan')
-- Require buildingplan directly; let it error if missing
local bp = require('plugins.buildingplan')
if bp and (not bp.isEnabled or not bp.isEnabled()) then bp = nil end
local cons = try_require('dfhack.constructions')
local cons = dfhack.constructions

local placed, skipped = 0, 0
local reasons = {}
Expand All @@ -186,9 +199,9 @@ local function main(...)
reasons[reason] = (reasons[reason] or 0) + 1
end

-- Process each tile
for i = 1, #footprint do
local x, y = footprint[i].x, footprint[i].y
for i, foot in ipairs(footprint) do
local x, y = foot.x, foot.y
local pos = xyz2pos(x, y, z_surface)
if not in_bounds(x, y, z_surface) then
skip('oob')
elseif is_constructed_tile(x, y, z_surface) then
Expand All @@ -198,9 +211,9 @@ local function main(...)
else
local ok, why
if bp then
ok, why = place_planned(bp, x, y, z_surface)
ok, why = place_planned(bp, pos)
else
ok, why = place_native(cons, x, y, z_surface)
ok, why = place_native(cons, pos)
end
if ok then placed = placed + 1 else skip(why or 'unknown') end
end
Expand All @@ -211,7 +224,7 @@ local function main(...)
print(('AutoCeiling: placed %d floor construction(s); skipped %d'):format(placed, skipped))
if bp then
print('buildingplan active: created planned floors that will auto-assign materials')
elseif cons and cons.designate then
elseif cons and cons.designateNew then
print('used native construction designations')
else
print('no buildingplan and no constructions API available')
Expand Down