-- Parametric (p,q) torus-knot swept tube. -- Exercises every export attribute: positions, normals, tangents (vec4), -- uv0, uv1, per-vertex colors, and indexed triangles. -- The tube frame uses a rotation-minimizing (parallel-transport) sweep so it -- doesn't flip along the curve. -- ── vector helpers ───────────────────────────────────────────────────────── local function v(x, y, z) return { x = x, y = y, z = z } end local function add(a, b) return v(a.x + b.x, a.y + b.y, a.z + b.z) end local function sub(a, b) return v(a.x - b.x, a.y - b.y, a.z - b.z) end local function mul(a, s) return v(a.x * s, a.y * s, a.z * s) end local function dot(a, b) return a.x * b.x + a.y * b.y + a.z * b.z end local function cross(a, b) return v(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x) end local function len(a) return math.sqrt(dot(a, a)) end local function norm(a) local l = len(a) if l < 1e-9 then return v(0, 0, 0) end return mul(a, 1 / l) end -- Rodrigues rotation of vec `p` about unit axis `k` by `theta`. local function rotate(p, k, theta) local c, s = math.cos(theta), math.sin(theta) return add(add(mul(p, c), mul(cross(k, p), s)), mul(k, dot(k, p) * (1 - c))) end -- ── curve: (p,q) torus knot ──────────────────────────────────────────────── local function knot_point(t, p, q, scale) local r = 2 + math.cos(q * t) return v(r * math.cos(p * t) * scale, r * math.sin(p * t) * scale, math.sin(q * t) * scale) end -- ── exposed params (sliders / color pickers in the node) ─────────────────── function get_properties() return { { name = "p", label = "p (longitude)", type = "float", min = 1, max = 8, default = 2 }, { name = "q", label = "q (meridian)", type = "float", min = 1, max = 8, default = 3 }, { name = "tube", label = "Tube radius", type = "float", min = 0.02, max = 0.6, default = 0.22 }, { name = "path_segs", label = "Path segments", type = "float", min = 32, max = 400, default = 220 }, { name = "ring_segs", label = "Ring segments", type = "float", min = 3, max = 48, default = 20 }, { name = "twist", label = "Twist", type = "float", min = 0, max = 12, default = 0 }, { name = "col_a", label = "Color A", type = "color", default = { 0.95, 0.45, 0.10, 1.0 } }, { name = "col_b", label = "Color B", type = "color", default = { 0.15, 0.55, 0.95, 1.0 } }, } end function generate(params) local p = math.floor((params.p or 2) + 0.5) local q = math.floor((params.q or 3) + 0.5) local tube = params.tube or 0.22 local PS = math.max(8, math.floor(params.path_segs or 220)) local RS = math.max(3, math.floor(params.ring_segs or 20)) local twist = math.floor((params.twist or 0) + 0.5) -- integer turns → seam stays coincident local ca = params.col_a or { r = 1, g = 1, b = 1, a = 1 } local cb = params.col_b or { r = 1, g = 1, b = 1, a = 1 } local scale = 0.5 -- 1) sample the centre curve (one extra point closes the UV seam) local centers, tangents = {}, {} for i = 0, PS do local t = (i / PS) * 2 * math.pi centers[i] = knot_point(t, p, q, scale) local dt = 1e-3 -- central-difference tangent tangents[i] = norm(sub(knot_point(t + dt, p, q, scale), knot_point(t - dt, p, q, scale))) end -- 2) rotation-minimizing frame (N, B) per sample local Ns, Bs = {}, {} local T0 = tangents[0] local ref = (math.abs(T0.z) < 0.9) and v(0, 0, 1) or v(0, 1, 0) Ns[0] = norm(cross(T0, ref)) Bs[0] = norm(cross(T0, Ns[0])) for i = 1, PS do local Tp, Tc = tangents[i - 1], tangents[i] local axis = cross(Tp, Tc) local al = len(axis) if al < 1e-7 then Ns[i] = Ns[i - 1] else local angle = math.acos(math.max(-1, math.min(1, dot(Tp, Tc)))) Ns[i] = norm(rotate(Ns[i - 1], mul(axis, 1 / al), angle)) end Bs[i] = norm(cross(Tc, Ns[i])) end -- 2b) the RMF is not periodic: after one loop, Ns[PS] is rotated from Ns[0] -- by the frame's holonomy angle (measured about the shared tangent T0). -- Distribute -holonomy linearly across the sweep so the frame closes up and -- ring PS lands exactly on ring 0 → seam vertices coincide and weld cleanly. local n0, nE, T0c = Ns[0], Ns[PS], tangents[0] local holonomy = math.atan(dot(cross(n0, nE), T0c), dot(n0, nE)) -- 3) sweep the ring and emit vertices local positions, normals, tangs = {}, {}, {} local uv0, uv1, colors = {}, {}, {} for i = 0, PS do local u = i / PS local C, T, N, B = centers[i], tangents[i], Ns[i], Bs[i] local tw = u * (twist * 2 * math.pi - holonomy) for j = 0, RS do local vrt = j / RS local ang = vrt * 2 * math.pi + tw local off = add(mul(N, math.cos(ang)), mul(B, math.sin(ang))) local pos = add(C, mul(off, tube)) positions[#positions + 1] = pos.x positions[#positions + 1] = pos.y positions[#positions + 1] = pos.z normals[#normals + 1] = off.x normals[#normals + 1] = off.y normals[#normals + 1] = off.z -- tangent runs along the curve (U direction); w = handedness tangs[#tangs + 1] = T.x tangs[#tangs + 1] = T.y tangs[#tangs + 1] = T.z tangs[#tangs + 1] = 1.0 uv0[#uv0 + 1] = u * 4 -- wraps a few times along the length uv0[#uv0 + 1] = vrt uv1[#uv1 + 1] = u -- second channel: unwrapped uv1[#uv1 + 1] = vrt -- colour: blend A→B along length, shade by ring angle local shade = 0.55 + 0.45 * math.cos(ang) local m = u colors[#colors + 1] = (ca.r * (1 - m) + cb.r * m) * shade colors[#colors + 1] = (ca.g * (1 - m) + cb.g * m) * shade colors[#colors + 1] = (ca.b * (1 - m) + cb.b * m) * shade colors[#colors + 1] = (ca.a * (1 - m) + cb.a * m) end end -- 4) indices (1-based; the exporter converts to 0-based) local indices = {} local stride = RS + 1 for i = 0, PS - 1 do for j = 0, RS - 1 do local a = i * stride + j + 1 local b = a + 1 local c = a + stride local d = c + 1 indices[#indices + 1] = a; indices[#indices + 1] = b; indices[#indices + 1] = c indices[#indices + 1] = b; indices[#indices + 1] = d; indices[#indices + 1] = c end end return { positions = positions, normals = normals, tangents = tangs, uv0 = uv0, uv1 = uv1, colors = colors, indices = indices, } end