-- luadraw_coils_chains.lua -- date 2026/05/29 -- version 3.1 -- 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 -- draw coils and chains local ld = luadraw local graph = ld.graph local cpx = ld.cpx local Z = cpx.Z function graph:Dcoil(list,R,options) -- list = {start, nb1, end1, nb2, end2, ...} or list = {polygonale line, nb turns} -- R = radius -- options={ color="gray", colorB = color, reverse=false, border=current color, border_width=current lien width, holes=false, start_angle=nil, end_angle=nil, tension=1, wire_dia=2R/15, direction=1, leftC=100, midC=10, rightC=50 } options = options or {} local color = options.color or "gray" local colorB = options.colorB or color local mirror = options.reverse or false local border = options.border or self.param.linecolor local border_width = options.border_width or self.param.linewidth local wireframe = options.wireframe or false local curve = (#list == 2) and (type(list[1]) == "table") and (type(list[2]) == "number") local holes = options.holes or false local tension = options.tension or 1 local alpha1 = options.start_angle local alpha2 = options.end_angle local alphaA, alphaB = alpha1, alpha2 local d = options.wire_dia or 2*R/15 local sens = options.direction or 1 local leftC = options.leftC or 100 local midC = options.midC or 10 local rightC = options.rightC or 50 d = d/2 if mirror then list = ld.reverse(list) end if curve then local n = table.remove(list) list = list[1] if not cpx.isComplex(list[1]) then list = list[1] end -- first component local f, Len = ld.curvilinear_param(list) local L1 = {list[1]} for k = 1, n do table.insert(L1,1); table.insert(L1,f(k/n)) end list = L1 end local oldcolor = self.param.linecolor local oldwidth = self.param.linewidth local oldstyle = self.param.linestyle self:Lineoptions("solid",border,border_width) local Dsegaux = function(S, r, endb, back, start) -- endb = 1 -> round, endb = 0-> open, endb = 2-> butt, endb = 3 -> round at start or end -- back = true/false -- start = true/false local options ="left color ="..color.."!"..leftC..",right color="..color.."!"..rightC..",middle color="..color.."!"..midC if (endb ~= 1) then if start then options = "left color ="..color.."!"..midC..",right color="..color.."!"..leftC else options = "left color ="..color.."!"..midC..",right color="..color.."!"..tostring(leftC/2) end end local ep = sens*tension*R/25 if back then options = "left color ="..colorB.."!80,right color="..colorB.."!15" else ep =-ep end if endb ~= 1 then ep = 0 end local a, b = table.unpack(S) local u = cpx.normalize(b-a) local v = cpx.I*u local c = (a+b)/2+ep*v local u1, u2 = c-a, c-b local v1, v2, C = r*cpx.I*cpx.normalize(u1), -r*cpx.I*cpx.normalize(u2) if tension ~= 0 then C = {a+v1,"m",a+v1+u1, b+v2+u2, b+v2,"b"} else C = {a+v1, "m", b+v2,"l"} end ld.insert(C, {b,b-v2,r,-1,"ca",b-v2+u2,a-v1+u1,a-v1,"b"}) if endb%2 == 1 then ld.insert(C,{a,a+v1,r,-1,"ca"}) end if endb == 2 then table.insert(C,"cl") end local angle = (cpx.arg(v)*ld.rad )--%180 if wireframe then local col = color if back then col= colorB end self:Dpath(C, "fill="..col) else self:Dpath(C, options..",shading angle="..ld.strReal(angle)) end end local B, A, C, n = list[1] local close = cpx.abs(B-list[#list]) < 1e-4 local first = true local Lb, Lf, Ends, Holes = {}, {}, {}, {} local nb = #list for i = 2, nb-1, 2 do A = B; B = list[i+1]; n = list[i]; --alphA = alphaB if i > 3 then C = list[i-3]; local theta = cpx.angle(B-A,A-C)*ld.rad if math.abs(theta) < 0.1 then alphaA = alphaB+ theta alphaB = 0 end else if alphaA == nil then alphaA = math.atan(cpx.abs(B-A)/(4*R*n))*ld.rad end if alphaB == nil then alphaB = alphaA end end local t = cpx.normalize(B-A) local distA = R*math.tan(alphaA*ld.deg) local distB = R*math.tan(alphaB*ld.deg) local A1,B1 = A+distA*t, B-distB*t local a, u, b = A1, (B1-A1)/(2*n-1), B1 local v = -sens*R*cpx.I*t if #Ends > 0 then local S = table.remove(Ends) table.insert(Lf,{A1+v,S[2]}) else table.insert(Ends,{A,A1+v}) end if first then first = false if holes then table.insert(Holes, A) end end for k = 1, n do table.insert(Lb, {a+v, a-v+u}); a = a+2*u if k <= n-1 then table.insert(Lf, {a+v, a-v-u}); end end table.insert(Ends,{B,B1-v}) if i == nb-1 then if holes then table.insert(Holes, B) end end end -- drawing for k, S in ipairs(Lb) do Dsegaux(S, d, 1, true, false) -- true: back, false: start end if close then local S1, S2 = table.remove(Ends,1), table.remove(Ends) table.insert(Lf, {S1[2],S2[2]}) end for k, S in ipairs(Ends) do if k == 1 then Dsegaux(S, d, 3, false, true) elseif k == #Ends then Dsegaux(S, d, 3, false, false) else Dsegaux(S, d, 0, false, true) end end for k, S in ipairs(Lf) do Dsegaux(S, d, 1, false, false) end for _, C in ipairs(Holes) do if holes then self:Dcircle(C,d/2,"draw=none,fill=black") end end self:Lineoptions(oldstyle, oldcolor, oldwidth) end local coil2 = function(list, R, direction) direction= direction or 1 local end_angle = 0 local spire_base = function(angle) angle = angle or 0 local a, a1, a2 = Z(0,0), Z(0,1.44), Z(0.555,2) local b, b1, b2 = Z(1,2), Z(1.455,2), Z(2,1.44) local c, c1, c2 = Z(2,0), Z(2,-1.11), Z(1.55,-2) local d, d1, d2 = Z(1,-2), Z(0.455,-2), Z(0,-1.11) local mat = {Z(0,0), Z(0.5,0), Z(0,1) } local k = 1/math.sqrt(2)/2 a1,a2,b,b1,b2,c,c1,c2,d,d1,d2 = table.unpack( ld.mtransform({a1,a2,b,b1,b2,c,c1,c2,d,d1,d2}, mat) ) local ap = a c1, c2 , d, d1, d2, ap = table.unpack( ld.map(function(z) z=cpx.toComplex(z); return Z( k*(z.re-1)+1,z.im) end, {c1,c2,d,d1,d2,a}) ) if angle ~= 0 then local mat2 = ld.matrixof( function(z) return ap+(z-ap)*cpx.exp(cpx.I*angle*2/3) end ) a2,b,b1,b2,c,c1,c2,d,d1,d2 = table.unpack( ld.mtransform({a2,b,b1,b2,c,c1,c2,d,d1,d2}, mat2) ) end return {a,"m",a1,a2,b, "b", b1, b2, c,"b", c1, c2, d, "b", d1, d2, ap, "b"} end local spring = function(A, nb, B) local Len = cpx.abs(B-A) local angle = cpx.arg(B-A) local sc = R/2 local sp = spire_base(end_angle) local tr = sp[17].re*sc local mat = {Z(0,0), Z(sc,0), Z(0,sc)} local tr_mat = {Z(tr,0), Z(1,0), Z(0,1)} local Len_mat = {Z(0,0), Z(Len/(nb*tr),0), Z(0,1)} local rot_mat = ld.matrixof( function(z) return A+cpx.exp(cpx.I*angle)*z end ) sp = ld.mtransform(sp, mat) local rep = {sp} for i = 2, nb do sp = ld.mtransform(sp, tr_mat) table.insert(rep, sp) end rep = ld.mtransform(rep,Len_mat) return ld.mtransform(rep,rot_mat) end local ret = {} local curve = (#list == 2) and (type(list[1]) == "table") and (type(list[2]) == "number") if curve then local n = table.remove(list) list = list[1] if not cpx.isComplex(list[1]) then list = list[1] end -- first component local f, Len = ld.curvilinear_param(list) local L1 = {list[1]} for k = 1, n do table.insert(L1,1); table.insert(L1,f(k/n)) end list = L1 end local N = #list local B, A, C = list[1] for i = 2, N-1, 2 do A = B B, sp = list[i+1], list[i] if i <= N-3 then C = list[i+3] end_angle = cpx.angle(B-A,C-B) else end_angle = 0 end ld.insert(ret, spring(A, sp, B)) end if direction == 1 then ret = ld.reverse(ret) end return ret end function graph:Dcoil2(list,R,options) -- list = {start, nb1, end1, nb2, end2, ...} or list = {polygonale line, nb turns} -- R = radius -- options={ color=linecolor, border=nil ("color"), border_width=linewidth,width=linewidth, direction=1 } options = options or {} local color = options.color or self.param.linecolor local border = options.border or "white" local width = options.width or self.param.linewidth local border_width = options.border_width or self.param.linewidth local sens = options.direction or 1 local draw_options local curve = (#list == 2) and (type(list[1]) == "table") and (type(list[2]) == "number") if border ~= "" then draw_options = "line width="..tostring(border_width/10).."pt,double="..color..",draw="..border..",double distance="..tostring(width/10).."pt" else draw_options = "color="..color..",line width="..tostring(width/10).."pt" end draw_options = draw_options..",line cap=butt" local lst = coil2(list,R,sens) for _, sp in ipairs(lst) do self:Dpath(sp,draw_options) end end --------------------- chains ------------------------------------------- local chain = function(list, rx, ry, ep) local f, Len = ld.curvilinear_param(list) local n = math.floor( (Len/rx+1)/2 ) n = 2*n-1 rx = Len/n/2 if ry == "circle" then ry = rx elseif ry == nil then ry = rx/1.618 else ry = ry/2 end ep = ep or ry/2 local mailleA = function(a,b) local u = b-a local r = cpx.abs(u)/2 u = cpx.normalize(u) local c, alpha = (a+b)/2, cpx.arg(b-a)*ld.rad return {a, "m", c, r, ry, alpha, "e", a-ep*u, "m", c, r+ep, ry+ep, alpha, "e"} end local mailleB = function(a,b) local v = ep*cpx.I*cpx.normalize(b-a)/2 local alpha = cpx.arg(b-a)*ld.rad return {a+v, "m", b+v, "l", b, b-v, ep, ep/2, -1, alpha, "ea", a-v, "l", a, a+v, ep, ep/2, -1, alpha, "ea", "cl"} end local back, front = {}, {} local x2, x1 = f(0) for k = 1, n do x1 = x2; x2 = f(k/n) if k%2 == 1 then -- mailleA table.insert(back,mailleA(x1,x2)) else table.insert(front,mailleB(x1,x2)) end end return back, front end function graph:Dchain(list, link_length, options) -- list = list of complex numbers options = options or {} local rx = link_length local ry = options.width local ep = options.wire_dia local wireframe = options.wireframe or false local colorA = options.color or "gray" local colorB = options.colorB or colorA local border = options.border or self.param.linecolor local border_width = options.border_width or self.param.linewidth local leftC = options.leftC or 100 local midC = options.midC or 10 local rightC = options.rightC or 50 local oldfillstyle = self.param.fillstyle local oldfillopacity = self.param.fillopacity local oldfillcolor = self.param.fillcolor local oldcolor = self.param.linecolor local oldwidth = self.param.linewidth local oldstyle = self.param.linestyle self:Lineoptions("solid",border,border_width) local gradoptions = "left color="..colorA.."!"..leftC..",right color="..colorB.."!"..rightC..",middle color="..colorA.."!"..midC local bck, frt = chain(list,rx,ry,ep) for _, bc in ipairs(bck) do local angle = bc[6] if wireframe then self:Dpath(bc, "even odd rule, fill="..colorA) else self:Dpath(bc, "even odd rule,"..gradoptions..",shading angle="..(angle+90)) end end for _, bc in ipairs(frt) do local angle = bc[10] if wireframe then self:Dpath(bc, "fill="..colorA) else self:Dpath(bc, gradoptions..",shading angle="..(angle+90)) end end self:Lineoptions(oldstyle, oldcolor, oldwidth) end local chain2 = function(L,h) -- a and b are two 2d points (complex numbers) -- h = height of wave -- returns a back and front (two lists of paths) local f, Len = ld.curvilinear_param(L) local closed = cpx.abs(f(1)-f(0))<1e-4 h = h or 0.0625 local n = math.floor(Len/(4*h)) -- number of zigzag local back,front = {}, {} -- to build path local lastback, lastfront = {f(0)}, {f(0)} local x2, x1, c, d = f(0) for k = 1, n do x1 = x2; x2 = f(k/n) local w = (x2-x1)/12 local v = h*cpx.I*cpx.normalize(x2-x1) c, d = (3*x1+x2)/4, (x1+3*x2)/4 ld.insert(lastfront, {c-w+v,c-w+v,c+v,"b"}) if (k > 1) or closed then table.insert(front, lastfront) else table.insert(back, lastfront) end table.insert(front, {c-v,"m",c-v+w,d+v-w,d+v,"b"}) lastfront = {d-v,"m",d-v+w,d-v+w,x2,"b"} ld.insert(lastback, {c-v-w,c-v-w,c-v,"b"}) table.insert(back, lastback) table.insert(back, {c+v,"m",c+v+w,d-v-w,d-v,"b"}) lastback = {d+v,"m",d+v+w,d+v+w,x2,"b"} end if closed then table.remove(back[1],1) back[1] = ld.concat(lastback, back[1]) table.remove(front[1],1) front[1] = ld.concat(lastfront, front[1]) else table.insert(back, lastback) table.insert(back, lastfront) end return back,front end function graph:Dchain2(L,R,options) -- list = list of complex numbers -- options={ color=linecolor, border=nil ("color"), border_width=4,width=linewidth, direction=1 } local h = R options = options or {} local color = options.color or self.param.linecolor local border = options.border or "white" local width = options.width or self.param.linewidth local border_width = options.border_width or self.param.linewidth local sens = options.direction or 1 local back_options, front_options back_options = "color="..color..",line width="..tostring(width/10).."pt" if border ~= "" then front_options = "line width="..tostring(border_width/10).."pt,double="..color..",draw="..border..",double distance="..tostring(width/10).."pt" else front_options = back_options end local bck, frt = chain2(L,h) for _,bc in ipairs(bck) do self:Dpath(bc, back_options..",line cap=round") end for _, fr in ipairs(frt) do self:Dpath(fr,front_options..",line cap=butt") end end