-- luadraw_spherical.lua -- date 2026/06/13 -- version 3.2 -- 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 local ld = luadraw local pt3d = ld.pt3d local Origin, vecI, vecJ, vecK, M, Ms = pt3d.Origin, pt3d.vecI, pt3d.vecJ, pt3d.vecK, pt3d.M, pt3d.Ms local graph3d = ld.graph3d local insidelabelcolor = "gray" local hiddendelayed = false local arrowBstyle = "->" local arrowAstyle = "<-" local arrowABstyle = "<->" local sphere = {["C"]=Origin, ["R"]=3, ["color"]="orange", ["opacity"]=1, ["mode"]=ld.mBorder, ["edgecolor"]="lightgray", ["edgestyle"] = "solid", ["edgewidth"] = "4", ["hiddenstyle"] = ld.Hiddenlinestyle, ["hiddencolor"] = "gray", ["show"] = true, ["horizon"] = nil } -- sphere definition local before_sphere = {} local after_sphere = {} local hidden_part = {} function graph3d:Clear_spherical() before_sphere = {} after_sphere = {} hidden_part = {} sphere = {["C"]=Origin, ["R"]=3, ["color"]="orange", ["opacity"]=1, ["mode"]=ld.mBorder, ["edgecolor"]="lightgray", ["edgestyle"] = "solid", ["edgewidth"] = "4", ["hiddenstyle"] = ld.Hiddenlinestyle, ["hiddencolor"] = "gray", ["show"] = true, ["horizon"] = nil } -- sphere definition insidelabelcolor = "gray" arrowBstyle = "->" arrowAstyle = "<-" arrowABstyle = "<->" end function graph3d:Define_sphere( args ) args = args or {} if args.center ~= nil then sphere.C = args.center end if args.radius ~= nil then sphere.R = args.radius end local Ct, R = sphere.C, sphere.R if args.color ~= nil then sphere.color = args.color end if args.opacity ~= nil then sphere.opacity = args.opacity end if args.mode ~= nil then sphere.mode = args.mode end if args.edgecolor ~= nil then sphere.edgecolor = args.edgecolor end if args.edgestyle ~= nil then sphere.edgestyle = args.edgestyle end if args.hiddenstyle ~= nil then sphere.hiddenstyle = args.hiddenstyle end if args.hiddencolor ~= nil then sphere.hiddencolor = args.hiddencolor end if args.edgewidth ~= nil then sphere.edgewidth = args.edgewidth end if args.show ~= nil then sphere.show = args.show end insidelabelcolor = args.insidelabelcolor or "gray" arrowBstyle = args.arrowBstyle or "->" arrowAstyle = args.arrowAstyle or "<-" arrowABstyle = args.arrowABstyle or "<->" local mat = self.matrix3d local N, cam = self.Normal, ld.camera if not ld.isID3d(mat) then mat = ld.invmatrix3d(mat) N = ld.mLtransform3d(N,mat); cam = ld.mtransform3d(cam,mat) end if ld.projection_mode == "central" then sphere.horizon = { ld.interSS({Ct,R}, {(Ct+cam)/2,pt3d.abs(Ct-cam)/2}) } else sphere.horizon = {Ct, R, N} end end ---------------- new functions ------------------------------------- function ld.sM(x,y,z) -- or sM(theta,phi) (theta, phi degrees), define a spherical dot in (C,vecI,vecJ,vecK) local C, R = sphere.C, sphere.R if z ~= nil then --cartesian coordinates return C + R*pt3d.normalize(M(x,y,z)) else -- spherical coordinates return C + Ms(R,x*ld.deg,y*ld.deg) end end function ld.toSphere(A) local C, R = sphere.C, sphere.R local u = A-C if pt3d.N1(u)< 1e-12 then return end return C+R*pt3d.normalize(u) end local visibledot = function(A) if sphere.horizon == nil then print("Please define sphere first"); return end local I, r, n = table.unpack(sphere.horizon) return pt3d.dot(A-I,n) >= 0 end function ld.interSphericalC(P1, P2) local C, R = sphere.C, sphere.R local D = ld.interPP(P1,P2) if D ~= nil then local I1, I2 = ld.interDS(D, {C,R}) if (I1 ~= nil) and (I2 ~= nil) and (not visibledot(I1)) then I1, I2 = I2, I1 end return I1, I2 end end function ld.interGreatC(AB, CD) -- AB = {A,B} (two points of sphere) -- CD = {C,D} (two points of sphere) -- options = {style=, color=, width=, opacity=} local C, R = sphere.C, sphere.R local A1, B1 = table.unpack(AB) local A2, B2 = table.unpack(CD) return ld.interSphericalC( ld.plane(A1,B1,C), ld.plane(A2,B2,C) ) end function ld.projstereo_Sfacet(L, N, h, close) -- stereographic projection of a spherical facet -- L is a list of 3D points on the sphere, -- the "segments" joining two consecutive points are arcs of a great circle (spherical facet). local C, R = sphere.C, sphere.R local function projstereo_Sarc(AB) -- AB is a list of two 3D points on the sphere (not aligned with the center of the sphere) local A, B = table.unpack(AB) if pt3d.N1(B-A) < 1e-12 then return end local inverse = false local d1, d2 = pt3d.N1(N-A), pt3d.N1(N-B) if d2 < 1e-12 then A,B = B,A; d1,d2 = d2,d1; inverse = true end if d1 < 1e-12 then -- A is the pole A = ld.toSphere( (9*N+B)/10) if inverse then A,B = B,A end return { ld.projstereo(A, {C,R}, N, h),"m", ld.projstereo(B, {C,R}, N, h), "l"}, true else -- A and B are not at the pole --local arc = ld.arc3d(A,C,B,R,1)[1] local a, b = table.unpack( ld.projstereo({A,B}, {C,R}, N, h) ) local D = ld.toSphere((A+B)/2) --arc[#arc//2] if math.abs( pt3d.det(A-C,B-C,N-C) ) < 1e-8 then -- pole sur le grand cercle if pt3d.angle(N-C,D-C,1e-6) <= pt3d.angle(A-C,B-C,1e-6)/2 then -- N entre A et B local u = pt3d.normalize(b-a) return {a, a-100*u, "l", b+100*u,"m", b, "l"} else return {a,b,"l"} end else local c = ld.projstereo(D, {C,R}, N, h) local I, r, n = ld.circumcircle3d(a, b, c) local sens if (pt3d.det(a-I,c-I,n)>0) then sens = 1 else sens = -1 end return ld.arc3db(a,I,b,r,sens,n) -- path end end end if close == nil then close = true end local B, A, D = ld.toSphere(L[1]) local n, p = #L local ret= {} if close then p = n+1 else p = n end for k = 2, p do A = B; B = ld.toSphere( L[1+(k-1)%n] ) local apath, move = projstereo_Sarc({A,B}, N, h) if apath ~= nil then if (k>2) and (not move) then table.remove(apath,1) end ld.insert(ret, apath) end end return ret end function ld.projstereo_Scircle(P, N, h) -- stereographic projection of a spherical circle -- P = {A,n} is a plane local A, n = table.unpack(P) local C, R = sphere.C, sphere.R local I, r, v = ld.interPS(P,{C,R}) if I == nil then return end if math.abs(pt3d.dot( N-A,n)) < 1e-8 then -- N is on the circle local B, D = ld.rotate3d( N, -10, {I,n}), ld.rotate3d(N, 10, {I,n}) return {ld.projstereo(B, {C,R}, N, h), ld.projstereo(D, {C,R}, N, h), "l"} else local w = pt3d.prod(n, M(1,0,0)) if pt3d.N1(w) < 1e-8 then w = pt3d.prod(n, M(0,1,0)) end w = pt3d.normalize(w) local B, D = I+r*w, I-r*w local E = ld.rotate3d(B, 90, {I,n}) local b, d, e = table.unpack( ld.projstereo({B,D,E}, {C,R}, N, h) ) return ld.circle3db( ld.circumcircle3d(b,d,e) ) end end ----------------------- new methods --------------------------------- function graph3d:Dspherical() local oldlinestyle = self.param.linestyle local oldlinecolor = self.param.linecolor local oldlinewidth = self.param.linewidth local oldlineopacity = self.param.lineopacity local oldfillstyle = self.param.fillstyle local oldfillcolor = self.param.fillcolor local oldfillopacity = self.param.fillopacity local display_elt = function(elt) -- elt={path,style,color,width,opacity,arrows} ou -- elt={text,anchor,options} (labels) if type(elt[1]) == "string" then -- a label self:Dlabel3d(elt[1],elt[2],elt[3]) elseif pt3d.isPoint3d(elt[1]) then -- a dot self:Lineoptions(oldlinestyle, oldlinecolor, oldlinewidth); self:Lineopacity(oldlineopacity) self:Filloptions(oldfillstyle,oldfillcolor,oldfillopacity) self:Ddots3d(elt[1],elt[2]) else self:Lineoptions(elt[2],elt[3],elt[4]); self:Lineopacity(elt[5]) local arrowstyle, arrows = "", elt[6] if arrows == 1 then arrowstyle = arrowBstyle elseif arrows == 2 then arrowstyle = arrowABstyle elseif arrows == -1 then arrowstyle = arrowAstyle else arrows = 0 end if (elt[7] ~= nil) and (elt[7] ~= "") then -- fill --self:Filloptions("gradient","ball color="..elt[7],elt[8]) self:Filloptions("full",elt[7],elt[8]) else self:Filloptions("none",nil,1) end self:Linecap("round"); -- pour que les liaisons soient correctes entre segments successifs if arrows ~= 0 then self:Dpath3d(elt[1],"arrows="..arrowstyle) else self:Dpath3d(elt[1]) end end end for _, elt in ipairs(before_sphere) do display_elt(elt) end if sphere.show then self:Lineoptions(oldlinestyle, oldlinecolor, oldlinewidth); self:Lineopacity(oldlineopacity) self:Dsphere(sphere.C, sphere.R, {mode=sphere.mode, color=sphere.color, opacity=sphere.opacity, edgecolor=sphere.edgecolor, edgewidth=sphere.edgewidth, edgestyle=sphere.edgestyle, hiddenstyle=sphere.hiddenstyle, hiddencolor=sphere.hiddencolor}) end for _, elt in ipairs(after_sphere) do display_elt(elt) end if hiddendelayed then self:Begindeferred() end if sphere.show and ld.Hiddenlines and (sphere.edgestyle ~= "noline") and (sphere.hiddenstyle ~= "noline") then self:Dsphere(sphere.C, sphere.R, {mode=ld.mBorder,edgecolor=sphere.edgecolor, edgewidth=3*sphere.edgewidth/4, edgestyle=sphere.hiddenstyle}) end for _, elt in ipairs(hidden_part) do display_elt(elt) end if hiddendelayed then self:Enddeferred() end self:Lineoptions(oldlinestyle, oldlinecolor, oldlinewidth); self:Lineopacity(oldlineopacity) self:Filloptions(oldfillstyle,oldfillcolor,oldfillopacity) self:Clear_spherical() end -- ajouter un cercle tracé sur la sphère function graph3d:DScircle(P,options) -- P={A,u} (plane) -- options = {style=, color=, width=, opacity=, out=} options = options or {} local style = options.style or self.param.linestyle local color = options.color or self.param.linecolor local width = options.width or self.param.linewidth local opacity = options.opacity or self.param.lineopacity local hidden = options.hidden if hidden == nil then hidden = ld.Hiddenlines end local out = options.out -- returns end points of hidden part if it exists local C, R = sphere.C, sphere.R local N, angle = self.Normal local acircle = function(I,r,v,u) -- when we have to draw a circle local w = pt3d.prod(u,vecI) if pt3d.N1(w) < 1e-12 then w =pt3d.prod(u,vecJ) end local J = I+r*pt3d.normalize(w) -- a point of the circle if visibledot(I) then --(pt3d.dot(v,N) > 0) then -- visible table.insert(after_sphere, {{J,I,u,"c"},style,color,width,opacity}) if hidden and hiddendelayed and (style ~= "noline") then table.insert(hidden_part, {{J,I,u,"c"},ld.Hiddenlinestyle,color,width,opacity}) end elseif hidden and (style ~= "noline") then table.insert(hidden_part, {{J,I,u,"c"},ld.Hiddenlinestyle,color,width,opacity}) else --self:Beginadvanced() table.insert(before_sphere, {{J,I,u,"c"},style,color,width,opacity}) --self:Endadvanced() end end local A, b, c, u if #P == 3 then A, b, c = table.unpack(P) A= ld.toSphere(A); b = ld.toSphere(b); c = ld.toSphere(c) u = pt3d.prod(b-A,c-A) P = {A,u} else A, u = table.unpack(P) end local I = ld.proj3d(C,P) -- center if ld.projection_mode == "central" then N = ld.camera-I end local mat = self.matrix3d if not ld.isID3d(mat) then mat = ld.invmatrix3d(mat); N = ld.mLtransform3d(N,mat) end if pt3d.dot(u,I-C) < 0 then u = -u end local d = pt3d.abs(C-I) if d >= R then return end -- no circle local v, r = I-C if pt3d.N1(v) < 1e-12 then -- C is on P (big circle) v = Origin; r = R; --angle = 0 else r = math.sqrt(R^2- pt3d.abs2(v)) end if pt3d.N1(pt3d.prod(u,N))< 1e-12 then -- P has the same direction than screen acircle(I,r,v,u) else local n2 = pt3d.normalize(pt3d.prod(N,u)) local n1 = pt3d.normalize(pt3d.prod(u,n2)) if ld.projection_mode == "central" then angle = (r*r-pt3d.dot(v,N))/(r*pt3d.dot(n1,N)) else angle = -pt3d.dot(v,N)/(r*pt3d.dot(n1,N)) end if math.abs(angle) > 1+1e-6 then acircle(I,r,v,u,angle) elseif (1