--
-- Minetest mod to build a street from given points
-- © 2021-06-03 secklsurvival@gerloni.net
-- LGPL-3.0-or-later
-- https://spdx.org/licenses/LGPL-3.0-or-later.html
--

-- To use the functions outside this Mod
streetbuilder={}

local mod_storage=minetest.get_mod_storage()

minetest.register_chatcommand('street',{
  params='<streetname> <command> [<options>]',
  description='Edit, show and build streets. Valid commands are:\
  /street\
    Show all streets currently saved in the mod storage.\
  /street <streetname> set point <N>|next <X>,<Y>,<Z>\
    Set point <N> or next point of street <streetname> to position <X>,<Y>,<Z>.\
    If no position is given then the current player position is used. \
  /street <streetname> set point all <X1>,<Y1>,<Z1> <X2>,<Y2>,<Z2> <Xn>,<Yn>,<Zn>\
    Replace all points of street <streetname> with the given positions. Previous points will be deleted!\
  /street <streetname> set bordernode|roadnode|linenode|piersnode|pointsnode <nodename>\
    Set the border/road surface/center line/bridge piers/original points node type of street <streetname> to <nodename>.\
    If <nodename> is omitted the node type will be reset to default.\
    If pointsnode is set then the manually set points will be marked (for debugging).\
  /street <streetname> set air|pierdist <N>\
    Set the number of air nodes above street/the distance between bridge piers.\
    If air is not set the default value is used.\
    If pierdist is not set no bridge piers are built.\
  /street <streetname> ins point <N> <X>,<Y>,<Z>\
    Insert point <N> of street <streetname> at position <X>,<Y>,<Z>.\
    If no position is given then the current player position is used.\
  /street <streetname> del point <N>\
    Delete point <N> of street <streetname>.\
  /street <streetname> get\
  /street <streetname> get all\
    Show all parameters of street <streetname>.\
  /street <streetname> get point <N>\
    Show the position of point <N> in street <streetname>.\
  /street <streetname> get point\
  /street <streetname> get point all\
    Show all points in street <streetname>.\
  /street <streetname> get bordernode|roadnode|linenode|piersnode|pointsnode\
    Show the node type of the border/road surface/center line/bridge piers/original points of street <streetname>.\
  /street <streetname> build\
    Generate the street <streetname> in the current world.\
  /street <streetname> vanish\
    Make the street <streetname> disappear in the current world. The volume around the street will be re-generated to its original state.\
  /street <streetname> delete\
    Delete all data of street <streetname> from the mod storage.\
    If you already generated the street in the world you may want to issue "/street <streetname> vanish" before deleting its data.',
  privs={server=true},
  func=function(plname,params)

    -- Example:    /street strnameXY cmdXY opt1XY opt2XY opt3XY opt4XY
    -- Variables contain: |-strname-|-cmd-|-opt1-|-opt2-|----opt3-----|
    local strname,cmd,opt1,opt2,opt3=params:match('^(%S*)%s*(%S*)%s*(%S*)%s*(%S*)%s*(.*)$')

    if strname=='' then

      local strlist=''
      for k,v in pairs(mod_storage:to_table().fields) do
        strlist=strlist..' '..k
      end
      if strlist=='' then
        return false,'Error: No streets are stored yet.'
      else
        return true,'Currently saved streets:'..strlist..'.'
      end
      return

    elseif strname:match('^[A-Za-z0-9-_]+$') then

      -- Read data of street to edit
      local street=minetest.deserialize(mod_storage:get_string(strname))

      -- Default street parameters
      local str_default_par=streetbuilder.build_street()
      -- Set a description for parameters that default to nil
      str_default_par['pointsnode']='do not mark the original points'
      str_default_par['pierdist']='do not build bridge piers'

      local x,y,z
      local pnum, param
      local ppos={}

      if cmd=='set' then

        if street==nil then street={} end
        if street.points==nil then street.points={} end
        if street.params==nil then street.params={} end

        if opt1=='point' then
          if opt2:match('^%d+$') or opt2=='next' then
            if opt2:match('^%d+$') then
              pnum=tonumber(opt2)
              if pnum > #street.points+1 then
               return false,'Error: Unable to set point '..pnum..' of street "'..strname..'". Points must be set one after the other, set point '..  #street.points+1  ..' first.'
              end
            else
              pnum=#street.points+1
            end
            if opt3=='' then
              ppos=minetest.get_player_by_name(plname):get_pos()
              ppos.x=round(ppos.x)
              ppos.y=round(ppos.y)
              ppos.z=round(ppos.z)
              street.points[pnum]=ppos
              mod_storage:set_string(strname,minetest.serialize(street))
              return true,'Point '..pnum..' of street "'..strname..'" set to player position '..ppos.x..','..ppos.y..','..ppos.z..'.'
            elseif opt3:match('^%-?%d+,%-?%d+,%-?%d+$') then
              x,y,z=opt3:match('^(%-?%d+),(%-?%d+),(%-?%d+)$')
              ppos={x=x,y=y,z=z}
              street.points[pnum]=ppos
              mod_storage:set_string(strname,minetest.serialize(street))
              return true,'Point '..pnum..' of street "'..strname..'" set to position '..ppos.x..','..ppos.y..','..ppos.z..'.'
            else
              return false,'Error: Cannot read position '..opt3..': Wrong format.'
            end
          elseif opt2=='all' then
            local i=1
            while (opt3:match('^%-?%d+,%-?%d+,%-?%d+%s*.*$')) do
              x,y,z,opt3=opt3:match('^(%-?%d+),(%-?%d+),(%-?%d+)%s*(.*)$')
              ppos[i]={x=x,y=y,z=z}
              i=i+1
            end
            if opt3~='' then
              minetest.chat_send_player(plname,'Warning: Ignoring position(s) "'..opt3..'": Wrong format.')
            end
            if #street.points<1 then
              if #ppos<1 then
                return false,'Street "'..strname..'" did not contain any points and no new points provided.'
              else
                street.points=ppos
                mod_storage:set_string(strname,minetest.serialize(street))
                if #ppos==1 then
                  return true,'One new point set for street "'..strname..'" at position '..ppos[1].x..','..ppos[1].y..','..ppos[1].z..'.'
                else
                  return true,#ppos..' new points set for street "'..strname..'".'
                end
              end
            else
              street.points=ppos
              mod_storage:set_string(strname,minetest.serialize(street))
              if #ppos<1 then
                return true,'Removed all points from street "'..strname..'". No new points provided.'
              elseif #ppos==1 then
                return true,'Replaced all points of street "'..strname..'" by one new point at position '..ppos[1].x..','..ppos[1].y..','..ppos[1].z..'.'
              else
                return true,'Replaced all points of street "'..strname..'" by '..#ppos..' new points.'
              end
            end
          end
        elseif (opt1=='bordernode' or opt1=='roadnode' or opt1=='linenode' or opt1=='piersnode' or opt1=='pointsnode') then
          if opt2=='' then
            street.params[opt1]=nil
            mod_storage:set_string(strname,minetest.serialize(street))
            return true,opt1..' of street "'..strname..'" set to default ('..str_default_par[opt1]..').'
          elseif opt3=='' then
            street.params[opt1]=opt2
            mod_storage:set_string(strname,minetest.serialize(street))
            return true,opt1..' of street "'..strname..'" set to '..opt2..'.'
          end
        elseif (opt1=='width' or opt1=='air' or opt1=='pierdist') then
          if opt2=='' then
            street.params[opt1]=nil
            mod_storage:set_string(strname,minetest.serialize(street))
            return true,opt1..' of street "'..strname..'" set to default ('..str_default_par[opt1]..').'
          elseif opt2:match('^%d+$') and opt3=='' then
            street.params[opt1]=tonumber(opt2)
            mod_storage:set_string(strname,minetest.serialize(street))
            return true,opt1..' of street "'..strname..'" set to '..opt2..'.'
          end
        end

      elseif cmd=='ins' and opt1=='point' and opt2:match('^%d+$') then

        if street==nil then street={} end
        if street.points==nil then street.points={} end
        if street.params==nil then street.params={} end

        pnum=tonumber(opt2)
        if pnum<=#street.points then
          if opt3=='' then
            ppos=minetest.get_player_by_name(plname):get_pos()
            ppos.x=round(ppos.x)
            ppos.y=round(ppos.y)
            ppos.z=round(ppos.z)
            table.insert(street.points,pnum,ppos)
            mod_storage:set_string(strname,minetest.serialize(street))
            return true,'Added point number '..pnum..' to street "'..strname..'" at player position '..ppos.x..','..ppos.y..','..ppos.z..'.'
          elseif opt3:match('^%-?%d+,%-?%d+,%-?%d+$') then
            x,y,z=opt3:match('^(%-?%d+),(%-?%d+),(%-?%d+)$')
            ppos={x=x,y=y,z=z}
            table.insert(street.points,pnum,ppos)
            mod_storage:set_string(strname,minetest.serialize(street))
            return true,'Added point number '..pnum..' to street "'..strname..'" at position '..ppos.x..','..ppos.y..','..ppos.z..'.'
          else
            return false,'Error: Cannot read position '..opt3..': Wrong format.'
          end
        else
          return true,'Error: Point number '..pnum..' does not exist in street "'..strname..'"'
        end

      elseif cmd=='del' and opt1=='point' and opt2:match('^%d+$') and opt3=='' then

        if street==nil then street={} end
        if street.points==nil then street.points={} end
        if street.params==nil then street.params={} end

        pnum=tonumber(opt2)
        if pnum<=#street.points then
          ppos=street.points[pnum]
          table.remove(street.points,pnum)
          mod_storage:set_string(strname,minetest.serialize(street))
          return true,'Removed point number '..pnum..' from street "'..strname..'" at position '..ppos.x..','..ppos.y..','..ppos.z..'.'
        else
          return false,'Error: Point number '..pnum..' does not exist in street "'..strname..'"'
        end

      elseif cmd=='get' then

        if street==nil then
          return false,'Street "'..strname..'" does not exist.'
        elseif (opt1=='' or opt1=='all') and opt2=='' then
          minetest.chat_send_player(plname,'Properties of street "'..strname..'":')
          if #street.points<1 then
            minetest.chat_send_player(plname,' The Street has no points yet.')
          else
            for i=1,#street.points do
              minetest.chat_send_player(plname,' Point '..i..': '..street.points[i].x..','..street.points[i].y..','..street.points[i].z)
            end
          end
          for k,v in pairs({'bordernode','roadnode','linenode','piersnode','pointsnode','width','air','pierdist'}) do
            if street.params[v] then
              minetest.chat_send_player(plname,' '..v..' is set to '..street.params[v]..'.')
            else
              minetest.chat_send_player(plname,' '..v..' is not set. Using default ('..str_default_par[v]..').')
            end
          end
          return
        elseif opt1=='point' and opt2:match('^%d+$') and opt3=='' then
          pnum=tonumber(opt2)
          if street.points[pnum] then
            return true,'Point '..pnum..' of street "'..strname..'" is at position '..street.points[pnum].x..','..street.points[pnum].y..','..street.points[pnum].z..'.'
          else
            return false,'Error: Point '..pnum..' of street "'..strname..'" is not set.'
          end
        elseif opt1=='point' and (opt2=='' or opt2=='all') and opt3=='' then
          if #street.points<1 then
            return false,'Error: Street "'..strname..'" has no points yet.'
          else
            minetest.chat_send_player(plname,'Points of street "'..strname..'":')
            for i=1,#street.points do
              minetest.chat_send_player(plname,i..': '..street.points[i].x..','..street.points[i].y..','..street.points[i].z)
            end
          end
          return
        elseif (opt1=='bordernode' or opt1=='roadnode' or opt1=='linenode' or opt1=='piersnode' or opt1=='pointsnode' or opt1=='width' or opt1=='air' or opt1=='pierdist') and opt2=='' then
          if street.params[opt1] then
            return true,opt1..' of street "'..strname..'" is set to '..street.params[opt1]..'.'
          else
            if str_default_par[opt1] then
              return true,opt1..' of street "'..strname..'" is not set. Using default ('..str_default_par[opt1]..').'
            else
              return true,opt1..' of street "'..strname..'" is not set. No piers will be built.'
            end
          end
        end

      elseif (cmd=='build' or cmd=='vanish') and opt1=='' then

        if #street.points<1 then
          return false,'Error: Street "'..strname..'" does not contain any points. Aborting.'
        elseif cmd=='build' then
          streetbuilder.build_street(plname,street.points,street.params)
        else
          streetbuilder.build_street(plname,street.points,street.params,true)
        end
        return

      elseif cmd=='delete' and opt1=='' then

        if street==nil then
          return false,'Error: Street "'..strname..'" does not exist.'
        else
          mod_storage:set_string(strname,nil)
          return true,'Removed street "'..strname..'".'
        end

      end

      return false,'Error: Invalid usage, see /help street.'

    else

      return false,'Error: Street names may only contain the following characters: A-Z,a-z,0-9,-,_'

    end

  end
})

