local class = require('class') local DEFLATE = require('deflatelua') local UI = require('ui') local Logger = require('logger') local Profile = require('profile') local Point = require('point') --[[ Loading and manipulating a schematic --]] local schematicMagic = 0x0a00 local gzipMagic = 0x1f8b local Schematic = class() function Schematic:init(args) self.blocks = { } self.damages = { } self.originalBlocks = { } self.placementChains = { } self.x, self.y, self.z = 0, 0, 0 self.height = 0 self.index = 1 end --[[ Credit to Orwell for the schematic file reader code http://www.computercraft.info/forums2/index.php?/topic/1949-turtle-schematic-file-builder/ Some parts of the file reader code was modified from the original --]] function Schematic:discardBytes(h, n, spinner) for i = 1,n do h:readbyte() if (i % 1000) == 0 then spinner:spin() end end end function Schematic:readname(h) local n1 = h:readbyte(h) local n2 = h:readbyte(h) if(n1 == nil or n2 == nil) then return "" end local n = n1*256 + n2 local str = "" for i=1,n do local c = h:readbyte(h) if c == nil then return end str = str .. string.char(c) end return str end function Schematic:parse(a, h, containsName, spinner) if a==0 then return end local name if containsName then name = self:readname(h) end if a==1 then self:discardBytes(h, 1, spinner) elseif a==2 then local i1 = h:readbyte(h) local i2 = h:readbyte(h) local i = i1*256 + i2 if(name=="Height") then --self.height = i elseif (name=="Length") then self.length = i elseif (name=="Width") then self.width = i end return 2 elseif a==3 then self:discardBytes(h, 4, spinner) return 4 elseif a==4 then self:discardBytes(h,8, spinner) return 8 elseif a==5 then self:discardBytes(h,4, spinner) return 4 elseif a==6 then self:discardBytes(h,8, spinner) elseif a==7 then local i1 = h:readbyte(h) local i2 = h:readbyte(h) local i3 = h:readbyte(h) local i4 = h:readbyte(h) local i = bit.blshift(i1, 24) + bit.blshift(i2, 16) + bit.blshift(i3, 8) + i4 if not self.length or not self.width then self:discardBytes(h,i, spinner) self.twopass = true elseif name == "Blocks" then for i = 1, i do local id = h:readbyte(h) self:assignCoord(i, id) if (i % 1000) == 0 then spinner:spin() end end elseif name == "Data" then for i = 1, i do local dmg = h:readbyte(h) if dmg > 0 then self.damages[i] = dmg end if (i % 1000) == 0 then spinner:spin() end end else self:discardBytes(h,i, spinner) end elseif a==8 then local i1 = h:readbyte(h) local i2 = h:readbyte(h) local i = i1*256 + i2 self:discardBytes(h,i, spinner) elseif a==9 then local type = h:readbyte(h) local i1 = h:readbyte(h) local i2 = h:readbyte(h) local i3 = h:readbyte(h) local i4 = h:readbyte(h) local i = bit.blshift(i1, 24) + bit.blshift(i2, 16) + bit.blshift(i3, 8) + i4 for j=1,i do self:parse(type, h, false, spinner) end elseif a > 11 then error('invalid tag') end end -- end http://www.computercraft.info/forums2/index.php?/topic/1949-turtle-schematic-file-builder/ function Schematic:copyBlocks(iblocks, oblocks, spinner) Profile.start('copyBlocks') for k,b in ipairs(iblocks) do oblocks[k] = Util.shallowCopy(b) if spinner then if (k % 1000) == 0 then spinner:spin() end end end Profile.stop('copyBlocks') end function Schematic:reload() self.placementChains = {} self.blocks = { } self:copyBlocks(self.originalBlocks, self.blocks) --[[ self.planes = { } for i = 0, self.height - 1 do self.planes[i] = { } end for k,b in ipairs(self.blocks) do if not self.planes[b.y].start then self.planes[b.y].start = k end end --]] end function Schematic:getMagic(fh) fh:open() local magic = fh:readbyte() * 256 + fh:readbyte() fh:close() return magic end function Schematic:isCompressed(filename) local h = fs.open(filename, "rb") if not h then error('unable to open: ' .. filename) end local magic = h.read() * 256 + h.read() h.close() return magic == gzipMagic end function Schematic:checkFileType(fh) local magic = self:getMagic(fh) if magic ~= schematicMagic then error('Unknown file type') end end local DiskFile = class() function DiskFile:init(args) Util.merge(self, args) end function DiskFile:open() self.h = fs.open(self.filename, "rb") if not self.h then error('unable to open: ' .. self.filename) end end function DiskFile:readbyte() return self.h.read() end function DiskFile:close() self.h.close() end local MemoryFile = class() function MemoryFile:init(args) self.s = { } self.i = 1 end function MemoryFile:open(filename) self.i = 1 end function MemoryFile:close() end function MemoryFile:readbyte() local b = self.s[self.i] self.i = self.i + 1 return b end function MemoryFile:write(b) self.s[#self.s + 1] = b end function Schematic:decompress(ifname, spinner) Profile.start('decompress') local ifh = fs.open(ifname, "rb") if not ifh then error('Unable to open ' .. ifname) end local mh = MemoryFile() DEFLATE.gunzip({ input=function(...) spinner:spin() return ifh.read() end, output=function(b) mh:write(b) end, disable_crc=true }) ifh.close() spinner:stop() Profile.stop('decompress') return mh end function Schematic:loadpass(fh, spinner) Profile.start('load') fh:open() while true do local a = fh:readbyte() if not a then break end self:parse(a, fh, true, spinner) if self.twopass and self.width and self.length then break end spinner:spin() end fh:close() Profile.stop('load') self:assignDamages(spinner) self.damages = nil self:copyBlocks(self.blocks, self.originalBlocks, spinner) Profile.display() spinner:stop() end function Schematic:load(filename) local cursorX, cursorY = term.getCursorPos() local spinner = UI.Spinner({ x = UI.term.width, y = cursorY - 1 }) local f if self:isCompressed(filename) then local originalFile = filename filename = originalFile .. '.uncompressed' if not fs.exists(filename) then print('Decompressing') f = self:decompress(originalFile, spinner) end end self.filename = string.match(filename, '([^/]+)$') if not f then f = DiskFile({ filename = filename }) end self:checkFileType(f) --[[ local size = fs.getSize(filename) local buffer = { h = h, i = 1, s = { }, l = size, } for i = 1,size do buffer.s[i] = h.read() end function buffer:readbyte() --return self.h.read() local b = self.s[self.i] self.i = self.i + 1 return b end ]]-- print('Initial pass ') self:loadpass(f, spinner) if self.twopass then self.twopass = nil self.blocks = { } self.damages = { } self.originalBlocks = { } self.placementChains = { } self.x, self.y, self.z = 0, 0, 0 self.height = 0 self.index = 1 print('Second pass ') self:loadpass(f, spinner) end end function Schematic:assignCoord(i, id) if id > 0 then table.insert(self.blocks, { id = id, index = i, x = self.x, z = self.z, y = self.y, }) end self.x = self.x + 1 if self.x >= self.width then self.x = 0 self.z = self.z + 1 end if self.z >= self.length then self.z = 0 self.y = self.y + 1 end if self.y >= self.height then self.height = self.y + 1 end end function Schematic:assignDamages(spinner) local i = 0 Profile.start('assignDamages') print('Assigning damages') for _,b in pairs(self.blocks) do b.dmg = self.damages[b.index] or 0 i = i + 1 if (i % 1000) == 0 then spinner:spin() end end Profile.stop('assignDamages') end function Schematic:findIndexAt(x, z, y) if y < 0 then return end local ri = self.rowIndex[y] if ri then for i = ri.s, ri.e do local b = self.blocks[i] if b.x == x and b.z == z and b.y == y then if b.id == 'minecraft:air' then -- this will definitely screw up placement order if a substition is made with air after starting -- as blocks will be placed differently and could have a different heading break end return i, b end end end end function Schematic:findBlockAtSide(b, side) local hi = turtle.getHeadingInfo(side) local index = self:findIndexAt(b.x + hi.xd, b.z + hi.zd, b.y + hi.yd) if index then return self.blocks[index] -- could be better end end function Schematic:addPlacementChain(chain) local t = { } for _,v in ipairs(chain) do local k = self:findIndexAt(v.x, v.z, v.y) if k then local b = self.blocks[k] -- could be better b.index = v.y * self.width * self.length + v.z * self.width + v.x + 1 table.insert(t, b) end end if #t > 1 then local keys = { } for _,b in pairs(t) do keys[b.index] = true end table.insert(self.placementChains, { blocks = t, keys = keys }) end end function Schematic:bestSide(b, ...) local directions = { ... } local blocks = { } for k,d in pairs(directions) do local hi = turtle.getHeadingInfo(d) local sb = self:findIndexAt(b.x - hi.xd, b.z - hi.zd, b.y) if not sb then b.heading = turtle.getHeadingInfo(d).heading b.direction = d .. '-block' return end blocks[k] = { b = self.blocks[sb], hi = hi, d = d } end local bestBlock for _,sb in ipairs(blocks) do if not sb.b.direction then -- could be better bestBlock = sb break end end if not bestBlock then local sideDirections = { [ 'east-block' ] = 'east', [ 'south-block' ] = 'south', [ 'west-block' ] = 'west', [ 'north-block' ] = 'north' } for _,sb in ipairs(blocks) do if not bestBlock then bestBlock = sb end if not sideDirections[sb.b.direction] then bestBlock = sb break end end end local hi = bestBlock.hi b.heading = hi.heading -- ????????????????????????????????? b.direction = bestBlock.d .. '-block' self:addPlacementChain({ { x = b.x, z = b.z, y = b.y }, { x = b.x - hi.xd, z = b.z - hi.zd, y = b.y } }) end function Schematic:bestOfTwoSides(b, side1, side2) -- could be better local sb local fb = b -- first block local lb = b -- last block local od = b.direction -- original direction -- find the last block in the row with the same two-sided direction while true do sb = self:findBlockAtSide(lb, side2) if not sb or sb.direction ~= b.direction then break end lb = sb end -- find the first block while true do sb = self:findBlockAtSide(fb, side1) if not sb or sb.direction ~= b.direction then break end fb = sb end -- set the placement order to side1 -> side2 if fb ~= lb then -- only 1 block local pc = { } -- placementChain b = fb while true do table.insert(pc, { x = b.x, z = b.z, y = b.y }) b.direction = side1 .. '-block' b.heading = turtle.getHeadingInfo(side1).heading if b == lb then break end b = self:findBlockAtSide(b, side2) end self:addPlacementChain(pc) end -- can we place the first block from the side (instead of using piston) ? sb = self:findBlockAtSide(fb, side1) if not sb then local ub = self:findBlockAtSide(fb, 'down') if not ub then fb.direction = side1 .. '-block' fb.heading = turtle.getHeadingInfo(side1).heading else fb.direction = od end else -- really should use placement chain fb.direction = od end -- can we place the last block from the side (instead of using piston) ? sb = self:findBlockAtSide(lb, side2) if not sb then local ub = self:findBlockAtSide(lb, 'down') if not ub then lb.direction = side1 .. '-block' lb.heading = turtle.getHeadingInfo(side1).heading else fb.direction = od end else lb.direction = od end end -- Determine the best way to place each block function Schematic:determineBlockPlacement(row) -- NOTE: blocks are evaluated top to bottom local spinner = UI.Spinner({ x = 1, spinSymbols = { 'o.....', '.o....', '..o...', '...o..', '....o.', '.....o' } }) local stairDownDirections = { [ 'north-down' ] = 'north', [ 'south-down' ] = 'south', [ 'east-down' ] = 'east', [ 'west-down' ] = 'west' } local stairUpDirections = { [ 'east-up' ] = { 'east', 'east-block', 1, 0, 'west-block' }, [ 'west-up' ] = { 'west', 'west-block', -1, 0, 'east-block' }, [ 'north-up' ] = { 'north', 'north-block', 0, -1, 'south-block' }, [ 'south-up' ] = { 'south', 'south-block', 0, 1, 'north-block' } } local twoSideDirections = { [ 'east-west-block' ] = true, [ 'north-south-block' ] = true, } local directions = { [ 'north' ] = 'north', [ 'south' ] = 'south', [ 'east' ] = 'east', [ 'west' ] = 'west', } local blockDirections = { [ 'east-block' ] = 'east', [ 'south-block' ] = 'south', [ 'west-block' ] = 'west', [ 'north-block' ] = 'north', } local doorDirections = { [ 'east-door' ] = 'east', [ 'south-door' ] = 'south', [ 'west-door' ] = 'west', [ 'north-door' ] = 'north', } local vineDirections = { [ 'east-block-vine' ] = 'east-block', [ 'south-block-vine' ] = 'south-block', [ 'west-block-vine' ] = 'west-block', [ 'north-block-vine' ] = 'north-block' } local dirtyBlocks = {} local dirtyBlocks2 = {} self.rowIndex = { } for k,b in ipairs(self.blocks) do local ri = self.rowIndex[b.y] if not ri then self.rowIndex[b.y] = { s = k, e = k } else ri.e = k end end for k,b in ipairs(self.blocks) do local d = b.direction if d then if vineDirections[d] then local _, aboveBlock = self:findIndexAt(b.x, b.z, b.y+1) if aboveBlock and aboveBlock.id == b.id and aboveBlock.dmg == b.dmg and aboveBlock.direction == d then -- only need to place top vine b.id = 'minecraft:air' b.dmg = 0 b.direction = nil else b.direction = vineDirections[d] table.insert(dirtyBlocks, b) end elseif twoSideDirections[d] then table.insert(dirtyBlocks2, b) else table.insert(dirtyBlocks, b) end spinner:spin(#dirtyBlocks + #dirtyBlocks2 .. ' blocks remaining ') end end -- Util.filterInplace(dirtyBlocks, function(b) return b.id ~= 'minecraft:air' end) -- remove directional info from slabs where possible -- iterate backwards to process top planes first for k = #dirtyBlocks, 1, -1 do local b = dirtyBlocks[k] local d = b.direction if d == 'top' then -- slab occupying top of voxel -- can be placed from the top if there is no block below local belowBlock = self:findIndexAt(b.x, b.z, b.y-1) if not belowBlock then b.direction = nil table.remove(dirtyBlocks, k) end elseif d == 'bottom' then local _,db = self:findIndexAt(b.x, b.z, b.y-1) if db then if not db.direction or db.direction ~= 'bottom' then -- not a slab below, ok to place from above b.direction = nil end -- it is a slab below - must be pistoned table.remove(dirtyBlocks, k) end end spinner:spin(#dirtyBlocks + #dirtyBlocks2 .. ' blocks remaining ') end -- iterate through the directional blocks setting the placement strategy while #dirtyBlocks > 0 do local b = table.remove(dirtyBlocks) local d = b.direction or '' spinner:spin(#dirtyBlocks + #dirtyBlocks2 .. ' blocks remaining ') if directions[d] then b.heading = turtle.getHeadingInfo(directions[d]).heading end if doorDirections[d] then local hi = turtle.getHeadingInfo(doorDirections[d]) b.heading = hi.heading b.twoHigh = true self:addPlacementChain({ { x = b.x, z = b.z, y = b.y }, { x = b.x - hi.xd, z = b.z - hi.zd, y = b.y }, }) end if stairDownDirections[d] then if not self:findIndexAt(b.x, b.z, b.y-1) then b.direction = stairDownDirections[b.direction] b.heading = turtle.getHeadingInfo(b.direction).heading else b.heading = turtle.getHeadingInfo(stairDownDirections[b.direction]).heading end end if d == 'bottom' then -- slab occupying bottom of voxel -- can be placed from top if a block is below -- otherwise, needs to be placed from side -- except... if the block below is a slab :( --local _,db = self:findIndexAt(b.x, b.z, b.y-1) --if not db then -- no block below, place from side -- took care of all other cases above self:bestSide(b, 'east', 'south', 'west', 'north') -- elseif not db.direction or db.direction ~= 'bottom' then -- not a slab below, ok to place from above -- b.direction = nil --end -- otherwise, builder will piston it in from above elseif stairUpDirections[d] then -- a directional stair -- turtle can place correctly from above if there is a block below -- otherwise, the turtle must place the block from the same plane -- against another block -- if no block to place against (from side) then the turtle must place from -- the other side -- -- Stair bug in 1.7 - placing a stair southward doesn't respect the turtle's direction -- all other directions are fine -- any stair southwards that can't be placed against another block must be pistoned local sd = stairUpDirections[d] if self:findIndexAt(b.x, b.z, b.y-1) then -- there's a block below b.direction = sd[1] b.heading = turtle.getHeadingInfo(b.direction).heading else local _,pb = self:findIndexAt(b.x + sd[3], b.z + sd[4], b.y) if pb and pb.direction ~= sd[5] then -- place stair against another block (that's not relying on this block to be down first) d = sd[2] -- fall through to the blockDirections code below b.direction = sd[2] else b.heading = (turtle.getHeadingInfo(sd[1]).heading + 2) % 4 end end end if blockDirections[d] then -- placing a block from the side local hi = turtle.getHeadingInfo(blockDirections[d]) b.heading = hi.heading self:addPlacementChain({ { x = b.x + hi.xd, z = b.z + hi.zd, y = b.y }, -- block we are placing against { x = b.x, z = b.z, y = b.y }, -- the block (or torch, etc) { x = b.x - hi.xd, z = b.z - hi.zd, y = b.y } -- room for the turtle }) end end -- pass 3 while #dirtyBlocks2 > 0 do local b = table.remove(dirtyBlocks2) local d = b.direction spinner:spin(#dirtyBlocks2 .. ' blocks remaining ') if d == 'east-west-block' then self:bestOfTwoSides(b, 'east', 'west') elseif d == 'north-south-block' then self:bestOfTwoSides(b, 'north', 'south') end end spinner:stop() term.clearLine() end -- set the order for block dependencies function Schematic:setPlacementOrder() local cursorX, cursorY = term.getCursorPos() local spinner = UI.Spinner({ x = 1 }) Profile.start('overlapping') -- optimize for overlapping check for _,chain in pairs(self.placementChains) do for index,_ in pairs(chain.keys) do if not chain.startRow or (index < chain.startRow) then chain.startRow = index end if not chain.endRow or (index > chain.endRow) then chain.endRow = index end end end local function groupOverlappingChains(t, groupedChain, chain, spinner) local found = true local function overlaps(chain1, chain2) if chain1.startRow > chain2.endRow or chain2.startRow > chain1.endRow then return false end for k,_ in pairs(chain1.keys) do if chain2.keys[k] then return true end end end while found do found = false for k, v in pairs(t) do local o = overlaps(chain, v) if o then table.remove(t, k) table.insert(groupedChain, v) groupOverlappingChains(t, groupedChain, v, spinner) spinner:spin() found = true break end end end end -- group together any placement chains with overlapping blocks local groupedChains = {} while #self.placementChains > 0 do local groupedChain = {} local chain = table.remove(self.placementChains) table.insert(groupedChain, chain) table.insert(groupedChains, groupedChain) groupOverlappingChains(self.placementChains, groupedChain, chain, spinner) spinner:spin('chains: ' .. #groupedChains .. ' ' .. #self.placementChains .. ' ') end Profile.stop('overlapping') --Logger.log('schematic', 'groups: ' .. #groupedChains) --Logger.setFileLogging('chains') local function mergeChains(chains) --[[ Logger.debug('---------------') Logger.log('schematic', 'mergeChains: ' .. #chains) for _,chain in ipairs(chains) do Logger.log('schematic', chain) for _,e in ipairs(chain) do Logger.log('schematic', string.format('%d:%d:%d %s %d:%d', e.block.x, e.block.z, e.block.y, tostring(e.block.direction), e.block.id, e.block.dmg)) end end ]]-- local masterChain = table.remove(chains) --[[ it's something like this: A chain B chain result 1 1 2 -------- 2 2 3 3 4 4 5 5 6 -------- 6 6 7 7 --]] local function splice(chain1, chain2) for k,v in ipairs(chain1) do for k2,v2 in ipairs(chain2) do if v == v2 then local index = k local dupe for i = k2-1, 1, -1 do dupe = false -- traverse back through the first chain aligning on matches for j = index-1, 1, -1 do if chain1[j] == chain2[i] then index = j dupe = true break end end if not dupe then table.insert(chain1, index, chain2[i]) end end index = k+1 for i = k2+1, #chain2, 1 do dupe = false for j = index, #chain1, 1 do if chain1[j] == chain2[i] then index = j dupe = true break end end if not dupe then table.insert(chain1, index, chain2[i]) end index = index + 1 end return true end end end end while #chains > 0 do for k,chain in pairs(chains) do if splice(masterChain.blocks, chain.blocks) then table.remove(chains, k) break end end end --[[ Logger.log('schematic', 'master chain: ') Logger.log('schematic', masterChain) Logger.log('schematic', '---------------') for _,e in ipairs(masterChain.blocks) do Logger.log('schematic', string.format('%d:%d:%d %s %s:%d', e.x, e.z, e.y, tostring(e.direction), e.id, e.dmg)) end --]] return masterChain end -- combine the individual overlapping placement chains into 1 long master chain Profile.start('masterchains') local masterChains = {} for _,group in pairs(groupedChains) do spinner:spin('chains: ' .. #masterChains) table.insert(masterChains, mergeChains(group)) end Profile.stop('masterchains') local function removeDuplicates(chain) for k,v in ipairs(chain) do for i = #chain, k+1, -1 do if v == chain[i] then v.info = 'Unplaceable' table.remove(chain, i) end end end end -- any chains with duplicates cannot be placed correctly -- there are some cases where a turtle cannot place blocks the same as a player Profile.start('duplicates') for _,chain in pairs(masterChains) do removeDuplicates(chain.blocks) spinner:spin('chains: ' .. #masterChains) --[[ Logger.log('schematic', "MASTER CHAIN") for _,e in ipairs(chain) do Logger.log('schematic', string.format('%d:%d:%d %s %d:%d', e.block.x, e.block.z, e.block.y, tostring(e.block.direction), e.block.id, e.block.dmg)) end --]] end Profile.stop('duplicates') term.clearLine() -- adjust row indices as blocks are being moved Profile.start('reordering') for k,chain in pairs(masterChains) do spinner:spin('chains: ' .. #masterChains - k) local startBlock = table.remove(chain.blocks, 1) startBlock.movedBlocks = chain.blocks for _,b in pairs(chain.blocks) do b.moved = true end end local t = { } for k,b in ipairs(self.blocks) do -- adjust y so the turtle travels above the two high blocks if b.twoHigh then b.y = b.y + 1 end spinner:spin('blocks: ' .. #self.blocks - k .. ' ') if not b.moved then table.insert(t, b) end if b.movedBlocks then for _,mb in ipairs(b.movedBlocks) do table.insert(t, mb) end end end self.blocks = t Profile.stop('reordering') --Logger.setWirelessLogging() term.clearLine() spinner:stop() end function Schematic:optimizeRoute() local function getNearestNeighbor(p, pt, maxDistance) local key, block, heading local moves = maxDistance local function getMoves(b, k) local distance = math.abs(pt.x - b.x) + math.abs(pt.z - b.z) if distance < moves then -- this operation is expensive - only run if distance is close local c, h = Point.calculateMoves(pt, b, distance) if c < moves then block = b key = k moves = c heading = h end end end local mid = pt.index local forward = mid + 1 local backward = mid - 1 while forward <= #p or backward > 0 do if forward <= #p then local b = p[forward] if not b.u then getMoves(b, forward) if moves <= 1 then break end if moves < maxDistance and math.abs(b.z - pt.z) > moves and pt.index > 0 then forward = #p end end forward = forward + 1 end if backward > 0 then local b = p[backward] if not b.u then getMoves(b, backward) if moves <= 1 then break end if moves < maxDistance and math.abs(pt.z - b.z) > moves then backward = 0 end end backward = backward - 1 end end pt.x = block.x pt.z = block.z pt.y = block.y pt.heading = heading pt.index = key block.u = true return block end local pt = { x = -1, z = -1, y = 0, heading = 0 } local t = {} local cursorX, cursorY = term.getCursorPos() local spinner = UI.Spinner({ x = 0, y = cursorY }) local function extractPlane(y) local t = {} local dt = {} for _, b in pairs(self.blocks) do if b.y == y then if b.twoHigh then table.insert(dt, b) else table.insert(t, b) end end end return t, dt end local maxDistance = self.width*self.length Profile.start('optimize') for y = 0, self.height do local percent = math.floor(#t * 100 / #self.blocks) .. '%' spinner:spin(percent) local plane, doors = extractPlane(y) pt.index = 0 for i = 1, #plane do local b = getNearestNeighbor(plane, pt, maxDistance) table.insert(t, b) spinner:spin(percent .. ' ' .. #plane - i .. ' ') end -- all two high blocks are placed last on each plane pt.index = 0 for i = 1, #doors do local b = getNearestNeighbor(doors, pt, maxDistance) table.insert(t, b) spinner:spin(percent .. ' ' .. #doors - i .. ' ') end end Profile.stop('optimize') self.blocks = t spinner:stop(' ') end return Schematic