-- lua-tikz3dtools-matrix-math.lua local mm = {} mm.tau = 2*math.pi local cos, sin = math.cos, math.sin --- matrix multiplication --- --- @param A table> left matrix --- @param B table> right matrix --- @return table> the 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.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,1}} 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 (u[1][1]~=0 and u[1][2]==0 and u[1][3]==0) 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.stereographic_projection(point) local x = point[1][1] local y = point[1][2] local z = point[1][3] return {{x / (1 - z), y / (1 - z), 0, 1}} end function mm.clip_triangle_against_line(triangle, line) -- triangle: array of 3 points {{x,y,z}, {x,y,z}, {x,y,z}} -- line: two points defining a 3D line {{x,y,z}, {x,y,z}} local function point_on_side(p, line) -- Determine signed distance of point p to the infinite line -- using vector cross product magnitude with line direction vector local function vector_sub(a,b) return {a[1]-b[1], a[2]-b[2], a[3]-b[3]} end local function cross(u,v) return { u[2]*v[3] - u[3]*v[2], u[3]*v[1] - u[1]*v[3], u[1]*v[2] - u[2]*v[1], } end local function dot(u,v) return u[1]*v[1] + u[2]*v[2] + u[3]*v[3] end local function norm(v) return math.sqrt(dot(v,v)) end local A = line[1] local B = line[2] local AB = vector_sub(B,A) local AP = vector_sub(p,A) local cross_vec = cross(AB, AP) local dist = norm(cross_vec) / norm(AB) -- Also need sign: find projection of AP onto AB perpendicular to AB -- sign = dot(cross(AB, AP), some reference vector). -- Here we pick a consistent reference: AB cross with vector perpendicular to AB and in plane of triangle -- But simpler: pick sign by dot product with cross(AB, AP) and cross(AB, normal) -- For simplicity here, let's get the sign by dot product of vector from point projected to line -- We'll just use a rough approach: calculate vector perpendicular to AB in triangle plane -- Triangle normal local normal = cross( vector_sub(triangle[2], triangle[1]), vector_sub(triangle[3], triangle[1]) ) -- Sign based on dot product of cross_vec and normal local sign_val = dot(cross_vec, normal) if sign_val >= 0 then return dist else return -dist end end -- Compute distances of each vertex to the line local d = {} for i=1,3 do d[i] = point_on_side(triangle[i], line) end -- Classify vertices by sign local positive = {} local negative = {} for i=1,3 do if d[i] >= 0 then table.insert(positive, i) else table.insert(negative, i) end end -- If all on one side, return original triangle if #positive == 0 or #negative == 0 then return {triangle} end -- Helper to interpolate between two points by ratio t local function interp(p1, p2, t) return { p1[1] + t*(p2[1] - p1[1]), p1[2] + t*(p2[2] - p1[2]), p1[3] + t*(p2[3] - p1[3]), } end -- Find intersection points on edges crossing the line (zero crossing of distance) local function intersect(i1, i2) local p1, p2 = triangle[i1], triangle[i2] local dist1, dist2 = d[i1], d[i2] local t = dist1 / (dist1 - dist2) return interp(p1, p2, t) end local new_triangles = {} if #positive == 2 and #negative == 1 then -- Two positive, one negative -- Split into two triangles local i_neg = negative[1] local i_pos1 = positive[1] local i_pos2 = positive[2] local p_int1 = intersect(i_neg, i_pos1) local p_int2 = intersect(i_neg, i_pos2) -- Triangle 1: positive1, positive2, p_int1 table.insert(new_triangles, {triangle[i_pos1], triangle[i_pos2], p_int1}) -- Triangle 2: positive2, p_int1, p_int2 table.insert(new_triangles, {triangle[i_pos2], p_int1, p_int2}) elseif #negative == 2 and #positive == 1 then -- Two negative, one positive -- Split into one smaller triangle local i_pos = positive[1] local i_neg1 = negative[1] local i_neg2 = negative[2] local p_int1 = intersect(i_pos, i_neg1) local p_int2 = intersect(i_pos, i_neg2) -- Triangle: positive, p_int1, p_int2 table.insert(new_triangles, {triangle[i_pos], p_int1, p_int2}) else -- Should not happen for triangles but just in case return {triangle} end return new_triangles end return mm