-- Build a street along the given points. The function returns a table with the parameters the street was build with.
-- Examples:
--   streetbuilder.build_street(plname,{ {x=114,y=7,z=595}, {x=129,y=9,z=588}, {x=137,y=10,z=565}, {x=113,y=9,z=535} })                                                         -- Build the street with default parameter
--   streetbuilder.build_street(plname,{ {x=114,y=7,z=595}, {x=129,y=9,z=588}, {x=137,y=10,z=565}, {x=113,y=9,z=535} },{roadnode='default:stone_block',bordernode='wool:blue'}) -- Build the street with the given node types
--   streetbuilder.build_street(plname,{ {x=114,y=7,z=595}, {x=129,y=9,z=588}, {x=137,y=10,z=565}, {x=113,y=9,z=535} },{pierdist=30,piersnode='wool:yellow'})                   -- Build bridge piers below the street
--   streetbuilder.build_street(plname,{ {x=114,y=7,z=595}, {x=129,y=9,z=588}, {x=137,y=10,z=565}, {x=113,y=9,z=535} },{pointsnode='wool:red'})                                 -- Mark the original points (for debugging)
--   streetbuilder.build_street(plname,{ {x=114,y=7,z=595}, {x=129,y=9,z=588}, {x=137,y=10,z=565}, {x=113,y=9,z=535} },{},true)                                                 -- Re-generate the original world
function streetbuilder.build_street(plname,points,params,vanish)

  -- Function arguments
  local points=points or {}                -- Points the street goes through
  local params=params or {}                -- Parameters of the street
  local vanish=vanish or false             -- Re-generate the original world around the street if true

  -- Sanitized parameters of the street and default values
  local str_par={}
  str_par.bordernode='default:meselamp'    -- Border node type
  str_par.roadnode='default:stone'         -- Road node type
  str_par.linenode=str_par.roadnode        -- Center line node type
  str_par.piersnode='default:stone_block'  -- Bridge pier node type
  str_par.pointsnode=nil                   -- Original points node type (nil: do not mark the original points)
  str_par.width=3                          -- Width of one lane (the street will be roughly 2 x width + 1 nodes nodes broad)
  str_par.air=5                            -- Number of air nodes above street
  str_par.pierdist=nil                     -- Distance between bridge piers (nil: do not build bridge piers)

  -- Sanitized points the street goes through
  local str_points={}

  -- x/y/z coordinates of generated street
  local spline={}                          -- Center line of road
  local border={}                          -- Border that remains after placing the road
  local road={}                            -- Road
  local line={}                            -- Center line
  local air={}                             -- Air nodes above road and border
  local piers={}                           -- Bridge piers below street

  -- Locally used coordinates and max value
  local x,y,z
  local ppos,cpos1,cpos2={},{},{}
  local worldlimit=30912-200               -- https://wiki.minetest.net/World_boundaries minus some safety distance

  -- Read function arguments and check for validity
  for k,v in pairs({'bordernode','roadnode','linenode','piersnode','pointsnode'}) do
    if params[v] then
      if minetest.registered_nodes[params[v]] then
        str_par[v]=params[v]
      elseif not vanish then
        minetest.chat_send_player(plname,'Warning: Node name '..params[v]..' is not defined. '..str_par[v]..' will be used for '..v..'.')
      end
    end
  end
  for k,v in pairs({'pierdist','width','air'}) do
    if params[v] then
      if tonumber(params[v]) then
        if params[v]<0 then
          minetest.chat_send_player(plname,'Warning: Invalid '..v..' '..params[v]..'. '..v..' must be between 0 and 100. Setting '..v..' to 0.')
          str_par[v]=0
        elseif params[v]>100 then
          minetest.chat_send_player(plname,'Warning: Invalid '..v..' '..params[v]..'. '..v..' must be between 0 and 100. Setting '..v..' to 100.')
          str_par[v]=100
        else
          str_par[v]=params[v]
        end
      else
        minetest.chat_send_player(plname,'Warning: Invalid '..v..' '..params[v]..'. '..v..' must be a number between 0 and 100. Ignoring '..v..'.')
      end
    end
  end
  -- Read points and check for validity
  for i=1,#points do
    for k,v in pairs({'x','y','z'}) do
      ppos[v]=tonumber(points[i][v])
      if ppos[v] then
        if ppos[v]<-worldlimit then
          minetest.chat_send_player(plname,'Warning: Coordinates must be greater than -'..worldlimit..'. Setting '..v..' coordinate of point '..i..' to -'..worldlimit..' (was '..ppos[v]..').')
          ppos[v]=-worldlimit
        elseif ppos[v]>worldlimit then
          minetest.chat_send_player(plname,'Warning: Coordinates must be less than '..worldlimit..'. Setting '..v..' coordinate of point '..i..' to '..worldlimit..' (was '..ppos[v]..').')
          ppos[v]=worldlimit
        end
      else
        minetest.chat_send_player(plname,'Warning: '..v..' coordinate of point '..i..' is invalid. Aborting.')
        return str_par
      end
    end
    str_points[i]={x=ppos.x,y=ppos.y,z=ppos.z}
  end

  if #str_points<1 then
    return str_par
  end

  spline=calculate_spline(str_points)

  if vanish then
    minetest.chat_send_player(plname,'Deleting street...')
    for i=1,#spline do
      cpos1={x=spline[i].x-str_par.width,y=spline[i].y+str_par.air,z=spline[i].z-str_par.width}
      cpos2={x=spline[i].x+str_par.width,y=spline[i].y            ,z=spline[i].z+str_par.width}
      -- Since there may be bridge piers below the street we re-generate everything down to y=-200
      if cpos2.y>-200 then
        cpos2.y=-200
      end
      minetest.delete_area(cpos1,cpos2)
    end
    minetest.chat_send_player(plname,'Street deleted.')
    return str_par
  end

  minetest.chat_send_player(plname,'To make the street build reliably we emerge the area around the street ...')
  for i=1,#spline do
    cpos1={x=spline[i].x-str_par.width,y=spline[i].y+str_par.air,z=spline[i].z-str_par.width}
    cpos2={x=spline[i].x+str_par.width,y=spline[i].y            ,z=spline[i].z+str_par.width}
    -- Since there may be bridge piers below the street we emerge everything down to y=-200 
    if cpos2.y>-200 then
      cpos2.y=-200
    end
    local vm=minetest.get_voxel_manip()
    vm:read_from_map(cpos1,cpos2)
  end
  minetest.chat_send_player(plname,'Area emerged.')

  minetest.chat_send_player(plname,'Building street '.. 2*str_par.width+1 ..' blocks broad with '..str_par.air..' nodes of air above...')

  minetest.chat_send_player(plname,' Calculating coordinates')
  -- Border blocks with air above
  for i=1,#spline do
    y=spline[i].y
    for dx=-str_par.width,str_par.width do
      x=spline[i].x+dx
      for dz=-str_par.width,str_par.width do
        z=spline[i].z+dz
        table.insert(border,{x=x,y=y,  z=z})
        table.insert(border,{x=x,y=y-1,z=z})
        for a=1,str_par.air do
          table.insert(air,{x=x,y=y+a,z=z})
        end
      end
    end
  end
  -- Road blocks within the border blocks
  for i=1,#spline do
    y=spline[i].y
    for dx=-str_par.width+1,str_par.width-1 do
      x=spline[i].x+dx
      for dz=-str_par.width+1,str_par.width-1 do
        z=spline[i].z+dz
        table.insert(road,{x=x,y=y,  z=z})
        table.insert(road,{x=x,y=y-1,z=z})
        table.insert(road,{x=x,y=y-2,z=z})
      end
    end
  end
  -- Center line blocks within the road blocks and bridge piers
  local pdist=str_par.pierdist
  for i=1,#spline do
    x=spline[i].x
    y=spline[i].y
    z=spline[i].z
    table.insert(line,{x=x,y=y,  z=z})
    table.insert(line,{x=x,y=y-1,z=z})
    table.insert(line,{x=x,y=y-2,z=z})
    table.insert(road,{x=x,y=y-3,z=z})
    if str_par.pierdist then
      pdist=pdist+1
      if pdist>=str_par.pierdist then
        y=y-4
        -- Build bridge piers down as long as we are in air, liquids or trees. Also overwrite existing bridge piers in case some pier nodes are left from a previously failed build attempt.
        local solidground=false
        while solidground==false do
          solidground=true
          for dx=-1,1 do
            for dy=-1,1 do
              for dz=-1,1 do
                local nodehere=minetest.get_node({x=x+dx,y=y+dy,z=z+dz}).name
                if nodehere=='air' or minetest.registered_nodes[nodehere].drawtype=='liquid' or minetest.get_item_group(nodehere,"leaves")>0 or minetest.get_item_group(nodehere,'tree')>0 or nodehere==str_par.piersnode then
                  solidground=false
                end
                table.insert(piers,{x=x+dx,y=y+dy,z=z+dz})
              end
            end
          end
          y=y-1
        end
        pdist=0
      end
    end
  end

  minetest.chat_send_player(plname,' Placing nodes')
  minetest.bulk_set_node(border,{name=str_par.bordernode})
  minetest.bulk_set_node(road,{name=str_par.roadnode})
  minetest.bulk_set_node(line,{name=str_par.linenode})
  minetest.bulk_set_node(air,{name="air"})
  if str_par.pierdist then
    minetest.bulk_set_node(piers,{name=str_par.piersnode})
  end
  if str_par.pointsnode then
    minetest.bulk_set_node(str_points,{name=str_par.pointsnode})
  end

  minetest.chat_send_player(plname,'Building street done.')

  return str_par

