-- lua-tikz3dtools-implementation.lua --- indiscernable distance local eps = 0.0000001 --- the dot product --- @param u table> a vector --- @param v table> another vector --- @return number the dot product local function dot_product(u, v) local a, b = u[1], v[1] return a[1]*b[1] + a[2]*b[2] + a[3]*b[3] end --- scalar multiplication --- @param a number the scalar coefficient --- @param v table> the vector --- @return table> the scaled vector local function scalar_multiplication(a, v) local tmp = v[1] return { {a*tmp[1], a*tmp[2], a*tmp[3], tmp[4]} } end --- normalizes a vector --- @param v table> the vector --- @return table> the vectors normalization local function normalize(v) local u = v[1] local len = math.sqrt(u[1]^2 + u[2]^2 + u[3]^2) return { {u[1]/len, u[2]/len, u[3]/len, u[4]} } end --- sign function --- @param number number a number --- @return string the sign|zero local function sign(number) if number > 0 then return "positive" end if number == 0 then return "zero" end return "negative" end --- distance between two points --- @param P1 table> a point --- @param P2 table> another point --- @return number their distance local function distance(P1, P2) local A = P1[1] local B = P2[1] return math.sqrt( (A[1]-B[1])^2 + (A[2]-B[2])^2 + (A[3]-B[3])^2 ) end --- vector subtraction --- @param u table> the first vector --- @param v table> the second vector --- @return table> their difference local function vector_subtraction(u, v) local U = u[1] local V = v[1] return { {U[1]-V[1], U[2]-V[2], U[3]-V[3], U[4]} } end --- test for indistinguishable points --- @param P1 table> a point --- @param P2 table> another point --- @return bool whether they intersect local function point_point_intersecting(P1, P2) if distance(P1, P2) < eps then return true else return false end end --- CAUTION: CHATGPT GENERATED -- Correct column-major Gauss-Jordan solver (drop-in replacement) -- M is an array of columns; each column is an array of n rows. -- Last column is RHS. Returns: -- {solution = {..}, freevars = {..}} on success -- nil on inconsistency --- @param M table> an augmented matrix --- @return table> the solution set and free variables local function gauss_jordan(M) -- basic validation local m = #M -- number of columns (vars + RHS) if m == 0 then return {} end local n = #M[1] -- number of rows (equations) local vars = m - 1 if vars < 1 then return nil end for c = 1, m do if #M[c] ~= n then return nil end end -- Work on a local copy local cols = {} for c = 1, m do cols[c] = {} for r = 1, n do cols[c][r] = M[c][r] end end local rank = 0 local row = 1 local pivot_cols = {} -- track pivot columns -- Gauss–Jordan elimination for col = 1, vars do -- find pivot row in column col, rows row..n local pivot_row = nil local maxval = eps for r = row, n do local v = math.abs(cols[col][r]) if v > maxval then maxval = v pivot_row = r end end if pivot_row then -- swap pivot_row with current row if pivot_row ~= row then for c = 1, m do cols[c][row], cols[c][pivot_row] = cols[c][pivot_row], cols[c][row] end end -- normalize pivot row local pivot = cols[col][row] for c = 1, m do cols[c][row] = cols[c][row] / pivot end -- eliminate column in other rows for r = 1, n do if r ~= row then local factor = cols[col][r] if math.abs(factor) > eps then for c = 1, m do cols[c][r] = cols[c][r] - factor * cols[c][row] end cols[col][r] = 0 end end end pivot_cols[#pivot_cols+1] = col rank = rank + 1 row = row + 1 if row > n then break end end end -- check for inconsistency: [0 0 ... | b] with b≠0 for r = rank+1, n do local all_zero = true for c = 1, vars do if math.abs(cols[c][r]) > eps then all_zero = false break end end if all_zero and math.abs(cols[m][r]) > eps then return nil -- inconsistent end end -- Identify free variables local freevars = {} local pivotset = {} for _,c in ipairs(pivot_cols) do pivotset[c] = true end for c = 1, vars do if not pivotset[c] then freevars[#freevars+1] = c end end -- Extract one particular solution local sol = {} for i = 1, vars do sol[i] = 0 end for k,pcol in ipairs(pivot_cols) do sol[pcol] = cols[m][k] -- solution from reduced form end return {solution = sol, freevars = freevars} end --- The orthogonal vector projection. --- @param u table> the vector being projected onto --- @param v table> the vector being projected --- @return table> the resulting projection local function orthogonal_vector_projection(u, v) return scalar_multiplication( dot_product(v, u) / dot_product(u, u), u ) end --- vector addition --- @param u table> the first vector --- @param v table> the second vector --- @return table> their sum local function vector_addition(u, v) local U = u[1] local V = v[1] return { {U[1]+V[1], U[2]+V[2], U[3]+V[3], U[4]} } end --- determined whether a point and a line segment intersect --- @param P table> the point --- @param L table> the line segment --- @return bool whether the point and line intersect local function point_line_segment_intersecting(P, L) local LO = {L[1]} local LU = vector_subtraction({L[2]}, {L[1]}) local LOP = vector_subtraction(P, LO) -- Check orthogonal projection distance local orth = orthogonal_vector_projection(LU, LOP) local real_orth = vector_addition(LO, orth) if distance(real_orth, P) >= eps then return false end local rhs = vector_subtraction(real_orth, LO) -- LO + (t) * LU = orth -- (t) * LU = orth - LO local augmented_matrix = { {LU[1][1], LU[1][2], LU[1][3]} ,{rhs[1][1], rhs[1][2], rhs[1][3]} } local sol = gauss_jordan(augmented_matrix) if not sol then return false end if #sol.solution < 1 then return false end local t = sol.solution[1] return 0+eps> the first vector --- @param v table> the second vector --- @return table> their cross product local function cross_product(u,v) local x = u[1][2]*v[1][3]-u[1][3]*v[1][2] local y = u[1][3]*v[1][1]-u[1][1]*v[1][3] local z = u[1][1]*v[1][2]-u[1][2]*v[1][1] return { {x, y, z, u[1][4]} } end --- Projects a point onto a plane (in a given affine basis) --- @param P table> the point --- @param T table> the AFFINE basis of the plane NOT ITS TRIANGLE --- @return table> the literal intersection and not the coordinates local function orthogonal_projection_onto_plane(P, T) local TO = {T[1]} local TU = {T[2]} local TV = {T[3]} local TW = cross_product(TU, TV) local TOP = vector_subtraction(P, TO) local proj_TW_TOP = orthogonal_vector_projection(TW, TOP) return vector_subtraction(P, proj_TW_TOP) end --- determines whether a point that is known to be in the triangle's plane intersects it --- @param P table> the point which is known to be coplannar with the triangle --- @param T table> the triangle --- @return bool whether they intersect local function point_in_triangle(P, T) local eps = 0.001 local A, B, C = T[1], T[2], T[3] -- vectors relative to A, wrapped as table of tables local v0 = vector_subtraction({C}, {A}) local v1 = vector_subtraction({B}, {A}) local v2 = vector_subtraction(P, {A}) -- dot products local d00 = dot_product(v0, v0) local d01 = dot_product(v0, v1) local d11 = dot_product(v1, v1) local d20 = dot_product(v2, v0) local d21 = dot_product(v2, v1) local denom = d00 * d11 - d01 * d01 if math.abs(denom) < eps then return false -- degenerate triangle end -- barycentric coordinates local v = (d11 * d20 - d01 * d21) / denom local w = (d00 * d21 - d01 * d20) / denom local u = 1 - v - w -- inside triangle with epsilon buffer return u >= -3*eps and v >= -3*eps and w >= -3*eps end --- detects whether a point and a triangle intersect --- @param P table> the point --- @param T table> the triangle --- @return bool whether they intersect local function point_triangle_intersecting(P, T) local TO = {T[1]} local TU = vector_subtraction({T[2]}, TO) local TV = vector_subtraction({T[3]}, TO) local affine_basis = {TO[1], TU[1], TV[1]} local S = orthogonal_projection_onto_plane(P, affine_basis) return distance(S, P) < eps and point_in_triangle(P, T) end --- length of a vector --- @param u table> the vector to be measured --- @return number the vectors length local function length(u) local result = math.sqrt((u[1][1])^2 + (u[1][2])^2 + (u[1][3])^2) return result end --- return coordinates and free variables for line-line intersection --- @param L1 table> affine basis of a line --- @param L1 table> another 1D affine basis --- @return table> the solution and free variables local function line_line_intersection(L1, L2) local L1O = {L1[1]} local L1U = {L1[2]} local L2O = {L2[1]} local L2U = {L2[2]} -- L1O + (t) * L1U = L2O + (s) * L2U -- (t) * L1U - (s) * L2U = L1O - L2O local rhs = vector_subtraction(L2O, L1O) local augmented_matrix = { {L1U[1][1], L1U[1][2], L1U[1][3]}, {-L2U[1][1], -L2U[1][2], -L2U[1][3]}, {rhs[1][1], rhs[1][2], rhs[1][3]} } return gauss_jordan(augmented_matrix) end --- determines the solution coefficients and solution set of two line segments, or produces nil --- @param L1 table> the first line segment --- @param L2 table> the second line segment --- @return table>,table>>|nil the solution coefficients and solution set local function line_segment_line_segment_intersection(L1, L2) local num = 0 for _, P1 in ipairs(L1) do for _, P2 in ipairs(L2) do if distance({P1}, {P2}) < eps then num = num + 1 end end end if num ~= 0 then return nil end local L1O = {L1[1]} local L1U = vector_subtraction({L1[2]}, L1O) local L1A = {L1O[1], L1U[1]} local L2O = {L2[1]} local L2U = vector_subtraction({L2[2]}, L2O) local L2A = {L2O[1], L2U[1]} local coeffs = line_line_intersection(L1A, L2A) if coeffs == nil then return nil end if #coeffs.solution == 0 then return nil end local t, s = coeffs.solution[1], coeffs.solution[2] if 0 < t - eps and t < 1 - eps and 0 < s - eps and s < 1 - eps then return { coefficients = coeffs, intersection = vector_addition( L1O, scalar_multiplication(t, L1U) ) } end return nil end --- obtains the coordinates and free variables of the intersection --- between a line and a plane, each defined by their affine bases. --- @param L table> an affine basis of a line --- @param T table> an affine basis of a plane --- @return table> the solution and free variables local function line_plane_intersection(L, T) local LO = {L[1]} local LU = {L[2]} local TO = {T[1]} local TU = {T[2]} local TV = {T[3]} --- LO + (t) * LU = TO + (s) * TU + (w) * TV -- (t) * LU - (s) * TU - (w) * TV = TO - LO local rhs = vector_subtraction(TO, LO) local augmented_matrix = { {LU[1][1], LU[1][2], LU[1][3]}, {-TU[1][1], -TU[1][2], -TU[1][3]}, {-TV[1][1], -TV[1][2], -TV[1][3]}, {rhs[1][1], rhs[1][2], rhs[1][3]} } local sol = gauss_jordan(augmented_matrix) if not sol then -- print("No solution: line-plane system inconsistent") return nil end if #sol.freevars > 0 then -- print("Line lies in the plane (coplanar case). Free vars:", #sol.freevars) end return sol end --- determines the intersection coefficients and intersection point of a line segment and triangle, if it exists, or returns nil --- @param L table> line segment defined by endpoints --- @param T table> triangle defined by vertices --- @return table>,table>>|nil the solution coefficients and literal R3 intersection point local function line_segment_triangle_intersection(L, T) local eps = 0.0000001 local num = 0 for _, P1 in ipairs(L) do for _, P2 in ipairs(T) do if distance({P1}, {P2}) < eps then num = num + 1 end end end if num > 1 then return nil end local LO = {L[1]} local LU = vector_subtraction({L[2]}, LO) local LA = {LO[1], LU[1]} local TO = {T[1]} local TU = vector_subtraction({T[2]}, TO) local TUA = {TO[1], TU[1]} local TV = vector_subtraction({T[3]}, TO) local TVA = {TO[1], TV[1]} local TA = {TO[1], TU[1], TV[1]} local TUVA = { vector_addition(TO, TU)[1] ,vector_subtraction(TV, TU)[1] } local coeffs = line_plane_intersection(LA, TA) if coeffs == nil then return nil end if #coeffs.freevars == 0 then local t = coeffs.solution[1] if 0+eps<=t and t<=1-eps then local I = vector_addition( LO, scalar_multiplication(t, LU) ) if point_in_triangle(I, T) then return { solution = coeffs, intersection = I } end end end return nil end local function fmt_vec(v) if type(v) ~= "table" then return tostring(v) end local parts = {} for i = 1, #v do parts[#parts+1] = tostring(v[i]) end return "{" .. table.concat(parts, ", ") .. "}" end local function fmt_matrix(m) if type(m) ~= "table" then return tostring(m) end local rows = {} for r = 1, #m do rows[#rows+1] = fmt_vec(m[r]) end return "[" .. table.concat(rows, " | ") .. "]" end --- produces exactly zero, or exactly two intersection points between two triangles --- @param T1 table> a triangle defined by its vertices --- @param T2 table> another triangle defined by its vertices --- @return table> a matrix of exactly zero or exactly two points nothing else local function triangle_triangle_intersections(T1, T2) local edges1 = { {T1[1], T1[2]}, {T1[2], T1[3]}, {T1[3], T1[1]} } local edges2 = { {T2[1], T2[2]}, {T2[2], T2[3]}, {T2[3], T2[1]} } local points = {} --- appends a point to a list if it is unique --- @param P table> a point local function add_unique(P) -- if not P then return nil end for _, Q in ipairs(points) do if point_point_intersecting(P, Q) then return nil end end table.insert(points, P) end -- Check all edge pairs for _, E1 in ipairs(edges1) do local intersect = line_segment_triangle_intersection(E1, T2) if intersect ~= nil then add_unique(intersect.intersection) end end for _, E2 in ipairs(edges2) do local intersect = line_segment_triangle_intersection(E2, T1) if intersect ~= nil then add_unique(intersect.intersection) end end if #points ~= 2 then -- print(("two triangles intersected at %f points"):format(#points)) -- print(fmt_matrix(T1)) -- print(fmt_matrix(T2)) return nil --assert(false, ("two triangles intersected at %f points"):format(#points)) end return points end --- returns a table of line segments - the partition of L into two - if L itnersects a point, else returns nil --- @param P table> the point --- @param L table> the line segment --- @return table>,table>>|nil the intersection points if they exist local function point_line_segment_partition(P, L) if point_line_segment_intersecting(P, L) then return { point1 = {L[1], P[1]}, point2 = {L[2], P[1]} } end return nil end --- if two line segments intersect, returns the pieces of the first --- one, after being partitioned by the intersection with the other --- @param L1 table> the first line segment --- @param L2 table> the second line segment --- @return table>,table>> the fragmented pieces of the first segment local function line_segment_line_segment_partition(L1, L2) local eps = 0.0000001 local intersect = line_segment_line_segment_intersection(L1, L2) if intersect == nil then return nil end local I = intersect.intersection -- if distance(I, L1) < eps then -- return { -- line_segment1 = {L2[1], I[1]}, -- line_segment2 = line_segment1 -- } -- end -- if distance(I, L2) < eps then -- return { -- line_segment1 = {L1[1], I[1]}, -- line_segment2 = line_segment1 -- } -- end return { line_segment1 = {L1[1], I[1]}, line_segment2 = {L1[2], I[1]} } end --- partitions a line segment by its intersection with a triangle, or returns nil --- @param L table> the line segment --- @param T table> the triangle --- @return table>,table>>|nil the partitioned line segment or nil local function line_segment_triangle_clip(L, T) local intersect = line_segment_triangle_intersection(L, T) if intersect == nil then return nil end local I = intersect.intersection return { line_segment1 = {L[1], I[1]}, line_segment2 = {L[2], I[1]} } end --- ChatGPT written centroid sorting function, for the purpose of convex polygon ordering --- @param points table> the not necessarily convex matrix of polygon points --- @return table> the definitively convex matrix of polygon points local function centroid_sort(points) local num = #points assert(num >= 3, "Need at least 3 points to sort by centroid.") -- Compute centroid local sum = { {0, 0, 0, 1} } for i = 1, num do sum = vector_addition(sum, {points[i]}) end local centroid = scalar_multiplication(1/num, sum) -- Build local basis for projection local v1 = vector_subtraction({points[2]}, {points[1]}) local v2 = vector_subtraction({points[3]}, {points[1]}) local normal = cross_product(v1, v2) -- Pick axes in the plane local x_axis = v1 local y_axis = cross_product(normal, x_axis) x_axis = normalize(x_axis) y_axis = normalize(y_axis) -- Compute angle of each point relative to centroid local angles = {} for i, P in ipairs(points) do local rel = vector_subtraction({P}, centroid)[1] local x = rel[1]*x_axis[1][1] + rel[2]*x_axis[1][2] + rel[3]*x_axis[1][3] local y = rel[1]*y_axis[1][1] + rel[2]*y_axis[1][2] + rel[3]*y_axis[1][3] local theta = math.atan2(y, x) table.insert(angles, {point = P, angle = theta}) end -- Sort by angle table.sort(angles, function(a, b) return a.angle < b.angle end) -- Extract sorted points local sorted = {} for _, entry in ipairs(angles) do table.insert(sorted, entry.point) end return sorted end --- returns the partitio of the first triangle into three subtriangles, --- if it intersects the second, otherwise produces nil --- @param T1 table> the first triangle --- @param T2 table> the second triangle --- @return table>,table>,table>> table of sub triangles local function triangle_triangle_partition(T1, T2) local I = triangle_triangle_intersections(T1, T2) if I == nil then return nil end if #I == 0 then return nil end if #I == 1 then return nil end if #I ~= 2 then assert(false, ("I is not 2, it is instead: %f"):format(#I)) end local IO = I[1] local IU = vector_subtraction(I[2], IO) local I_basis = {IO[1], IU[1]} local T1A = {T1[1], T1[2]} local T1AU = vector_subtraction({T1A[2]}, {T1A[1]}) local T1A_basis = {T1A[1], T1AU[1]} local T1B = {T1[2], T1[3]} local T1BU = vector_subtraction({T1B[2]}, {T1B[1]}) local T1B_basis = {T1B[1], T1BU[1]} local T1C = {T1[3], T1[1]} local T1CU = vector_subtraction({T1C[2]}, {T1C[1]}) local T1C_basis = {T1C[1], T1CU[1]} local T2A = {T2[1], T2[2]} local T2AU = vector_subtraction({T2A[2]}, {T2A[1]}) local T2A_basis = {T2A[1], T2AU[1]} local T2B = {T2[2], T2[3]} local T2BU = vector_subtraction({T2B[2]}, {T2B[1]}) local T2B_basis = {T2B[1], T2BU[1]} local T2C = {T2[3], T2[1]} local T2CU = vector_subtraction({T2C[2]}, {T2C[1]}) local T2C_basis = {T2C[1], T2CU[1]} local points = {} local non_intersecting = nil local int1 = line_line_intersection(I_basis, T1A_basis) if int1 == nil then int1 = {solution = {}} end if #int1.solution ~= 0 then local t = int1.solution[1] local intersect = vector_addition( IO, scalar_multiplication(t, IU) ) if point_line_segment_intersecting(intersect, T1A) then table.insert(points, intersect) else non_intersecting = "T1A" end else non_intersecting = "T1A" end local int2 = line_line_intersection(I_basis, T1B_basis) if int2 == nil then int2 = {solution = {}} end if #int2.solution ~= 0 then local t = int2.solution[1] local intersect = vector_addition( IO, scalar_multiplication(t, IU) ) if point_line_segment_intersecting(intersect, T1B) then table.insert(points, intersect) else non_intersecting = "T1B" end else non_intersecting = "T1B" end local int3 = line_line_intersection(I_basis, T1C_basis) if int3 == nil then int3 = {solution = {}} end if #int3.solution ~= 0 then local t = int3.solution[1] local intersect = vector_addition( IO, scalar_multiplication(t, IU) ) if point_line_segment_intersecting(intersect, T1C) then table.insert(points, intersect) else non_intersecting = "T1C" end else non_intersecting = "T1C" end -- if #points == 3 then return nil end -- if #points == 1 then return nil end if #points ~= 2 then -- print("Partition failure: got", #points, "points") -- print("Triangle 1:", T1) -- print("Triangle 2:", T2) -- print("Non-intersecting edge:", non_intersecting) -- for i, p in ipairs(points) do -- print("Point", i, p[1][1], p[1][2], p[1][3]) -- end return nil --assert(false, "triangle_triangle_partition doesn't have exactly two points") end local quad = {} local tri1 local A, B = points[1], points[2] table.insert(quad, A[1]) table.insert(quad, B[1]) if non_intersecting == "T1A" then table.insert(quad, T1A[1]) table.insert(quad, T1A[2]) tri1 = {A[1], B[1], T1B[2]} elseif non_intersecting == "T1B" then table.insert(quad, T1B[1]) table.insert(quad, T1B[2]) tri1 = {A[1], B[1], T1C[2]} elseif non_intersecting == "T1C" then table.insert(quad, T1C[1]) table.insert(quad, T1C[2]) tri1 = {A[1], B[1], T1A[2]} end quad = centroid_sort(quad) return { tri1 = tri1, tri2 = {quad[1], quad[2], quad[3]}, tri3 = {quad[3], quad[4], quad[1]} } end -- https://tex.stackexchange.com/a/747040 --- Creates a TeX command that evaluates a Lua function --- --- @param name string The name of the `\csname` to define --- @param func function --- @param args table The TeX types of the function arguments --- @param protected boolean|nil Define the command as `\protected` --- @return nil local function register_tex_cmd(name, func, args, protected) -- The extended version of this function uses `N` and `w` where appropriate, -- but only using `n` is good enough for exposition purposes. name = "__lua_tikztdtools_" .. name .. ":" .. ("n"):rep(#args) -- Push the appropriate scanner functions onto the scanning stack. local scanners = {} for _, arg in ipairs(args) do scanners[#scanners+1] = token['scan_' .. arg] end -- An intermediate function that properly "scans" for its arguments -- in the TeX side. 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 local segments = {} -- lua-tikz3dtools-matrix.lua local mm = {} mm.tau = 2*math.pi local pi = math.pi local cos, sin = math.cos, math.sin --- matrix multiplication --- --- @param A table> left matrix --- @param B table> right matrix --- @return table> their product function mm.matrix_multiply(A, B) local rows_A = #A local columns_A = #A[1] local rows_B = #B local columns_B = #B[1] assert( columns_A == rows_B, string.format( [[ Wrong size matrices for multiplication. Size A: %d,%d Size B: %d,%d ]], rows_A, columns_A, rows_B, columns_B ) ) local product = {} for row = 1, rows_A do product[row] = {} for column = 1, columns_B do product[row][column] = 0 for dot_product_step = 1, columns_A do local a = A[row][dot_product_step] local b = B[dot_product_step][column] assert(type(a) == "number", string.format("Expected number but got %s in A[%d][%d]", type(a), row, dot_product_step)) assert(type(b) == "number", string.format("Expected number but got %s in B[%d][%d]", type(b), dot_product_step, column)) product[row][column] = product[row][column] + a * b end end end return product end function mm.reciprocate_by_homogenous(vector) local result = {} for i = 1, #vector do local row = vector[i] local w = row[4] if w == 0 then error("Cannot reciprocate row " .. i .. ": homogeneous coordinate w = 0") end --if w<0 then w=-w end result[i] = { row[1]/w, row[2]/w, row[3]/w, 1 } end return result end function mm.transpose(A) local rows_A = #A local columns_A = #A[1] local result = {} for row = 1, columns_A, 1 do result[row] = {} for column = 1, rows_A, 1 do result[row][column] = A[column][row] end end return result end function mm.matrix_inverse(matrix) local rows = #matrix local columns = #matrix[1] assert(rows == columns, "You can only take the inverse of a square matrix.") local det = mm.det(matrix) assert(math.abs(math.abs(det)) > 0.00001, "You cannot take the inverse of a singular matrix.") local n = rows -- Build an augmented matrix [A | I] local augment = {} for i = 1, n do augment[i] = {} -- copy row i of A for j = 1, n do augment[i][j] = matrix[i][j] end -- append row i of I for j = 1, n do augment[i][n + j] = (i == j) and 1 or 0 end end -- Gauss-Jordan elimination for i = 1, n do -- If pivot is zero (or very close), swap with a lower row that has a nonzero pivot if math.abs(augment[i][i]) < 1e-12 then local swapRow = nil for r = i + 1, n do if math.abs(augment[r][i]) > 1e-12 then swapRow = r break end end assert(swapRow, "Matrix is singular (zero pivot encountered).") augment[i], augment[swapRow] = augment[swapRow], augment[i] end -- Normalize row i so that augment[i][i] == 1 local pivot = augment[i][i] for col = 1, 2 * n do augment[i][col] = augment[i][col] / pivot end -- Eliminate column i in all other rows for r = 1, n do if r ~= i then local factor = augment[r][i] for col = 1, 2 * n do augment[r][col] = augment[r][col] - factor * augment[i][col] end end end end -- Extract the inverse matrix from the augmented result local inv = {} for i = 1, n do inv[i] = {} for j = 1, n do inv[i][j] = augment[i][n + j] end end return inv end function mm.det(matrix) local rows = #matrix local columns = #matrix[1] assert(rows > 0, "Matrix must have at least one row to take determinant.") assert(columns > 0, "Matrix must have at least one column to take determinant.") assert(rows == columns, "You can only take the determinant of a square matrix.") if rows == 1 then return matrix[1][1] elseif rows == 2 then -- return a*d - b*c return matrix[1][1] * matrix[2][2] - matrix[1][2] * matrix[2][1] end -- We will do a cofactor expansion on the first row. local det = 0 local minor local new_row for element = 1, columns, 1 do minor = {} for row = 2, rows, 1 do new_row = {} for column = 1, columns, 1 do if column ~= element then table.insert(new_row, matrix[row][column]) end end table.insert(minor,new_row) end det = det + matrix[1][element] * (-1)^(element+1) * mm.det(minor) end return det end function mm.yrotation(angle) local c = cos(angle) local s = sin(angle) return { {c,0,-s,0} ,{0,1,0,0} ,{s,0,c,0} ,{0,0,0,1} } end function mm.translate(x,y,z) return { {1,0,0,0} ,{0,1,0,0} ,{0,0,1,0} ,{x,y,z,1} } end function mm.xscale(scale) return { {scale,0,0,0} ,{0,1,0,0} ,{0,0,1,0} ,{0,0,0,1} } end function mm.yscale(scale) return { {1,0,0,0} ,{0,scale,0,0} ,{0,0,1,0} ,{0,0,0,1} } end function mm.zscale(scale) return { {1,0,0,0} ,{0,1,0,0} ,{0,0,scale,0} ,{0,0,0,1} } end function mm.scale(scale) return { {scale,0,0,0} ,{0,scale,0,0} ,{0,0,scale,0} ,{0,0,0,1} } end function mm.xrotation(angle) return { {1,0,0,0} ,{0,math.cos(angle),math.sin(angle),0} ,{0,-math.sin(angle),math.cos(angle),0} ,{0,0,0,1} } end function mm.zrotation(angle) local c = cos(angle) local s = sin(angle) return { {c,s,0,0} ,{-s,c,0,0} ,{0,0,1,0} ,{0,0,0,1} } end function mm.euler(alpha,beta,gamma) return mm.matrix_multiply( mm.zrotation(gamma) ,mm.matrix_multiply( mm.yrotation(beta) ,mm.zrotation(alpha) ) ) end function mm.sphere(longitude,latitude) local s = sin(latitude) return {{ s * cos(longitude) ,s * sin(longitude) ,cos(latitude) ,1 }} end function mm.matrix_add(A, B) local rows_A = #A local columns_A = #A[1] local rows_B = #B local columns_B = #B[1] assert(rows_A == rows_B and columns_A == columns_B, "Wrong size matrices for addition.") local sum = {} for row = 1, rows_A, 1 do sum[row] = {} for column = 1, columns_A, 1 do sum[row][column] = A[row][column] + B[row][column] end end return sum end function mm.matrix_subtract(A,B) local rows_A = #A local columns_A = #A[1] local rows_B = #B local columns_B = #B[1] assert(rows_A == rows_B and columns_A == columns_B, "Wrong size matrices for subtraction.") local sum = {} for row = 1, rows_A, 1 do sum[row] = {} for column = 1, columns_A, 1 do sum[row][column] = A[row][column] - B[row][column] end end return sum end function mm.matrix_scale(factor, A) local rows = #A local cols = #A[1] local result = {} for i = 1, rows do result[i] = {} for j = 1, cols do result[i][j] = A[i][j] * factor end end return result end function mm.sign(number) if number >= 0 then return "positive" end return "negative" end function mm.dot_product(u,v) local result = u[1][1]*v[1][1] + u[1][2]*v[1][2] + u[1][3]*v[1][3] return result end function mm.cross_product(u,v) local x = u[1][2]*v[1][3]-u[1][3]*v[1][2] local y = u[1][3]*v[1][1]-u[1][1]*v[1][3] local z = u[1][1]*v[1][2]-u[1][2]*v[1][1] local result = {{x,y,z,u[1][4]}} return result end function mm.norm(u) local result = math.sqrt((u[1][1])^2 + (u[1][2])^2 + (u[1][3])^2) return result end function mm.normalize(vector) local len = mm.norm(vector) return {{ vector[1][1]/len ,vector[1][2]/len ,vector[1][3]/len ,1 }} end function mm.identity_matrix() local I = {} for i = 1, 4 do I[i] = {} for j = 1, 4 do I[i][j] = (i == j) and 1 or 0 end end return I end function mm.midpoint(triangle) local P,Q,R = table.unpack(triangle) local x = (P[1]+Q[1]+R[1])/3 local y = (P[2]+Q[2]+R[2])/3 local z = (P[3]+Q[3]+R[3])/3 return {{x,y,z,1}} end function mm.orthogonal_vector(u) local v if (math.abs(u[1][1])>0.001 and math.abs(u[1][2])<0.001 and math.abs(u[1][3])<0.001) then v = mm.cross_product(u,{{0,1,0,1}}) else v = mm.cross_product(u,{{1,0,0,1}}) end return v end function mm.get_observer_plane_basis(observer) local origin = {{0,0,0,1}} local basis_i = mm.orthogonal_vector(observer) basis_i = mm.normalize(basis_i) local basis_j = mm.cross_product(observer,basis_i) basis_j = mm.normalize(basis_j) return {origin,basis_i,basis_j} end function mm.orthogonal_vector_projection(base_vector,projected_vector) local scale = ( mm.dot_product(base_vector,projected_vector) / mm.dot_product(base_vector,base_vector) ) return {{base_vector[1][1]*scale,base_vector[1][2]*scale,base_vector[1][3]*scale,1}} end function mm.project_point_onto_basis(point,basis) local normal = mm.cross_product(basis[2],basis[3]) normal = mm.normalize(normal) local vector_from_plane = mm.orthogonal_vector_projection(point,normal) local result = {{ point[1][1]-vector_from_plane[1][1] ,point[1][2]-vector_from_plane[1][2] ,point[1][3]-vector_from_plane[1][3] ,1 }} return result end function mm.transform_about(triplet_point,f) local p = triplet_point local a = triplet_angles return mm.matrix_multiply( mm.matrix_inverse(mm.translate(p[1],p[2],p[3])) ,mm.matrix_multiply(f,mm.translate(p[1],p[2],p[3])) ) end function mm.scale3(x,y,z) return { {x,0,0,0} ,{0,y,0,0} ,{0,0,z,0} ,{0,0,0,1} } end local lua_tikz3dtools_parametric = {} lua_tikz3dtools_parametric.math = {} for k, v in pairs(_G) do lua_tikz3dtools_parametric.math[k] = v end for k, v in pairs(mm) do lua_tikz3dtools_parametric.math[k] = v end for k, v in pairs(math) do lua_tikz3dtools_parametric.math[k] = v end local function single_string_expression(str) local func = load(([[return %s]]):format(str), "expression", "t", lua_tikz3dtools_parametric.math)() -- if not func then -- error("Failed to parse expression: " .. expr) -- return -- end -- local ok, result = pcall(func) -- if not ok then -- error("Error evaluating expression: " .. result) -- return -- end -- if type(result) ~= "function" then -- error("Expected a function, got: " .. type(result)) -- return -- end return func--result end local function single_string_function(str) return load(("return function(u) return %s end"):format(str), "expression", "t", lua_tikz3dtools_parametric.math)() end local function double_string_function(str) return load(("return function(u,v) return %s end"):format(str), "expression", "t", lua_tikz3dtools_parametric.math)() end local function triple_string_function(str) return load(("return function(u,v,w) return %s end"):format(str), "expression", "t", lua_tikz3dtools_parametric.math)() end local function append_point(hash) local x = hash.x local y = hash.y local z = hash.z local filloptions = hash.filloptions local transformation = hash.transformation x = single_string_expression(x) y = single_string_expression(y) z = single_string_expression(z) transformation = single_string_expression(transformation) local A = { { x, y, z, 1 } } local the_segment = mm.matrix_multiply(A, transformation) table.insert( segments, { segment = the_segment, filloptions = filloptions, type = "point" } ) end register_tex_cmd( "appendpoint", function() append_point{ x = token.get_macro("luatikztdtools@p@p@x"), y = token.get_macro("luatikztdtools@p@p@y"), z = token.get_macro("luatikztdtools@p@p@z"), filloptions = token.get_macro("luatikztdtools@p@p@filloptions"), transformation = token.get_macro("luatikztdtools@p@p@transformation") } end, { } ) local function append_surface(hash) local ustart = hash.ustart local ustop = hash.ustop local usamples = hash.usamples local vstart = hash.vstart local vstop = hash.vstop local vsamples = hash.vsamples local x = hash.x local y = hash.y local z = hash.z local drawoptions = hash.drawoptions local filloptions = hash.filloptions local transformation = hash.transformation local filter = hash.filter x = double_string_function(x) y = double_string_function(y) z = double_string_function(z) ustart = single_string_expression(ustart) ustop = single_string_expression(ustop) usamples = single_string_expression(usamples) vstart = single_string_expression(vstart) vstop = single_string_expression(vstop) vsamples = single_string_expression(vsamples) transformation = single_string_expression(transformation) local ustep = (ustop - ustart) / (usamples - 1) local vstep = (vstop - vstart) / (vsamples - 1) local function parametric_surface(u, v) return { x(u,v), y(u,v), z(u,v), 1 } end local function distance(P1, P2) return math.sqrt( (P1[1]-P2[1])^2 + (P1[2]-P2[2])^2 + (P1[3]-P2[3])^2 ) end local eps = 0.0000001 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, v + vstep) local D = parametric_surface(u + ustep, v + vstep) local the_segment1 = mm.matrix_multiply({ A, B, D },transformation) local the_segment2 = mm.matrix_multiply({ C, A, D },transformation) if not ( distance(A, B) < eps or distance(B, D) < eps or distance(A, D) < eps ) then table.insert( segments, { segment = the_segment1, filloptions = filloptions, type = "triangle" ,filter = filter } ) end if not ( distance(A, C) < eps or distance(C, D) < eps or distance(A, D) < eps ) then table.insert( segments, { segment = the_segment2, filloptions = filloptions, type = "triangle", filter = filter } ) end end end 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"), x = token.get_macro("luatikztdtools@p@s@x"), y = token.get_macro("luatikztdtools@p@s@y"), z = token.get_macro("luatikztdtools@p@s@z"), transformation = token.get_macro("luatikztdtools@p@s@transformation"), filloptions = token.get_macro("luatikztdtools@p@s@filloptions"), filter = token.get_macro("luatikztdtools@p@s@filter"), } end, { } ) local function append_label(hash) local x = hash.x local y = hash.y local z = hash.z local name = hash.name local transformation = hash.transformation x = single_string_expression(x) y = single_string_expression(y) z = single_string_expression(z) transformation = single_string_expression(transformation) local A = { { x, y, z, 1 } } local the_segment = mm.matrix_multiply(A, transformation) table.insert( segments, { segment = the_segment, type = "point", name = name } ) end register_tex_cmd( "appendlabel", function() append_label{ x = token.get_macro("luatikztdtools@p@l@x"), y = token.get_macro("luatikztdtools@p@l@y"), z = token.get_macro("luatikztdtools@p@l@z"), name = token.get_macro("luatikztdtools@p@l@name"), transformation = token.get_macro("luatikztdtools@p@l@transformation") } end, { } ) local function append_curve(hash) local ustart = hash.ustart local ustop = hash.ustop local usamples = hash.usamples local x = hash.x local y = hash.y local z = hash.z local transformation = hash.transformation local drawoptions = hash.drawoptions local arrowtip = single_string_expression(hash.arrowtip) local arrowtail = single_string_expression(hash.arrowtail) local arrowoptions = hash.arrowoptions ustart = single_string_expression(ustart) ustop = single_string_expression(ustop) usamples = single_string_expression(usamples) x = single_string_function(x) y = single_string_function(y) z = single_string_function(z) transformation = single_string_expression(transformation) drawoptions = drawoptions local ustep = (ustop - ustart) / (usamples - 1) local function parametric_curve(u) return { { x(u), y(u), z(u), 1 } } end for i = 0, usamples - 2 do local u = ustart + i * ustep local A = parametric_curve(u) local B = parametric_curve(u+ustep) local thesegment = mm.matrix_multiply({ A[1], B[1] }, transformation) table.insert( segments, { segment = thesegment, drawoptions = drawoptions, type = "line segment" } ) if i == 0 and arrowtail == true then -- append_surface{ -- } elseif i == usamples - 2 and arrowtip == true then local P = parametric_curve(ustop) local NP = parametric_curve(ustop-ustep) NP = mm.matrix_subtract(P, NP) NP = mm.normalize(NP) local U = mm.cross_product(NP, mm.orthogonal_vector(NP)) local V = mm.cross_product(NP, U) append_surface{ ustart = 0 ,ustop = 0.1 ,usamples = 2 ,vstart = 0 ,vstop = 1 ,vsamples = 4 ,x = "u*cos(v*tau)" ,y = "u*sin(v*tau)" ,z = "-u" ,filloptions = arrowoptions ,transformation = ([[ matrix_multiply( { {%f,%f,%f,0} ,{%f,%f,%f,0} ,{%f,%f,%f,0} ,{%f,%f,%f,1} } ,%s ) ]]):format( U[1][1],U[1][2],U[1][3] ,V[1][1],V[1][2],V[1][3] ,NP[1][1],NP[1][2],NP[1][3] ,P[1][1],P[1][2],P[1][3] ,hash.transformation ) } end end 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"), x = token.get_macro("luatikztdtools@p@c@x"), y = token.get_macro("luatikztdtools@p@c@y"), z = token.get_macro("luatikztdtools@p@c@z"), transformation = token.get_macro("luatikztdtools@p@c@transformation"), drawoptions = token.get_macro("luatikztdtools@p@c@drawoptions"), arrowtip = token.get_macro("luatikztdtools@p@c@arrowtip"), arrowtail = token.get_macro("luatikztdtools@p@c@arrowtail"), arrowoptions = token.get_macro("luatikztdtools@p@c@arrowtipoptions") } end, { } ) local function append_solid(hash) -- Evaluate bounds and samples 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 -- Compile x,y,z as triple-string functions local x = triple_string_function(hash.x) local y = triple_string_function(hash.y) local z = triple_string_function(hash.z) -- Parametric solid function local function parametric_solid(u,v,w) return { x(u,v,w), y(u,v,w), z(u,v,w), 1 } end -- Steps for tessellation local ustep = (ustop - ustart)/(usamples-1) local vstep = (vstop - vstart)/(vsamples-1) local wstep = (wstop - wstart)/(wsamples-1) -- Helper to tessellate a single face 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 s1a = s1_start + i*s1_step local s1b = s1_start + (i+1)*s1_step for j = 0, s2_count-2 do local s2a = s2_start + j*s2_step local s2b = s2_start + (j+1)*s2_step -- Compute 4 corners of the quad local A,B,C,D if fixed_var == "u" then A = parametric_solid(fixed_val, s1a, s2a) B = parametric_solid(fixed_val, s1b, s2a) C = parametric_solid(fixed_val, s1a, s2b) D = parametric_solid(fixed_val, s1b, s2b) elseif fixed_var == "v" then A = parametric_solid(s1a, fixed_val, s2a) B = parametric_solid(s1b, fixed_val, s2a) C = parametric_solid(s1a, fixed_val, s2b) D = parametric_solid(s1b, fixed_val, s2b) else -- fixed_var == "w" A = parametric_solid(s1a, s2a, fixed_val) B = parametric_solid(s1b, s2a, fixed_val) C = parametric_solid(s1a, s2b, fixed_val) D = parametric_solid(s1b, s2b, fixed_val) end -- Insert two triangles for the quad table.insert(segments, { segment = mm.matrix_multiply({A,B,D}, transformation), filloptions=filloptions, type="triangle", filter = filter }) table.insert(segments, { segment = mm.matrix_multiply({A,C,D}, transformation), filloptions=filloptions, type="triangle", filter = filter }) end end end -- Tessellate all 6 faces tessellate_face("w", wstart, ustart, ustep, usamples, vstart, vstep, vsamples) -- front tessellate_face("w", wstop, ustart, ustep, usamples, vstart, vstep, vsamples) -- back tessellate_face("u", ustart, vstart, vstep, vsamples, wstart, wstep, wsamples) -- left tessellate_face("u", ustop, vstart, vstep, vsamples, wstart, wstep, wsamples) -- right tessellate_face("v", vstart, ustart, ustep, usamples, wstart, wstep, wsamples) -- bottom tessellate_face("v", vstop, ustart, ustep, usamples, wstart, wstep, wsamples) -- top 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"), x = token.get_macro("luatikztdtools@p@solid@x"), y = token.get_macro("luatikztdtools@p@solid@y"), z = token.get_macro("luatikztdtools@p@solid@z"), transformation = token.get_macro("luatikztdtools@p@solid@transformation"), filloptions = token.get_macro("luatikztdtools@p@solid@filloptions"), filter = token.get_macro("luatikztdtools@p@solid@filter") } end, { } ) --- Create a unique signature string for any fragment --- Ensures line segments are order-independent --- @param frag table a fragment (point, line segment, or triangle) --- @param eps number tolerance --- @return string the signature local function fragment_signature(frag, eps) eps = eps or 1e-7 local function key_point(p) -- round to tolerance to avoid floating noise return string.format("%.7f,%.7f,%.7f,%.7f", p[1], p[2], p[3],p[4]) end if frag.type == "point" then return "P:" .. key_point(frag.segment[1]) elseif frag.type == "line segment" then local a = key_point(frag.segment[1]) local b = key_point(frag.segment[2]) -- unordered: sort lexicographically if a < b then return "L:" .. a .. "|" .. b else return "L:" .. b .. "|" .. a end elseif frag.type == "triangle" then local pts = {} for i = 1,3 do pts[i] = key_point(frag.segment[i]) end table.sort(pts) return "T:" .. table.concat(pts, "|") end return "?" end ----------------------- -- ======================================================================== -- Topological sort with partitioning and cycle handling -- ======================================================================== local function apply_filters(segments) local DEBUG = true local function dprintf(tag, msg) if DEBUG then texio.write_nl(("lua | [%s] %s"):format(tag, msg)) end end local function fmt_vec(v) if type(v) ~= "table" then return tostring(v) end local parts = {} for i = 1, #v do parts[#parts+1] = tostring(v[i]) end return "{" .. table.concat(parts, ", ") .. "}" end local function fmt_matrix(m) if type(m) ~= "table" then return tostring(m) end local rows = {} for r = 1, #m do rows[#rows+1] = fmt_vec(m[r]) end return "[" .. table.concat(rows, " | ") .. "]" end -- dprintf("FILTER", ("starting filter pass; total segments=%d"):format(#segments)) local new_segments = {} local kept, culled, errors = 0, 0, 0 for idx, frag in ipairs(segments) do local tag = ("frag #%d"):format(idx) if frag.type ~= "triangle" or not frag.filter then new_segments[#new_segments+1] = frag -- dprintf("KEEP", ("%s type=%s no filter -> keep"):format(tag, tostring(frag.type))) else -- dprintf("FILTER", ("%s raw filter string: %s"):format(tag, tostring(frag.filter))) -- homogenize vertices as 1x4 matrices local A = { { table.unpack(frag.segment[1]) } } local B = { { table.unpack(frag.segment[2]) } } local C = { { table.unpack(frag.segment[3]) } } -- dprintf("ENV", ("%s vertices: A=%s B=%s C=%s"):format(tag, fmt_matrix(A), fmt_matrix(B), fmt_matrix(C))) -- dprintf("ENV", ("%s segment matrix: %s"):format(tag, fmt_matrix(frag.segment))) -- build environment local env = { A = A, B = B, C = C } for k,v in pairs(lua_tikz3dtools_parametric.math) do env[k] = v end local chunk, load_err = load("return (" .. frag.filter .. ")", "filter-chunk", "t", env) if not chunk then dprintf("ERROR", ("%s load failed: %s"):format(tag, tostring(load_err))) new_segments[#new_segments+1] = frag errors = errors + 1 else local ok, result = pcall(chunk) -- dprintf("EVAL", ("%s ok=%s result(type=%s)=%s"):format(tag, tostring(ok), type(result), tostring(result))) if not ok then dprintf("ERROR", ("%s runtime error: %s"):format(tag, tostring(result))) new_segments[#new_segments+1] = frag errors = errors + 1 elseif result then new_segments[#new_segments+1] = frag kept = kept + 1 else culled = culled + 1 end end end end -- dprintf("FILTER", ("filtering done; kept=%d culled=%d errors=%d"):format(kept, culled, errors)) return new_segments end -- local function topo_sort_with_cycles(items, cmp) -- max_depth = 16 -- local eps_local = 1e-7 -- less strict tolerance -- local DEBUG = false -- local global_seen = {} -- local deduplication per call -- -- --------------------------------------------------------------- -- -- type checks -- -- --------------------------------------------------------------- -- local function is_line_segment(p) return p.type == "line segment" or p.type == "line" end -- local function is_triangle(p) return p.type == "triangle" end -- local function is_point(p) return p.type == "point" end -- local function get_bbox(p) -- local minX, maxX = math.huge, -math.huge -- local minY, maxY = math.huge, -math.huge -- for _, P in ipairs(p.segment or {}) do -- local x, y = P[1] or 0, P[2] or 0 -- minX, maxX = math.min(minX, x), math.max(maxX, x) -- minY, maxY = math.min(minY, y), math.max(maxY, y) -- end -- return minX, maxX, minY, maxY -- end -- local function bboxes_overlap(b1, b2) -- local minX1, maxX1, minY1, maxY1 = table.unpack(b1) -- local minX2, maxX2, minY2, maxY2 = table.unpack(b2) -- return not (maxX1 < minX2+eps or maxX2 < minX1 or maxY1 < minY2 or maxY2 < minY1) -- end -- -- --------------------------------------------------------------- -- -- cloning / projection helpers -- -- --------------------------------------------------------------- -- local next_frag_id = 1 -- local function clone_fragment(orig) -- local copy = {} -- for k,v in pairs(orig) do if k ~= "segment" then copy[k] = v end end -- copy.segment = {} -- for _, P in ipairs(orig.segment or {}) do -- copy.segment[#copy.segment+1] = {P[1], P[2], P[3], P[4]} -- end -- copy.__id = next_frag_id; next_frag_id = next_frag_id + 1 -- return copy -- end -- -- Normalize caller-supplied arrays of points but preserve original w exactly. -- -- We ensure x,y,z are numbers (default 0 if missing) but do NOT change w. -- local function normalize_points(arr) -- local out = {} -- for _, P in ipairs(arr or {}) do -- if #P >= 3 then -- local x = P[1] -- local y = P[2] -- local z = P[3] -- local w = P[4] -- out[#out+1] = { x, y, z, w } -- end -- end -- return out -- end -- local function make_fragment_from_points(pts, orig, parent) -- local typ = (#pts==1 and "point") or (#pts==2 and "line segment") or "triangle" -- local frag = {} -- for k,v in pairs(orig or {}) do frag[k] = v end -- frag.segment = {} -- for _, P in ipairs(pts) do -- -- preserve w exactly (do not default to 1) -- local x = P[1] -- local y = P[2] -- local z = P[3] -- local w = P[4] -- frag.segment[#frag.segment+1] = { x, y, z, w } -- end -- frag.type = typ -- frag.__id = next_frag_id; next_frag_id = next_frag_id + 1 -- -- merge parent metadata into frag without a whitelist -- if parent then -- for k, v in pairs(parent) do -- if k ~= "segment" and frag[k] == nil then -- if type(v) == "table" then -- -- shallow copy to avoid reference sharing -- local cp = {} -- for i = 1, #v do cp[i] = v[i] end -- frag[k] = cp -- else -- frag[k] = v -- end -- end -- end -- end -- return frag -- end -- local function fragments_from_partition_result(result, orig, parent) -- local frags = {} -- if not result then return frags end -- for _, pts in pairs(result) do -- local eu = normalize_points(pts) -- will preserve w -- if #eu > 0 then -- frags[#frags+1] = make_fragment_from_points(eu, orig, parent) -- end -- end -- return frags -- end -- local working = {} -- for i=1,#items do -- local c = clone_fragment(items[i]) -- working[#working+1] = c -- end -- -- -- Copy originals to act as parents (never reinserted) -- local parents = {} -- for i = 1, #working do -- parents[i] = working[i] -- end -- -- Cut a single target using a single parent -- -- Returns: changed:boolean, kept:table|nil -- local function cut_once(target, parent) -- if target == parent then return false, nil end -- if not bboxes_overlap({get_bbox(target)}, {get_bbox(parent)}) then -- return false, nil -- end -- local partA, partB -- if is_triangle(target) and is_triangle(parent) then -- partA = triangle_triangle_partition(target.segment, parent.segment) -- elseif is_line_segment(target) and is_triangle(parent) then -- partA = line_segment_triangle_clip(target.segment, parent.segment) -- elseif is_triangle(target) and is_line_segment(parent) then -- -- no triangle-by-segment partitioner available, so don’t consume the triangle -- partB = nil -- elseif is_line_segment(target) and is_line_segment(parent) then -- partA = line_segment_line_segment_partition(target.segment, parent.segment) -- end -- local frags -- if partA then -- frags = fragments_from_partition_result(partA, target, parent) -- elseif partB then -- frags = fragments_from_partition_result(partB, target, parent) -- end -- if not frags or #frags == 0 then -- return false, nil -- end -- -- ✅ strict rule: if any fragment is of a different type, keep the original -- for i = 1, #frags do -- if frags[i].type ~= target.type then -- return false, nil -- end -- end -- -- deduplication -- local kept, sig_target, any_new = {}, fragment_signature(target, eps), false -- for _, f in ipairs(frags) do -- local s = fragment_signature(f, eps) -- if s ~= sig_target and not global_seen[s] then -- global_seen[s] = true -- kept[#kept+1] = f -- any_new = true -- end -- end -- if not any_new then -- return false, nil -- end -- return true, kept -- end -- local changed = true -- local iter = 0 -- while changed and iter < 50 do -- iter = iter + 1 -- changed = false -- local new_working = {} -- for _, target in ipairs(working) do -- local fragments = { target } -- local cut_history = {} -- while #fragments > 0 do -- local frag = table.remove(fragments) -- local sig = fragment_signature(frag, eps) -- cut_history[sig] = cut_history[sig] or {} -- local cut_any = false -- for _, parent in ipairs(parents) do -- if not cut_history[sig][parent] then -- local did_change, cut = cut_once(frag, parent) -- cut_history[sig][parent] = true -- if did_change and cut then -- for _, f in ipairs(cut) do -- fragments[#fragments + 1] = f -- end -- cut_any = true -- changed = true -- break -- process newly created fragments immediately -- end -- end -- end -- if not cut_any then -- new_working[#new_working + 1] = frag -- end -- end -- end -- working = new_working -- end -- working = apply_filters(working) -- for _, segment in ipairs(working) do -- local pts = {} -- -- Reciprocate each point and skip those that are out of bounds -- for _, P in ipairs(segment.segment) do -- local R = mm.reciprocate_by_homogenous({P})[1] -- -- Skip points outside the threshold range (culling logic) -- if math.abs(R[1]) > 150 or math.abs(R[2]) > 150 or R[3] > 0 then -- -- Mark segment to be skipped -- segment.skip = true -- break -- end -- -- Add reciprocated point to the list -- table.insert(pts, R) -- end -- -- Update the segment with the reciprocated points -- segment.segment = pts -- end -- -- --------------------------------------------------------------- -- -- replace items with working -- -- --------------------------------------------------------------- -- for k=#items,1,-1 do items[k]=nil end -- for _,v in ipairs(working) do items[#items+1]=v end -- -- --------------------------------------------------------------- -- -- occlusion graph + SCC topo sort -- -- --------------------------------------------------------------- -- local n=#items -- local bboxes,graph={},{} -- for i=1,n do bboxes[i]={get_bbox(items[i])}; graph[i]={} end -- for i=1,n-1 do -- for j=i+1,n do -- if bboxes_overlap(bboxes[i],bboxes[j]) then -- local r=cmp(items[i],items[j]) -- if r==true then table.insert(graph[i],j) -- elseif r==false then table.insert(graph[j],i) end -- end -- end -- end -- local index,stack,indices,lowlink,onstack,sccs=0,{}, {}, {}, {}, {} -- for i=1,n do indices[i],lowlink[i]=-1,-1 end -- local function dfs(v) -- indices[v],lowlink[v]=index,index; index=index+1 -- stack[#stack+1]=v; onstack[v]=true -- for _,w in ipairs(graph[v]) do -- if indices[w]==-1 then dfs(w); lowlink[v]=math.min(lowlink[v],lowlink[w]) -- elseif onstack[w] then lowlink[v]=math.min(lowlink[v],indices[w]) end -- end -- if lowlink[v]==indices[v] then -- local scc={} -- while true do -- local w=table.remove(stack); onstack[w]=false -- scc[#scc+1]=w -- if w==v then break end -- end -- sccs[#sccs+1]=scc -- end -- end -- for v=1,n do if indices[v]==-1 then dfs(v) end end -- local scc_index,scc_graph,indeg={}, {}, {} -- for i,comp in ipairs(sccs) do -- for _,v in ipairs(comp) do scc_index[v]=i end -- scc_graph[i],indeg[i]={},0 -- end -- for v=1,n do -- for _,w in ipairs(graph[v]) do -- local si,sj=scc_index[v],scc_index[w] -- if si~=sj then table.insert(scc_graph[si],sj); indeg[sj]=indeg[sj]+1 end -- end -- end -- local queue,sorted={},{} -- for i=1,#sccs do if indeg[i]==0 then queue[#queue+1]=i end end -- while #queue>0 do -- local i=table.remove(queue,1) -- for _,v in ipairs(sccs[i]) do sorted[#sorted+1]=items[v] end -- for _,j in ipairs(scc_graph[i]) do -- indeg[j]=indeg[j]-1 -- if indeg[j]==0 then queue[#queue+1]=j end -- end -- end -- return sorted -- end local function topo_sort_with_cycles(items, cmp) local max_depth = 16 local eps = 1e-7 -- consistent tolerance local DEBUG = false local global_seen = {} -- type checks local function is_line_segment(p) return p.type == "line segment" or p.type == "line" end local function is_triangle(p) return p.type == "triangle" end local function is_point(p) return p.type == "point" end local function nearly_equal(a, b, tol) return math.abs((a or 0) - (b or 0)) <= (tol or eps) end -- get_bbox expects Cartesian points (after divide-by-w) local function get_bbox(p) local minX, maxX = math.huge, -math.huge local minY, maxY = math.huge, -math.huge for _, P in ipairs(p.segment or {}) do local x, y = P[1] or 0, P[2] or 0 minX, maxX = math.min(minX, x), math.max(maxX, x) minY, maxY = math.min(minY, y), math.max(maxY, y) end return minX, maxX, minY, maxY end local function bboxes_overlap(b1, b2) local minX1, maxX1, minY1, maxY1 = table.unpack(b1) local minX2, maxX2, minY2, maxY2 = table.unpack(b2) return not (maxX1 < minX2 + eps or maxX2 < minX1 or maxY1 < minY2 or maxY2 < minY1) end -- clone fragment (preserve original w exactly; default nil->1 not forced here) local next_frag_id = 1 local function clone_fragment(orig) local copy = {} for k,v in pairs(orig) do if k ~= "segment" then copy[k] = v end end copy.segment = {} for _, P in ipairs(orig.segment or {}) do copy.segment[#copy.segment+1] = { P[1], P[2], P[3], P[4] } end copy.__id = next_frag_id; next_frag_id = next_frag_id + 1 return copy end -- create fragment from points, never replace w local function make_fragment_from_points(pts, orig, parent) local typ = (#pts==1 and "point") or (#pts==2 and "line segment") or "triangle" local frag = {} for k,v in pairs(orig or {}) do frag[k] = v end frag.segment = {} for _, P in ipairs(pts) do -- preserve the w in P exactly frag.segment[#frag.segment+1] = { P[1], P[2], P[3], P[4] } end frag.type = typ frag.__id = next_frag_id; next_frag_id = next_frag_id + 1 -- copy non-segment parent attributes if missing if parent then for k,v in pairs(parent) do if k ~= "segment" and frag[k] == nil then if type(v) == "table" then local cp = {} for i = 1, #v do cp[i] = v[i] end frag[k] = cp else frag[k] = v end end end end return frag end -- convert partition results to fragments, keeping the exact w of generated points local function fragments_from_partition_result(result, orig, parent) local frags = {} if not result then return frags end for _, pts in pairs(result) do -- pts already contain correct w, do not modify frags[#frags+1] = make_fragment_from_points(pts, orig, parent) end return frags end -- prepare working copy with raw Cartesian coords (x,y,z,w if present) local working = {} for i = 1, #items do working[#working+1] = clone_fragment(items[i]) end local parents = {} for i = 1, #working do parents[i] = working[i] end local function is_degenerate(f) if f.type == "triangle" then local A, B, C = f.segment[1], f.segment[2], f.segment[3] -- compute area in 2D or 3D Cartesian coords local ab = {B[1]-A[1], B[2]-A[2], (B[3] or 0)-(A[3] or 0)} local ac = {C[1]-A[1], C[2]-A[2], (C[3] or 0)-(A[3] or 0)} local area = 0.5 * math.sqrt( (ab[2]*ac[3]-ab[3]*ac[2])^2 + (ab[3]*ac[1]-ab[1]*ac[3])^2 + (ab[1]*ac[2]-ab[2]*ac[1])^2 ) return area < eps elseif f.type == "line segment" then local A, B = f.segment[1], f.segment[2] local dx, dy = B[1]-A[1], B[2]-A[2] return (dx*dx + dy*dy) < eps*eps else return false end end local function cut_once(target, parent) if target == parent then return false, nil end if not bboxes_overlap({ get_bbox(target) }, { get_bbox(parent) }) then return false, nil end local partA, partB if is_triangle(target) and is_triangle(parent) then partA = triangle_triangle_partition(target.segment, parent.segment) elseif is_line_segment(target) and is_triangle(parent) then partA = line_segment_triangle_clip(target.segment, parent.segment) elseif is_triangle(target) and is_line_segment(parent) then partB = nil elseif is_line_segment(target) and is_line_segment(parent) then partA = line_segment_line_segment_partition(target.segment, parent.segment) end local frags = partA and fragments_from_partition_result(partA, target, parent) or partB and fragments_from_partition_result(partB, target, parent) or {} if #frags == 0 then return false, nil end local kept = {} local sig_target = fragment_signature(target, eps) local any_new = false local saw_degenerate = false for _, f in ipairs(frags) do if f.type == "triangle" and #f.segment < 3 then saw_degenerate = true -- mark degenerate else local s = fragment_signature(f, eps) if s ~= sig_target and not global_seen[s] then global_seen[s] = true kept[#kept+1] = f any_new = true end end end -- If degenerate was skipped, allow the original-sized fragment if saw_degenerate and not global_seen[sig_target] then global_seen[sig_target] = true kept[#kept+1] = target any_new = true end if not any_new then return false, nil end return true, kept end -- iterative cutting (raw coords) local changed = true local iter = 0 while changed and iter < 50 do iter = iter + 1 changed = false local new_working = {} for _, target in ipairs(working) do local fragments = { target } local cut_history = {} while #fragments > 0 do local frag = table.remove(fragments) local sig = fragment_signature(frag, eps) cut_history[sig] = cut_history[sig] or {} local cut_any = false for _, parent in ipairs(parents) do if not cut_history[sig][parent] then local did_change, cut = cut_once(frag, parent) cut_history[sig][parent] = true if did_change and cut then for _, f in ipairs(cut) do fragments[#fragments+1] = f end cut_any = true changed = true break end end end if not cut_any then new_working[#new_working+1] = frag end end end working = new_working end -- SAFE wrapper: ensure apply_filters sees raw Cartesian coordinates and map results back local function safe_apply_filters(segments) local DEBUG = false local function dprintf(tag, msg) if DEBUG then texio.write_nl(("lua | [%s] %s"):format(tag, msg)) end end local function fmt_vec(v) if type(v) ~= "table" then return tostring(v) end local parts = {} for i = 1, #v do parts[#parts+1] = tostring(v[i]) end return "{" .. table.concat(parts, ", ") .. "}" end local function fmt_matrix(m) if type(m) ~= "table" then return tostring(m) end local rows = {} for r = 1, #m do rows[#rows+1] = fmt_vec(m[r]) end return "[" .. table.concat(rows, " | ") .. "]" end local new_segments = {} local kept, culled, errors = 0, 0, 0 for idx, frag in ipairs(segments) do local tag = ("frag #%d"):format(idx) -- Skip non-triangles or segments without filters if frag.type ~= "triangle" or not frag.filter then new_segments[#new_segments+1] = frag else -- homogenize vertices (w preserved) local A = {frag.segment[1]} local B = {frag.segment[2]} local C = {frag.segment[3]} -- Build environment for filter local env = { A = A, B = B, C = C } for k,v in pairs(lua_tikz3dtools_parametric.math) do env[k] = v end -- Always evaluate filter in Euclidean coordinates local chunk, load_err = load("return (" .. frag.filter .. ")", "filter-chunk", "t", env) if not chunk then dprintf("ERROR", ("%s load failed: %s"):format(tag, tostring(load_err))) new_segments[#new_segments+1] = frag errors = errors + 1 else local ok, result = pcall(chunk) if not ok then dprintf("ERROR", ("%s runtime error: %s"):format(tag, tostring(result))) new_segments[#new_segments+1] = frag errors = errors + 1 elseif result then new_segments[#new_segments+1] = frag kept = kept + 1 else culled = culled + 1 end end end end -- Return filtered segments (still Euclidean, w untouched) return new_segments end working = safe_apply_filters(working) -- NOW do the projective divide (post-filter): convert every segment point to Cartesian (x/w,y/w,z/w) for _, segment in ipairs(working) do local cart = {} for _, P in ipairs(segment.segment or {}) do local R = mm.reciprocate_by_homogenous({P}) cart[#cart+1] = R[1] end -- assign back the Cartesian coordinates segment.segment = cart end -- prune skipped segments local pruned = {} for _, s in ipairs(working) do if not s.skip and s.segment and #s.segment > 0 then pruned[#pruned+1] = s end end working = pruned -- replace items with working (now Cartesian) for k = #items, 1, -1 do items[k] = nil end for _, v in ipairs(working) do items[#items+1] = v end -- occlusion graph + SCC topo sort (bboxes computed on Cartesian coords) local n = #items local bboxes, graph = {}, {} for i = 1, n do bboxes[i] = { get_bbox(items[i]) }; graph[i] = {} end for i = 1, n - 1 do for j = i + 1, n do if bboxes_overlap(bboxes[i], bboxes[j]) then local r = cmp(items[i], items[j]) if r == true then table.insert(graph[i], j) elseif r == false then table.insert(graph[j], i) end end end end local index, stack, indices, lowlink, onstack, sccs = 0, {}, {}, {}, {}, {} for i = 1, n do indices[i], lowlink[i] = -1, -1 end local function dfs(v) indices[v], lowlink[v] = index, index; index = index + 1 stack[#stack+1] = v; onstack[v] = true for _, w in ipairs(graph[v]) do if indices[w] == -1 then dfs(w); lowlink[v] = math.min(lowlink[v], lowlink[w]) elseif onstack[w] then lowlink[v] = math.min(lowlink[v], indices[w]) end end if lowlink[v] == indices[v] then local scc = {} while true do local w = table.remove(stack); onstack[w] = false scc[#scc+1] = w if w == v then break end end sccs[#sccs+1] = scc end end for v = 1, n do if indices[v] == -1 then dfs(v) end end local scc_index, scc_graph, indeg = {}, {}, {} for i, comp in ipairs(sccs) do for _, v in ipairs(comp) do scc_index[v] = i end scc_graph[i], indeg[i] = {}, 0 end for v = 1, n do for _, w in ipairs(graph[v]) do local si, sj = scc_index[v], scc_index[w] if si ~= sj then table.insert(scc_graph[si], sj); indeg[sj] = indeg[sj] + 1 end end end local queue, sorted = {}, {} for i = 1, #sccs do if indeg[i] == 0 then queue[#queue+1] = i end end while #queue > 0 do local i = table.remove(queue, 1) for _, v in ipairs(sccs[i]) do sorted[#sorted+1] = items[v] end for _, j in ipairs(scc_graph[i]) do indeg[j] = indeg[j] - 1 if indeg[j] == 0 then queue[#queue+1] = j end end end return sorted end local function reverse_inplace(t) local n = #t for i = 1, math.floor(n/2) do t[i], t[n-i+1] = t[n-i+1], t[i] end end local function sign(number) if number > -0.0000001 then return "positive" end return "negative" end --- Occlusion sorts two points. --- @param P1 table> a point --- @param P2 table> another point --- @return boolean or nil whether P1 is in front local function point_point_occlusion_sort(P1, P2) --[[ If the points are not the same point, then calculate their depth directly. ]] if distance(P1, P2) > 0.0000001 then if distance({{P1[1][1], P1[1][2], 0, 1}}, {{P2[1][1], P2[1][2], 0, 1}}) < 0.0000001 then return P1[1][3] > P2[1][3] end end return nil end -- Gauss-Jordan solver (column-major) -- AugCols: array of n+1 columns, each column is an array of n numbers. -- Options (optional): -- opts.copy (default true) -> if true, solver works on a copy and does not mutate AugCols -- opts.eps (default 1e-12) -> pivot tolerance -- Returns: x (array 1..n) on success, or nil, reason on failure. local function gauss_jordan_cols(AugCols, opts) opts = opts or {} local copy_input = (opts.copy == nil) and true or not not opts.copy local eps = 0.0000001 -- basic validation local m = #AugCols if m == 0 then return {}, nil end local n = #AugCols[1] if m ~= n + 1 then return nil, "bad_matrix_size (need n+1 columns for n rows)" end for c = 1, m do if #AugCols[c] ~= n then return nil, "bad_column_length" end end -- clone columns if requested local cols = AugCols if copy_input then cols = {} for c = 1, m do local col = {} for r = 1, n do col[r] = AugCols[c][r] end cols[c] = col end end -- Gauss-Jordan elimination for i = 1, n do -- find pivot row (max abs value in column i for rows i..n) local pivot_row = i local maxval = math.abs(cols[i][i]) for r = i+1, n do local v = math.abs(cols[i][r]) if v > maxval then maxval = v pivot_row = r end end if maxval < eps then return nil, "singular" end -- swap rows i and pivot_row across all columns if needed if pivot_row ~= i then for c = 1, m do cols[c][i], cols[c][pivot_row] = cols[c][pivot_row], cols[c][i] end end -- normalize pivot row so pivot becomes 1 local pivot = cols[i][i] for c = 1, m do cols[c][i] = cols[c][i] / pivot end -- eliminate all other rows at column i for r = 1, n do if r ~= i then local factor = cols[i][r] if factor ~= 0 then for c = 1, m do cols[c][r] = cols[c][r] - factor * cols[c][i] end -- numerical cleanup cols[i][r] = 0 end end end end -- solution is in the last column (RHS), rows 1..n local x = {} for i = 1, n do x[i] = cols[m][i] end return x end --[[ Example usage: local aug = { {2, 1}, -- col 1 (a11, a21) {1, -1}, -- col 2 (a12, a22) {3, 0} -- RHS (b1, b2) } local sol, err = gauss_jordan_cols(aug) -- sol -> {1, 1} (if system solvable) ]] --- point_line_segment_occlusion_sort --- @param P table> the point --- @param L table> the line --- @return true|false|nil depth relationship local function point_line_segment_occlusion_sort(P, L) local line_origin = { L[1] } local line_direction_vector = vector_subtraction( { L[2] }, { L[1] } ) -- The line has affine basis {line_origin, line_direction_vector} local point_from_the_lines_origin = vector_subtraction( P, { L[1] } ) local projection = orthogonal_vector_projection( line_direction_vector, point_from_the_lines_origin ) local true_projection = vector_addition( line_origin, projection ) -- We orthogonally project the point onto the line segment. --[[ If the xy-projections of the point and true_projection are close, then proceed. Otherwise, they do not occlude one another. ]] local F = {{P[1][1], P[1][2], 0, 1}} local G = {{true_projection[1][1], true_projection[1][2], 0, 1}} if distance(F, G) < 0.0000001 then local tmp if ( sign(projection[1][1]) == sign(line_direction_vector[1][1]) and sign(projection[1][2]) == sign(line_direction_vector[1][2]) and sign(projection[1][3]) == sign(line_direction_vector[1][3]) ) then tmp = 1 else tmp = -1 end local test = tmp * length(projection) / length(line_direction_vector) if (0+eps<=test and test<=1-eps) then return point_point_occlusion_sort(P, true_projection) end end return nil end --- point versus triangle occlusion comparator --- @param P table> the point --- @param T table> the triangle --- @return boolean|nil the result of the occlusion comparison local function point_triangle_occlusion_sort(P, T) --[[ First we project the point and the triangle onto the viewing plane (xy). We do this to determine if they occluse using a cross product test. If they do, then we vertically project the point onto the triangle and compare by that. ]] local point_projection = { {P[1][1], P[1][2], 0, 1} } local T1_projection = { {T[1][1], T[1][2], 0, 1} } local T2_projection = { {T[2][1], T[2][2], 0, 1} } local T3_projection = { {T[3][1], T[3][2], 0, 1} } local dist1 = distance(P, {T[1]}) if dist1 > 0.0000001 == false then return nil end local dist2 = distance(P, {T[2]}) if dist2 > 0.0000001 == false then return nil end local dist3 = distance(P, {T[3]}) if dist3 > 0.0000001 == false then return nil end --[[ For reasons of space, we will invoke smaller names for our points. ]] local P1 = point_projection local T1 = T1_projection local T2 = T2_projection local T3 = T3_projection local T1T2 = vector_subtraction(T2, T1) local T1P1 = vector_subtraction(P1, T1) local T1T2xT1P1 = cross_product(T1T2, T1P1) local sign_T1T2xT1P1 = sign(T1T2xT1P1[1][3]) local T2T3 = vector_subtraction(T3, T2) local T2P1 = vector_subtraction(P1, T2) local T2T3xT2P1 = cross_product(T2T3, T2P1) local sign_T2T3xT2P1 = sign(T2T3xT2P1[1][3]) if not (sign_T1T2xT1P1 == sign_T2T3xT2P1) then return nil end local T3T1 = vector_subtraction(T1, T3) local T3P1 = vector_subtraction(P1, T3) local T3T1xT3P1 = cross_product(T3T1, T3P1) local sign_T3T1xT3P1 = sign(T3T1xT3P1[1][3]) if sign_T2T3xT2P1 == sign_T3T1xT3P1 then local o = {T[1]} local u = vector_subtraction({T[2]}, {T[1]}) local v = vector_subtraction({T[3]}, {T[1]}) local augmented_matrix = { {u[1][1], u[1][2]} -- t ,{v[1][1], v[1][2]} -- s ,{P[1][1]-o[1][1], P[1][2]-o[1][2]} -- t+s } local sol = gauss_jordan_cols(augmented_matrix) local t, s if sol then t = sol[1] s = sol[2] end if t == nil or s == nil then return nil end if 0+eps<=t and t<=1-eps and 0+eps<=s and s<=1-eps then local a = vector_addition(o, scalar_multiplication(t, u)) local b = vector_addition(a, scalar_multiplication(s, v)) return point_point_occlusion_sort(P, b) end end return nil end --- line segment versus line segment occlusion sort --- @param L1 table> the first line segment --- @param L2 table> the second line segment --- @return boolean|nil the result of the occlusion comparison local function line_segment_line_segment_occlusion_sort(L1, L2) local L1A, L1B = {{L1[1][1], L1[1][2], 0, 1}}, {{L1[2][1], L1[2][2], 0, 1}} local L2A, L2B = {{L2[1][1], L2[1][2], 0, 1}}, {{L2[2][1], L2[2][2], 0, 1}} local L1_dir = vector_subtraction(L1B, L1A) local L2_dir = vector_subtraction(L2B, L2A) if length(cross_product(L1_dir,L2_dir)) > 0 then --[=[ L1A + (t)*L1_dir = L2A + (s)*L2_dir -->... ...--> [(t)*L1_dir] - [(s)*L2_dir] = [L2A - L1A] ]=] local augmented_matrix = { {L1_dir[1][1], L1_dir[1][2]}, --> t {-L2_dir[1][1], -L2_dir[1][2]}, --> s {L2A[1][1]-L1A[1][1], L2A[1][2]-L1A[1][2]} --> t+s } local sol = gauss_jordan_cols(augmented_matrix) local t, s if sol then t = sol[1] s = sol[2] end if t == nil or s == nil then return nil end if (0-eps <= t and t<=1+eps and 0-eps <= s and s <= 1+eps) then local L1_inverse = vector_addition({L1[1]}, scalar_multiplication(t, vector_subtraction({L1[2]}, {L1[1]}))) local L2_inverse = vector_addition({L2[1]}, scalar_multiplication(s, vector_subtraction({L2[2]}, {L2[1]}))) return point_point_occlusion_sort(L1_inverse, L2_inverse) end else --return point_point_occlusion_sort({L1[1]}, {L2[1]}) local M1 = point_line_segment_occlusion_sort({L1[1]}, L2) local M2 = point_line_segment_occlusion_sort({L1[2]}, L2) local M3 = point_line_segment_occlusion_sort({L2[1]}, L1) if M3 ~= nil then M3 = not M3 end local M4 = point_line_segment_occlusion_sort({L2[2]}, L1) if M4 ~= nil then M4 = not M4 end if (M1 == true or M2 == true or M3 == true or M4 == true) then return true end if (M1 == false or M2 == false or M3 == false or M4 == false) then return false end end return nil end --- line segment versus triangle test --- @param L table> the line segment --- @param LT table> the triangle --- @return boolean|nil the result of the occlusion comparison local function line_segment_triangle_occlusion_sort(L, T) local A = point_triangle_occlusion_sort({L[1]}, T) if A ~= nil then return A end local B = point_triangle_occlusion_sort({L[2]}, T) if B ~= nil then return B end local T1, T2, T3 = {T[1], T[2]}, {T[2], T[3]}, {T[3], T[1]} local C = line_segment_line_segment_occlusion_sort(L, T1) if C ~= nil then return C end local D = line_segment_line_segment_occlusion_sort(L, T2) if D ~= nil then return D end local E = line_segment_line_segment_occlusion_sort(L, T3) if E ~= nil then return E end return nil end --- triangle versus triangle occlusion sort --- @param L1 table> the first triangle --- @param L2 table> the second triangle --- @return boolean|nil the result of the occlusion comparison local function triangle_triangle_occlusion_sort(T1, T2) local T1AB, T1BC, T1CA = {T1[1],T1[2]}, {T1[2],T1[3]}, {T1[3],T1[1]} local A = line_segment_triangle_occlusion_sort(T1AB, T2) if A ~= nil then return A end local B = line_segment_triangle_occlusion_sort(T1BC, T2) if B ~= nil then return B end local C = line_segment_triangle_occlusion_sort(T1CA, T2) if C ~= nil then return C end local D = point_triangle_occlusion_sort({T1[1]}, T2) if D ~= nil then return D end local E = point_triangle_occlusion_sort({T1[2]}, T2) if E ~= nil then return E end local F = point_triangle_occlusion_sort({T1[3]}, T2) if F ~= nil then return F end local G = point_triangle_occlusion_sort({T2[1]}, T1) if G ~= nil then return not G end local H = point_triangle_occlusion_sort({T2[2]}, T1) if H ~= nil then return not H end local I = point_triangle_occlusion_sort({T2[3]}, T1) if I ~= nil then return not I end return nil end --- Occlusion sorts an arbitrary set of segments. --- @param S1 table> a segment --- @param S2 table> another segment --- @return boolean or nil local function occlusion_sort_segments(S1, S2) if (S1.type == "point" and S2.type == "point") then return point_point_occlusion_sort(S1.segment, S2.segment) elseif (S1.type == "point" and S2.type == "line segment") then return point_line_segment_occlusion_sort(S1.segment, S2.segment) elseif (S1.type == "point" and S2.type == "triangle") then return point_triangle_occlusion_sort(S1.segment, S2.segment) elseif (S1.type == "line segment" and S2.type == "point") then local ans = point_line_segment_occlusion_sort(S2.segment, S1.segment) if ans == nil then return nil end return not ans elseif (S1.type == "line segment" and S2.type == "line segment") then return line_segment_line_segment_occlusion_sort(S1.segment, S2.segment) elseif (S1.type == "line segment" and S2.type == "triangle") then return line_segment_triangle_occlusion_sort(S1.segment, S2.segment) elseif (S1.type == "triangle" and S2.type == "point") then local ans = point_triangle_occlusion_sort(S2.segment, S1.segment) if ans == nil then return nil end return not ans elseif (S1.type == "triangle" and S2.type == "line segment") then local ans = line_segment_triangle_occlusion_sort(S2.segment, S1.segment) if ans == nil then return nil end return not ans elseif (S1.type == "triangle" and S2.type == "triangle") then return triangle_triangle_occlusion_sort(S1.segment, S2.segment) end end local function display_segments() filtered_segments = topo_sort_with_cycles(segments, occlusion_sort_segments, 16) -- Reverse the order after sorting reverse_inplace(filtered_segments) local function prune_far_segments(filtered_segments, max_dist) max_dist = max_dist or 150 local pruned = {} for _, seg in ipairs(filtered_segments) do local keep = true for _, P in ipairs(seg.segment or {}) do local d = math.sqrt(P[1]^2 + P[2]^2 + P[3]^2) if d > max_dist then keep = false break end end if keep then pruned[#pruned+1] = seg end end return pruned end -- Usage: filtered_segments = prune_far_segments(filtered_segments, 150) -- Iterate through the sorted segments for rendering for _, segment in ipairs(filtered_segments) do -- Handle point segments if segment.type == "point" and not segment.name then local P = segment.segment[1] tex.sprint(string.format( "\\path[%s] (%f,%f) circle[radius = 0.06];", segment.filloptions, P[1], P[2] )) end if segment.type == "point" and segment.name then local P = segment.segment[1] tex.sprint(string.format( "\\node at (%f,%f) {%s};", P[1], P[2] ,segment.name )) end -- Handle line segments if segment.type == "line segment" then local S, E = segment.segment[1], segment.segment[2] tex.sprint(string.format( "\\path[%s] (%f,%f) -- (%f,%f);", segment.drawoptions, S[1], S[2], E[1], E[2] )) end -- Handle triangle segments if segment.type == "triangle" then local P, Q, R = segment.segment[1], segment.segment[2], segment.segment[3] tex.sprint(string.format( "\\path[%s] (%f,%f) -- (%f,%f) -- (%f,%f) -- cycle;", segment.filloptions, P[1], P[2], Q[1], Q[2], R[1], R[2] )) end end -- Clear the segments array after processing segments = {} end register_tex_cmd("displaysegments", function() display_segments() end, { }) local function make_object(name, tbl) local obj = {} obj[name] = tbl return obj end local function set_matrix(hash) local transformation = single_string_expression(hash.transformation) local name = hash.name local tbl = make_object(name, transformation) for k, v in pairs(tbl) do lua_tikz3dtools_parametric.math[k] = v end end register_tex_cmd( "setobject", function() set_matrix{ name = token.get_macro("luatikztdtools@p@m@name"), transformation = token.get_macro("luatikztdtools@p@m@transformation"), } end, { } )