-- luadraw_polyhdron_net.lua (chargé par luadraw_graph2d.lua)
-- date 2026/08/04
-- version 3.4
-- Copyright 2026 Patrick Fradin
-- This work may be distributed and/or modified under the
-- conditions of the LaTeX Project Public License.
-- The latest version of this license is in
--   https://www.ctan.org/license/lppl

-- net of convex polyhedrons

local ld = luadraw
local cpx, pt3d = ld.cpx, ld.pt3d
local Z, M, vecI, vecJ, vecK = cpx.Z, pt3d.M, pt3d.vecI, pt3d.vecJ, pt3d.vecK
local graph3d = ld.graph3d

local getpos = function(elt, L)
-- renvoie la position de elt dans la liste L
    for k,x in ipairs(L) do
        if x == elt then return k end
    end
end

local common_edge = function(F1,F2)
-- F1, F2 deux faces avec numéros de sommets, on renvoie la position dans F1 des sommets communs (F1 sera l'ancêtre de F2)
    local b, a = F2[1]
    local pos2, pos1 = getpos(b,F1)
    local n = #F2
    for k = 2, n+1 do
        a = b; b = F2[1+(k-1)%n]
        pos1 = pos2; pos2 = getpos(b,F1)
        if (pos1 ~= nil) and (pos2 ~= nil) then 
            return {pos1,pos2}
        end
    end
end

local test_model = function(F,model)
    -- F = facets (with vertices numbers), model = list of lists or facet numbers
    local visited = ld.map(function(k) return false end, ld.range(1,#F))
    local neighbor = function(F1,F2)
        local A = common_edge(F1,F2)
        return (A~= nil)
    end
    local a, b, F2, F1, A
    for _,cp in ipairs(model) do
        b = cp[1]; F2 = F[b]
        for k = 2, #cp do
            a = b; b = cp[k]; F1 = F2; F2 = F[b]
            A = common_edge(F1,F2)
            if A == nil then
                print("net model error : facets "..a.." and "..b.." are not adjacent, aborted.")
                return false
            end
            if visited[b] == true then
                print("net model error : facet "..b.." has two ancestors, aborted.")
                return false
            else
                visited[b] = true
            end
        end
    end
    local list_root = {}
    for k,f in ipairs(visited) do
        if f == false then table.insert(list_root,k) end
    end
    if #list_root ~= 1 then
        print("net model error : "..(#list_root).." facets without ancestor instead of one, aborted.")
        print("facets: ",table.unpack(list_root))
        return false
    end
    return true
end

function ld.unfold_tree(tree, factor, num)
-- tree = { {ancestor, n1, n2, angle, vertices}, ...}
-- num est le numéro de facette (rang d'apparition dans P.facets)
    local rep = {}
    local arbre = table.copy(tree)
    if num ~= nil then -- on cherche le rang de cette facette dans l'arbre)
        for k, elt in ipairs(tree) do
            if num == elt[6] then num = k; break end
        end
    end
    factor = factor or 1
    for k, Z in ipairs(arbre) do
        local parent, a1,a2, angle, S, index = table.unpack(Z)
        angle = factor*angle
        if (num==nil) or (num==k) then -- on tourne tout ou bien la facette k et sa descendance
            if parent == 0 then 
                table.insert(rep,S) -- c'est la facette initiale (root), elle ne tourne pas
                --rep[index] = S
            else
                local F = arbre[parent][5] -- sommets du parent
                local axe = {F[a1], F[a2]-F[a1]}
                local S1 = ld.rotate3d(S,angle,axe) -- rotation de la facette
                Z[5] = S1
                table.insert(rep, S1)
                --rep[index] = S1
                local j, ancestors = 0, {k}
                for _, Y in ipairs(arbre) do
                    j = j+1
                    if getpos(Y[1],ancestors) ~= nil then -- Y fait partie des descendants de Z
                        Y[5] = ld.rotate3d(Y[5],angle,axe) -- on tourne Y
                        table.insert(ancestors,j)  --les descendants de Y doivent tourner eux aussi, ils sont après Y dans l'arbre
                    end
                    end
            end
        else table.insert(rep, S)
        end
    end
    return rep
end


function ld.unfold_polyhedron(P,options)
-- P = polyèdre, options = {opening= dans [0,1], root = n° facette, to2d=false, tabs=false, tabs_wd=0.2, tabs_lg=0.5, rotate=0}
-- renvoie le patron ouvert au taux d'ouverture voulu (entre 0 et 1), le patron est une liste de facettes avec sommets 2d ou 3d.
    options = options or {}
    local opening = options.opening or 1
    local root =  options.root or 1
    local to2d = options.to2d  or false
    local rotangle = options.rotate or 0
    local tabs = options.tabs  or false
    local out = {}
    local model = options.model
    if tabs then to2d = true end
    if to2d then opening = 1 end
    local tabs_wd = options.tabs_wd or 0.2
    local tabs_lg = options.tabs_lg or 0.5
    local lambda = (1-tabs_lg)/2
    
    local S = P.vertices
    local F = P.facets
    local nb_facet = #F
    local visited_facet = ld.map(function() return false end, F)
    local tree_index = ld.range(1,#F)
    local edges_list = {}
    local arbre
    
    local normal_vector = function(face)
    -- renvoie un vecteur normal à la face (face contient les numéros de sommets dans la liste S) 
        local A, B, C = S[face[1]], S[face[2]], S[face[3]]
        return pt3d.prod(B-A,C-A)
    end
    
    local neighbor = function(n, face, num)
    -- n est le vecteur normal à face
    -- ajoute à arbre les facettes adjacentes à face, num est le numéro de la face dans l'arbre
        local Z, A, N
        for k = 1, nb_facet do
            if not visited_facet[k] then
                Z = F[k]
                A = common_edge(face,Z)
                if A ~= nil then
                    N = normal_vector(Z)
                    table.insert(arbre, {num, A[1],A[2], pt3d.angle3d(N,n)*ld.rad, N, Z, k} ) -- on ajoute k, le numéro de la facette dans P
                    tree_index[k] = #arbre -- position de la face k dans l'arbre
                    visited_facet[k] = true
                end
            end
        end 
    end
    
    local facet2tree = function()
    -- P = polyedre convexe
    --conversion en arbre où arbre= { {numero parent, numero point commun 1, numero point commun 2, angle avec parent, vecteur normal, sommets}, ...}
        local face = F[root]
        local N = normal_vector(face)
        --if abs(%2) then viewDir(%2*N) fi,
        arbre = { {0,0,0,0, N, face, root} } -- facette de départ
        visited_facet[root] = true -- première facette en cours de visite
        tree_index[root] = 1
        local k = 1 -- index des faces dans la liste arbre
        while face ~= nil do
            neighbor(N,face,k) -- on ajoute les facettes adjacentes (qui sont dans F) dans l'arbre
            k = k+1 --facette suivante
            if k <= #arbre then
                local f = arbre[k]
                N = f[5] --  vecteur normal de la
                face = f[6] -- facette suivante 
            else face = nil -- on a parcouru arbre
            end
        end
        local rep = {}
        for _, Z in ipairs(arbre) do --on renvoie l'arbre sans les vecteurs normaux 
            local t =  { Z[1], Z[2], Z[3], Z[4], ld.map(function(k) return S[k] end, Z[6]), Z[7]} -- {ancetre,axe1,axe2,angle,sommets, index initial}
            if out ~= nil  then
                table.insert(out, table.copy(t))
            end
            table.insert(rep,t)
        end
        arbre = rep
    end
    
    
    local model2tree = function()
        -- model = { {f1,f2,...fn}, {f2,f4,...},...} les fi sont des numéros de facettes, chaque facette est précédée de son ancêtre (qui doit être une facette adjacente) sauf la facette racine, chaque facette doit avoir exactement un ancêtre (sauf la facette racine). La fonction ne vérifie pas si ces conditions sont remplies, c'est au risque de l'utilisateur...
        local read_model = function()
            local rep = {}
            local visited = ld.map(function(f) return false end, F)
            for _, L in ipairs(model) do
                local b, a = L[1]
                for k = 2, #L do
                    a = b; b = L[k]; 
                    if not visited[b] then table.insert(rep,{a,b}); visited[b] = true end
                end
            end
            for _, L in ipairs(model) do
                local a = L[1]
                if not visited[a] then table.insert(rep,1,{0,a}); root=a; break end -- root
            end
            return rep
        end
        if not test_model(F,model) then arbre = nil; return end
        model = read_model()
        -- mintenant model = { {0,i1}, {i2,i3}, ...} où les i1, i2, ... sont des n°s de facettes dans F par paires : {ancêtre, descendant}
        -- on fait un tri du modèle (les descendants d'une facette doivent être à droite)
        local visited = ld.map( function(k) return false end, model)
        local a, d, m
        local rep = {}
        while #model > 0 do
            m = table.remove(model)
            a, d = table.unpack(m)
            if (a==0) or (visited[a]) then table.insert(rep,m); visited[d] = true
            else table.insert(model,1,m)
            end
        end
        model = rep
        local normals = ld.map( function(f) return normal_vector(f) end, F)
        --local index_tree = map(function(k) return 0 end,model)
        arbre = {}
        for k, m in ipairs(model) do
            a,d = table.unpack(m)
            local Z, face, A, n = F[d]
            local N = normals[d]
            if a == 0 then  
                table.insert(arbre, {0,0,0,0,N,Z,d} )
                --index_tree[d] = #arbre
                tree_index[d] = #arbre -- position de la face k dans l'arbre
            else
                face = F[a]; n = normals[a]
                A = common_edge(face,Z)
                if A ~= nil then
                    table.insert(arbre, {a, A[1],A[2], pt3d.angle3d(N,n)*ld.rad, N, Z, d} )
                    --index_tree[d] = #arbre
                    tree_index[d] = #arbre -- position de la face k dans l'arbre
                else
                    print("net model error: facet "..a.." and facet "..d.." are not adjacent, aborted.\n")
                    arbre = nil; return
                end
            end
        end
        local rep = {}
        for m, Z in ipairs(arbre) do --on renvoie l'arbre sans les vecteurs normaux
            local index
            if Z[1] == 0 then index = 0 else index = tree_index[Z[1]] end
            local t =  {index, Z[2], Z[3], Z[4], ld.map(function(k) return S[k] end, Z[6]), Z[7]} -- {ancetre,axe1,axe2,angle,sommets, index initial}
            if out ~= nil  then
                table.insert(out, table.copy(t))
            end
            table.insert(rep,t)
        end
        arbre = rep
    end
    -- corps de la fonction unfold_polyhedron
    --if root ~= 1 then P.facets[1], P.facets[root] = P.facets[root], P.facets[1]  end -- on met la facette de base en premier
    if model == nil then facet2tree() else model2tree() end -- conversion en arbre
    if arbre == nil then return end
    if opening == 0 then return poly2facet(P)  end -- rien à faire, on renvoie les facettes de P  
    local rep, tabs_list, twin_edges = {}
    rep = ld.unfold_tree(arbre,opening) -- on ouvre le polyèdre
    if to2d then -- conversion en 2d, le patron est à plat dans le plan de l'écran
        local x1,x2,y1,y2,z1,z2 = ld.getbounds3d(rep)
        local G = M((x1+x2)/2, (y1+y2)/2, (z1+z2)/2 )
        local P = ld.facet2plane( rep[1] )
        local A, u, v = ld.orthoframe({G,-P[2]})
        rep = ld.ftransform3d( rep, function(N) return Z(pt3d.dot(u,N-A), pt3d.dot(v,N-A)) end)
        if rotangle ~= 0 then
            rep = ld.rotate(rep,rotangle)
        end
        if tabs then
            tabs_list = {}
            twin_edges = {}
            local a, b, ab, n, id1, id2
            for num,facet in ipairs(F) do -- parcours du polyèdre par facette
                b = facet[1]; n = #facet; id2 = 1
                for k = 2, n+1 do
                    if k == n+1 then k = 1 end
                    a = b; b = facet[k]; id1 = id2; id2 = k --id = n° arête
                    if a > b then 
                        ab = a..";"..b -- pour servir de clé
                    else ab = b..";"..a
                    end
                    if edges_list[ab] == nil then edges_list[ab] = {id1,id2,tree_index[num]}
                    else table.append(edges_list[ab], {id1,id2,tree_index[num]})
                    end
                end
            end
            for _,edge in pairs(edges_list) do
                -- edge est une liste {i1, i2} où i1 et i2 sont les indices dans arbre des deux facettes partagent l'arête
                if #edge == 6 then -- ce n'est pas une arête solitaire
                    local a1,b1,n1,a2,b2,n2 = table.unpack(edge)
                    if (arbre[n1][1] ~= n2) and (arbre[n2][1] ~= n1 ) then -- si une des deux facettes est l'ancêtre de l'autre, elles sont attachées par l'arête dans le patron, donc pas de languette sur cette arête                
                    local A, B, C, D = rep[n1][a1], rep[n1][b1], rep[n2][a2], rep[n2][b2]
                    -- [A,B] est une des deux arêtes qui se confondent quand le polyèdre est replié, mais elles sont différentes sur le patron alors il faut une languette sur une des deux
                        local h = tabs_wd*cpx.I*cpx.normalize(B-A)
                        table.insert(tabs_list, {A, (1-lambda)*A+lambda*B+h, lambda*A+(1-lambda)*B+h,B} )
                        table.insert(twin_edges,{{A,B},{C,D}})
                    end
                end
            end
        end
    elseif rotangle ~= 0 then
        local f = rep[1] -- root facet
        rep = ld.rotate3d(rep, rotangle, {pt3d.isobar3d(f), pt3d.prod(f[2]-f[1],f[3]-f[1])}) 
    end
    local rep2 = ld.map(function(k) return {} end, ld.range(1,#rep))
    for k,f in ipairs(arbre) do
        rep2[f[6]] = rep[k]
    end
    local bounds
    if to2d then
        bounds = {ld.getbounds( ld.concat(rep2,tabs_list) )}
    else
        bounds = {ld.getbounds3d( rep2 )}
    end
    if bounds == nil then bounds= {} end
    local ret = {["facets"]=rep2, ["tree"]=out, ["bounds"]=bounds}
    if tabs then ret.tabs = tabs_list; ret.twins = twin_edges end
    return ret
end


function graph3d:Dpolyhedron_net(P,options)
-- draw the 2d or 3d version of net of P
    options = options or {}
    local to2d = options.to2d or false
    local tabs = options.tabs or false
    if tabs then to2d = true end
    -- only for 2d version
    local facet_name = options.facet_name or false
    local edge_name = options.edge_name or false
    local tabs_options = options.tabs_options or ""
    -- end
    local facet_options = options.facet_options
    local net = ld.unfold_polyhedron(P, {tabs=tabs, to2d=to2d, opening=options.opening, root=options.root, model=options.model, tabs_wd=options.tabs_wd, tabs_lg=options.tabs_lg, rotate=options.rotate})
    if net == nil then return end
    local F = net.facets
    if to2d then
        self:Dpolyline(F,true,facet_options)
        if tabs then self:Dpolyline(net.tabs,false,tabs_options) end
        if edge_name then
            for k,f in ipairs(net.twins) do
                local a, b = unpack(f[1])
                local ab = (a+b)/2-0.1875*cpx.I*(b-a)/self:Abs(b-a)
                local c, d = unpack(f[2])
                local cd = (c+d)/2-0.1875*cpx.I*(d-c)/self:Abs(d-c)
                self:Dlabel("e"..k, ab, {node_options=" font=\\scriptsize"}, "e"..k, cd, {})
            end
        end
        if facet_name then
            for k,f in ipairs(F) do
                self:Dlabel("F"..k, cpx.isobar(F[k]), {node_options="font=\\footnotesize"})
            end
        end
        print("bounds of net =", table.unpack(net.bounds))
    else --3d version
       self:Dfacet(F,facet_options) 
       local x1,x2,y1,y2,z1,z2 = table.unpack(net.bounds)
       print("bounds of net =",ld.getbounds( self:Proj3d( ld.parallelep(M(x1,y1,z1),(x2-x1)*vecI, (y2-y1)*vecJ, (z2-z1)*vecK).vertices) ) )
    end
    
end