end

-- Round float to int
function round(float)
  return math.floor(float+0.5)
end

-- Calculate a smooth line through the given points x/y/z.
-- The returned table contains the points of the line with integer x/y/z coordinates.
function calculate_spline(points)

  if #points<2 then
    return points
  elseif #points==2 then
    points[3]={x=points[2].x,y=points[2].y,z=points[2].z}
    points[2].x=(points[1].x+points[3].x)/2
    points[2].y=(points[1].y+points[3].y)/2
    points[2].z=(points[1].z+points[3].z)/2
  end

  local spline={}
  local p0,p1,p2,p3,x,y,z

  for i=1,#points-1 do

    if i==1 then
      p0,p1,p2,p3=points[i],  points[i],points[i+1],points[i+2]
    elseif i==#points-1 then
      p0,p1,p2,p3=points[i-1],points[i],points[i+1],points[i+1]
    else
      p0,p1,p2,p3=points[i-1],points[i],points[i+1],points[i+2]
    end

    -- steps must be big enough to ensure that there is no gap in spline
    local steps=3
    steps=4*math.max(steps,math.abs(p1.x-p2.x),math.abs(p1.y-p2.y),math.abs(p1.z-p2.z))

    for t = 0,1,1/steps do

      x=round(( (2*p1.x) + (p2.x-p0.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t^2 + (3*p1.x-p0.x-3*p2.x+p3.x)*t^3 )/2)
      y=round(( (2*p1.y) + (p2.y-p0.y)*t + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t^2 + (3*p1.y-p0.y-3*p2.y+p3.y)*t^3 )/2)
      z=round(( (2*p1.z) + (p2.z-p0.z)*t + (2*p0.z-5*p1.z+4*p2.z-p3.z)*t^2 + (3*p1.z-p0.z-3*p2.z+p3.z)*t^3 )/2)

      -- Prevent duplicate entries in spline
      if not(#spline>0 and spline[#spline].x==x and spline[#spline].y==y and spline[#spline].z==z) then
        table.insert(spline,{x=x,y=y,z=z})
      end

    end

  end

  -- Ensure that the last point is contained in spline
  x=round(points[#points].x)
  y=round(points[#points].y)
  z=round(points[#points].z)
  if not(spline[#spline].x==x and spline[#spline].y==y and spline[#spline].z==z) then
    table.insert(spline,{x=x,y=y,z=z})
  end

  return spline

end
