--- lua-tikz3dtools-scene.lua --- Scene management, TeX command registration, and rendering for lua-tikz3dtools. local Vector local Matrix local Geometry local Scene = {} --- Set class references (called after all modules are loaded). function Scene._set_classes(V, M, G) Vector = V Matrix = M Geometry = G end -- ================================================================ -- Sandboxed evaluation environment -- ================================================================ --- Build a safe environment table for load(). --- Whitelists only math functions and user-defined objects. --- os, io, debug, dofile, loadfile, require are NOT reachable. local function make_safe_env(extra) local env = {} -- Whitelist standard math local safe_math = { "abs", "acos", "asin", "atan", "atan2", "ceil", "cos", "cosh", "deg", "exp", "floor", "fmod", "frexp", "huge", "ldexp", "log", "log10", "max", "min", "modf", "pi", "pow", "rad", "random", "randomseed", "sin", "sinh", "sqrt", "tan", "tanh", "maxinteger", "mininteger", "tointeger", "type", } for _, k in ipairs(safe_math) do if math[k] ~= nil then env[k] = math[k] end end -- Whitelist table utilities needed by expressions env.table = { unpack = table.unpack, insert = table.insert, remove = table.remove, sort = table.sort } env.unpack = table.unpack env.type = type env.tostring = tostring env.tonumber = tonumber env.pairs = pairs env.ipairs = ipairs env.select = select env.pcall = pcall env.error = error env.setmetatable = setmetatable env.getmetatable = getmetatable env.rawget = rawget env.rawset = rawset -- Copy extra entries (user objects, Vector, Matrix, tau, etc.) if extra then for k, v in pairs(extra) do env[k] = v end end return env end -- ================================================================ -- TeX command registration helper -- https://tex.stackexchange.com/a/747040 -- ================================================================ local function register_tex_cmd(name, func, args, protected) name = "__lua_tikztdtools_" .. name .. ":" .. ("n"):rep(#args) local scanners = {} for _, arg in ipairs(args) do scanners[#scanners+1] = token['scan_' .. arg] end local scanning_func = function() local values = {} for _, scanner in ipairs(scanners) do values[#values+1] = scanner() end func(table.unpack(values)) end local index = luatexbase.new_luafunction(name) lua.get_functions_table()[index] = scanning_func if protected then token.set_lua(name, index, "protected") else token.set_lua(name, index) end end -- ================================================================ -- Global scene state -- ================================================================ local lua_tikz3dtools = {} lua_tikz3dtools.simplices = {} lua_tikz3dtools.lights = {} -- The safe math environment exposed to user expressions lua_tikz3dtools.math = make_safe_env() lua_tikz3dtools.math.Vector = Vector lua_tikz3dtools.math.Matrix = Matrix lua_tikz3dtools.math.tau = 2 * math.pi --- Late-init: called after Vector/Matrix are set to update the env table function Scene._init_math_env() lua_tikz3dtools.math.Vector = Vector lua_tikz3dtools.math.Matrix = Matrix lua_tikz3dtools.math.tau = 2 * math.pi end -- ================================================================ -- Expression evaluators (sandboxed) -- ================================================================ local function single_string_expression(str) return load( ("return %s"):format(str), "expression", "t", lua_tikz3dtools.math )() end local function body_expression(str) return load(str, "expression", "t", lua_tikz3dtools.math)() end local function single_string_function(str) return load(("return function(u) %s end"):format(str), "expression", "t", lua_tikz3dtools.math)() end local function double_string_function(str) return load(("return function(u,v) %s end"):format(str), "expression", "t", lua_tikz3dtools.math)() end local function triple_string_function(str) return load(("return function(u,v,w) %s end"):format(str), "expression", "t", lua_tikz3dtools.math)() end -- ================================================================ -- Append functions -- ================================================================ local function append_point(hash) local v = body_expression(hash.v) local transformation = single_string_expression(hash.transformation) local filloptions = hash.filloptions local filter = hash.filter if v then local the_simplex = v:multiply(transformation) table.insert( lua_tikz3dtools.simplices, { simplex = the_simplex, filloptions = filloptions, type = "point", filter = filter } ) end end local function append_surface(hash) local ustart = single_string_expression(hash.ustart) local ustop = single_string_expression(hash.ustop) local usamples = single_string_expression(hash.usamples) local vstart = single_string_expression(hash.vstart) local vstop = single_string_expression(hash.vstop) local vsamples = single_string_expression(hash.vsamples) local transformation = single_string_expression(hash.transformation) local f = double_string_function(hash.v) local filloptions = hash.filloptions local filter = hash.filter assert(usamples and usamples >= 2, "usamples must be >= 2, got: " .. tostring(usamples)) assert(vsamples and vsamples >= 2, "vsamples must be >= 2, got: " .. tostring(vsamples)) local ustep = (ustop - ustart) / (usamples - 1) local vstep = (vstop - vstart) / (vsamples - 1) local function parametric_surface(u, v) return f(u, v) end for i = 0, usamples - 2 do local u = ustart + i * ustep for j = 0, vsamples - 2 do local v = vstart + j * vstep local A = parametric_surface(u, v) local B = parametric_surface(u + ustep, v) local C = parametric_surface(u + ustep, v + vstep) local D = parametric_surface(u, v + vstep) if A and B and C and D then local simplex1 = Matrix:_new{A:to_table(), B:to_table(), C:to_table()}:multiply(transformation) local simplex2 = Matrix:_new{A:to_table(), D:to_table(), C:to_table()}:multiply(transformation) if not ( Geometry.hpoint_point_intersecting(A, B) or Geometry.hpoint_point_intersecting(B, C) or Geometry.hpoint_point_intersecting(A, C) ) then table.insert(lua_tikz3dtools.simplices, { simplex = simplex1, filloptions = filloptions, type = "triangle", filter = filter }) end if not ( Geometry.hpoint_point_intersecting(A, D) or Geometry.hpoint_point_intersecting(D, C) or Geometry.hpoint_point_intersecting(A, C) ) then table.insert(lua_tikz3dtools.simplices, { simplex = simplex2, filloptions = filloptions, type = "triangle", filter = filter }) end end end end end local function append_triangle(hash) local A = single_string_expression(hash.A) local B = single_string_expression(hash.B) local C = single_string_expression(hash.C) local transformation = single_string_expression(hash.transformation) local filter = hash.filter local filloptions = hash.filloptions if A[1] and B[1] and C[1] then if not ( Geometry.hpoint_point_intersecting(A, B) or Geometry.hpoint_point_intersecting(B, C) or Geometry.hpoint_point_intersecting(C, A) ) then local the_simplex = Matrix:_new{A:to_table(), B:to_table(), C:to_table()}:multiply(transformation) table.insert(lua_tikz3dtools.simplices, { simplex = the_simplex, filloptions = filloptions, type = "triangle", filter = filter }) end end end local function append_label(hash) local v = body_expression(hash.v) local filter = hash.filter local text = hash.text local transformation = single_string_expression(hash.transformation) if v then local the_simplex = v:multiply(transformation) table.insert(lua_tikz3dtools.simplices, { simplex = the_simplex, text = text, type = "label", filter = filter }) end end local function append_light(hash) local v = body_expression(hash.v) if v and getmetatable(v) == Vector then table.insert(lua_tikz3dtools.lights, v) else error("Invalid light vector: " .. tostring(v)) end end local function append_curve(hash) local ustart = single_string_expression(hash.ustart) local ustop = single_string_expression(hash.ustop) local usamples = single_string_expression(hash.usamples) local transformation = single_string_expression(hash.transformation) local f = single_string_function(hash.v) local filter = hash.filter local drawoptions = hash.drawoptions local arrowoptions = hash.arrowtip local tailoptions = hash.arrowtail assert(usamples and usamples >= 2, "usamples must be >= 2, got: " .. tostring(usamples)) local ustep = (ustop - ustart) / (usamples - 1) local function parametric_curve(u) return f(u) end for i = 0, usamples - 2 do local u = ustart + i * ustep local A = parametric_curve(u) local B = parametric_curve(u + ustep) if A and B then local simplex = Matrix:_new{A:to_table(), B:to_table()}:multiply(transformation) table.insert(lua_tikz3dtools.simplices, { simplex = simplex, drawoptions = drawoptions, type = "line segment", filter = filter }) if i == 0 and tailoptions then local P = parametric_curve(ustart):multiply(transformation) local U = P:hsub(parametric_curve(ustart+ustep):multiply(transformation)):hnormalize() local V = U:orthogonal_vector():hnormalize() local W = U:hhypercross(V):hnormalize() append_surface{ ustart = "0", ustop = "tau", usamples = "6", vstart = "0+pi/2", vstop = "pi", vsamples = "2", v = "return Vector:_new{0.1*sin(v)*cos(u), 0.1*sin(v)*sin(u), 0*0.1*cos(v), 1}", filloptions = tailoptions, transformation = ([[ Matrix:new{ {%f,%f,%f,0},{%f,%f,%f,0},{%f,%f,%f,0},{%f,%f,%f,1} } ]]):format( W[1],W[2],W[3], V[1],V[2],V[3], U[1],U[2],U[3], P[1],P[2],P[3] ), filter = filter } end if i == usamples - 2 and arrowoptions then local P = parametric_curve(ustop):multiply(transformation) local U = P:hsub(parametric_curve(ustop - ustep):multiply(transformation)):hnormalize() local V = U:orthogonal_vector():hnormalize() local W = U:hhypercross(V):hnormalize() append_surface{ ustart = "0", ustop = "0.1", usamples = "2", vstart = "0", vstop = "1", vsamples = "4", v = "return Vector:_new{u*cos(v*tau), u*sin(v*tau), -u, 1}", filloptions = arrowoptions, transformation = ([[ Matrix:new{ {%f,%f,%f,0},{%f,%f,%f,0},{%f,%f,%f,0},{%f,%f,%f,1} } ]]):format( W[1],W[2],W[3], V[1],V[2],V[3], U[1],U[2],U[3], P[1],P[2],P[3] ), filter = filter } end end end end local function append_solid(hash) local ustart = single_string_expression(hash.ustart) local ustop = single_string_expression(hash.ustop) local usamples = single_string_expression(hash.usamples) local vstart = single_string_expression(hash.vstart) local vstop = single_string_expression(hash.vstop) local vsamples = single_string_expression(hash.vsamples) local wstart = single_string_expression(hash.wstart) local wstop = single_string_expression(hash.wstop) local wsamples = single_string_expression(hash.wsamples) local filloptions = hash.filloptions local transformation = single_string_expression(hash.transformation) local filter = hash.filter local f = triple_string_function(hash.v) assert(usamples and usamples >= 2, "usamples must be >= 2, got: " .. tostring(usamples)) assert(vsamples and vsamples >= 2, "vsamples must be >= 2, got: " .. tostring(vsamples)) assert(wsamples and wsamples >= 2, "wsamples must be >= 2, got: " .. tostring(wsamples)) local function parametric_solid(u, v, w) return f(u, v, w) end local ustep = (ustop - ustart) / (usamples - 1) local vstep = (vstop - vstart) / (vsamples - 1) local wstep = (wstop - wstart) / (wsamples - 1) local function tessellate_face(fixed_var, fixed_val, s1_start, s1_step, s1_count, s2_start, s2_step, s2_count) for i = 0, s1_count - 2 do local s1 = s1_start + i * s1_step for j = 0, s2_count - 2 do local s2 = s2_start + j * s2_step local A, B, C, D if fixed_var == "u" then A = parametric_solid(fixed_val, s1, s2) B = parametric_solid(fixed_val, s1 + s1_step, s2) C = parametric_solid(fixed_val, s1 + s1_step, s2 + s2_step) D = parametric_solid(fixed_val, s1, s2 + s2_step) elseif fixed_var == "v" then A = parametric_solid(s1, fixed_val, s2) B = parametric_solid(s1 + s1_step, fixed_val, s2) C = parametric_solid(s1 + s1_step, fixed_val, s2 + s2_step) D = parametric_solid(s1, fixed_val, s2 + s2_step) elseif fixed_var == "w" then A = parametric_solid(s1, s2, fixed_val) B = parametric_solid(s1 + s1_step, s2, fixed_val) C = parametric_solid(s1 + s1_step, s2 + s2_step, fixed_val) D = parametric_solid(s1, s2 + s2_step, fixed_val) end if A and B and D then local simplex = Matrix:_new{A:to_table(), B:to_table(), D:to_table()}:multiply(transformation) table.insert(lua_tikz3dtools.simplices, { simplex = simplex, filloptions = filloptions, type = "triangle", filter = filter }) end if B and C and D then local simplex = Matrix:_new{B:to_table(), C:to_table(), D:to_table()}:multiply(transformation) table.insert(lua_tikz3dtools.simplices, { simplex = simplex, filloptions = filloptions, type = "triangle", filter = filter }) end end end end tessellate_face("u", ustart, vstart, vstep, vsamples, wstart, wstep, wsamples) tessellate_face("u", ustop, vstart, vstep, vsamples, wstart, wstep, wsamples) tessellate_face("v", vstart, ustart, ustep, usamples, wstart, wstep, wsamples) tessellate_face("v", vstop, ustart, ustep, usamples, wstart, wstep, wsamples) tessellate_face("w", wstart, ustart, ustep, usamples, vstart, vstep, vsamples) tessellate_face("w", wstop, ustart, ustep, usamples, vstart, vstep, vsamples) end -- ================================================================ -- Filters (sandboxed) -- ================================================================ local function apply_filters(simplices) local new_simplices = {} for _, simplex in ipairs(simplices) do local env = make_safe_env(lua_tikz3dtools.math) if simplex.type == "point" then env.A = Vector:_new(simplex.simplex:to_table()) elseif simplex.type == "line segment" then env.A = Vector:_new(simplex.simplex[1]) env.B = Vector:_new(simplex.simplex[2]) elseif simplex.type == "triangle" then env.A = Vector:_new(simplex.simplex[1]) env.B = Vector:_new(simplex.simplex[2]) env.C = Vector:_new(simplex.simplex[3]) elseif simplex.type == "label" then env.A = Vector:_new(simplex.simplex:to_table()) end local filter_src = ("return function()\n%s\nend") :format(simplex.filter or "return true") local chunk, _ = load(filter_src, "filter", "t", env) if chunk then local ok, fn = pcall(chunk) if ok and fn then local ok2, result = pcall(fn) if ok2 and result then table.insert(new_simplices, simplex) end end end end return new_simplices end -- ================================================================ -- Display / render -- ================================================================ local function display_simplices() print("Time:" .. os.date("%X") .. " Displaying " .. #lua_tikz3dtools.simplices .. " simplices.") -- Pre-compute bbox2 for all simplices for _, s in ipairs(lua_tikz3dtools.simplices) do if s.type ~= "point" and s.type ~= "label" then s.bbox2 = s.simplex:get_bbox2() end end lua_tikz3dtools.simplices = Geometry.partition_simplices_by_parents( lua_tikz3dtools.simplices, lua_tikz3dtools.simplices ) print("Time:" .. os.date("%X") .. " After partitioning, " .. #lua_tikz3dtools.simplices .. " simplices remain.") lua_tikz3dtools.simplices = apply_filters(lua_tikz3dtools.simplices) print("Time:" .. os.date("%X") .. " After filtering, " .. #lua_tikz3dtools.simplices .. " simplices remain.") -- Re-compute bbox2 after filtering (some simplices removed) for _, s in ipairs(lua_tikz3dtools.simplices) do if s.type ~= "point" and s.type ~= "label" and not s.bbox2 then s.bbox2 = s.simplex:get_bbox2() end end lua_tikz3dtools.simplices = Geometry.scc(lua_tikz3dtools.simplices) print("Time:" .. os.date("%X") .. " After occlusion sorting, " .. #lua_tikz3dtools.simplices .. " simplices remain.") local labels = {} for _, simplex in ipairs(lua_tikz3dtools.simplices) do if simplex.type == "point" then tex.sprint( ("\\path[%s] (%f,%f) circle[radius = 0.06];") :format(simplex.filloptions, simplex.simplex[1], simplex.simplex[2]) ) elseif simplex.type == "line segment" then tex.sprint( ("\\path[%s] (%f,%f) -- (%f,%f);") :format( simplex.drawoptions, simplex.simplex[1][1], simplex.simplex[1][2], simplex.simplex[2][1], simplex.simplex[2][2] ) ) elseif simplex.type == "triangle" then local num_lights = #lua_tikz3dtools.lights if num_lights > 0 then local A = Vector:_new(simplex.simplex[1]) local B = Vector:_new(simplex.simplex[2]) local C = Vector:_new(simplex.simplex[3]) local normal = (B:hsub(A)):hhypercross(C:hsub(A)):hnormalize() local total_intensity = 0 for _, light in ipairs(lua_tikz3dtools.lights) do local light_dir = light:hnormalize() local cos_theta = math.abs(normal:hinner(light_dir)) if cos_theta > 1 then cos_theta = 1 end -- Linear falloff: 0° → 1.0, 90° → 0.0 local theta = math.deg(math.acos(cos_theta)) total_intensity = total_intensity + (1 - theta / 90) end local avg_intensity = math.floor((total_intensity / num_lights) * 100 + 0.01) tex.sprint(("\\colorlet{ltdtbrightness}{white!%f!black}"):format(avg_intensity)) else tex.sprint(("\\colorlet{ltdtbrightness}{white!%f!black}"):format(0)) end tex.sprint( ("\\path[%s] (%f,%f) -- (%f,%f) -- (%f,%f) -- cycle;") :format( simplex.filloptions, simplex.simplex[1][1], simplex.simplex[1][2], simplex.simplex[2][1], simplex.simplex[2][2], simplex.simplex[3][1], simplex.simplex[3][2] ) ) elseif simplex.type == "label" then table.insert(labels, simplex) end end for _, simplex in ipairs(labels) do tex.sprint( ("\\node at (%f,%f) {%s};") :format(simplex.simplex[1], simplex.simplex[2], simplex.text) ) end lua_tikz3dtools.simplices = {} lua_tikz3dtools.lights = {} end -- ================================================================ -- set_object -- ================================================================ local function set_object(hash) local object = single_string_expression(hash.object) local name = hash.name lua_tikz3dtools.math[name] = object end -- ================================================================ -- Register all TeX commands -- ================================================================ --- Read a TeX macro, returning a fallback if undefined. local function get_macro_or(name, fallback) local val = token.get_macro(name) if val == nil or val == "" then return fallback end return val end function Scene.register_commands() register_tex_cmd("appendpoint", function() append_point{ v = token.get_macro("luatikztdtools@p@p@v"), filloptions = get_macro_or("luatikztdtools@p@p@filloptions", ""), transformation = get_macro_or("luatikztdtools@p@p@transformation", "Matrix:identity3()"), filter = get_macro_or("luatikztdtools@p@p@filter", "return true") } end, { }) register_tex_cmd("appendsurface", function() append_surface{ ustart = token.get_macro("luatikztdtools@p@s@ustart"), ustop = token.get_macro("luatikztdtools@p@s@ustop"), usamples = token.get_macro("luatikztdtools@p@s@usamples"), vstart = token.get_macro("luatikztdtools@p@s@vstart"), vstop = token.get_macro("luatikztdtools@p@s@vstop"), vsamples = token.get_macro("luatikztdtools@p@s@vsamples"), v = token.get_macro("luatikztdtools@p@s@v"), transformation = get_macro_or("luatikztdtools@p@s@transformation", "Matrix:identity3()"), filloptions = get_macro_or("luatikztdtools@p@s@filloptions", ""), filter = get_macro_or("luatikztdtools@p@s@filter", "return true"), } end, { }) register_tex_cmd("appendtriangle", function() append_triangle{ A = token.get_macro("luatikztdtools@p@t@A"), B = token.get_macro("luatikztdtools@p@t@B"), C = token.get_macro("luatikztdtools@p@t@C"), transformation = get_macro_or("luatikztdtools@p@t@transformation", "Matrix:identity3()"), filloptions = get_macro_or("luatikztdtools@p@t@filloptions", ""), filter = get_macro_or("luatikztdtools@p@t@filter", "return true"), } end, { }) register_tex_cmd("appendlabel", function() append_label{ v = token.get_macro("luatikztdtools@p@l@v"), text = token.get_macro("luatikztdtools@p@l@text"), transformation = get_macro_or("luatikztdtools@p@l@transformation", "Matrix:identity3()"), filter = get_macro_or("luatikztdtools@p@l@filter", "return true") } end, { }) register_tex_cmd("appendlight", function() append_light{ v = token.get_macro("luatikztdtools@p@la@v") } end, { }) register_tex_cmd("appendcurve", function() append_curve{ ustart = token.get_macro("luatikztdtools@p@c@ustart"), ustop = token.get_macro("luatikztdtools@p@c@ustop"), usamples = token.get_macro("luatikztdtools@p@c@usamples"), v = token.get_macro("luatikztdtools@p@c@v"), transformation = get_macro_or("luatikztdtools@p@c@transformation", "Matrix:identity3()"), drawoptions = get_macro_or("luatikztdtools@p@c@drawoptions", ""), arrowtip = token.get_macro("luatikztdtools@p@c@arrowtip"), arrowtail = token.get_macro("luatikztdtools@p@c@arrowtail"), filter = get_macro_or("luatikztdtools@p@c@filter", "return true") } end, { }) register_tex_cmd("appendsolid", function() append_solid{ ustart = token.get_macro("luatikztdtools@p@solid@ustart"), ustop = token.get_macro("luatikztdtools@p@solid@ustop"), usamples = token.get_macro("luatikztdtools@p@solid@usamples"), vstart = token.get_macro("luatikztdtools@p@solid@vstart"), vstop = token.get_macro("luatikztdtools@p@solid@vstop"), vsamples = token.get_macro("luatikztdtools@p@solid@vsamples"), wstart = token.get_macro("luatikztdtools@p@solid@wstart"), wstop = token.get_macro("luatikztdtools@p@solid@wstop"), wsamples = token.get_macro("luatikztdtools@p@solid@wsamples"), v = token.get_macro("luatikztdtools@p@solid@v"), transformation = get_macro_or("luatikztdtools@p@solid@transformation", "Matrix:identity3()"), filloptions = get_macro_or("luatikztdtools@p@solid@filloptions", ""), filter = get_macro_or("luatikztdtools@p@solid@filter", "return true") } end, { }) register_tex_cmd("displaysimplices", function() display_simplices() end, { }) register_tex_cmd("setobject", function() set_object{ name = token.get_macro("luatikztdtools@p@m@name"), object = token.get_macro("luatikztdtools@p@m@object"), } end, { }) end return Scene