--- lua-tikz3dtools-matrix.lua --- Matrix class for the lua-tikz3dtools package. local Vector -- set via Matrix._set_Vector() local Matrix = {} Matrix.__index = Matrix --- Create a new Matrix object (validated). --- @param matrix table The matrix data (row-major) --- @return Matrix function Matrix:new(matrix) assert(type(matrix) == "table", "A Matrix object must be a table of rows.") local l = #matrix local p = #matrix[1] for i = 1, l do assert(type(matrix[i]) == "table", "A Matrix row must be a table.") local m = #matrix[i] assert(m == p, "All Matrix rows must have the same number of columns.") for j = 1, m do local g = getmetatable(matrix[i][j]) == Vector assert( type(matrix[i][j]) == "number" or g, "A Matrix can only contain numbers or Vectors" ) if g and i == 1 then for k = 2, l do assert(#matrix[k][j] == #matrix[1][j], "All Vectors in a Matrix column must have the same size.") end end end end return setmetatable(matrix, self) end --- Unchecked constructor — skips validation. Use for internal calls only. --- @param matrix table --- @return Matrix function Matrix:_new(matrix) return setmetatable(matrix, self) end --- Print a Matrix object --- @return string function Matrix:__tostring() local s = "Matrix{\n" local l = #self for i = 1, l do s = s .. "\t{" local m = #self[i] for j = 1, m do if type(self[i][j]) == "number" then s = s .. string.format("%.12f", self[i][j]) else s = s .. tostring(self[i][j]) end if j < m then s = s .. "," end end s = s .. "}" if i < l then s = s .. ",\n" end end s = s .. "\n}" return s end --- swap two columns of the Matrix --- @param c1 number The first column --- @param c2 number The second column function Matrix:swap_columns(c1, c2) local l = #self for i = 1, l do local t = self[i][c1] self[i][c1] = self[i][c2] self[i][c2] = t end end --- add a scalar multiple of one column to another column --- @param src_col number The source column --- @param scalar number The scalar multiple --- @param dest_col number The destination column function Matrix:add_scalar_multiple_of_column(src_col, scalar, dest_col) local l = #self for i = 1, l do self[i][dest_col] = self[i][dest_col] + scalar * self[i][src_col] end end --- scale a column by a scalar --- @param col number The column --- @param scalar number The scalar function Matrix:scale_column(col, scalar) local l = #self for i = 1, l do self[i][col] = self[i][col] * scalar end end --- Deep copy of Matrix object --- @return Matrix A deep copy of self function Matrix:deep_copy() local numrows = #self local numcols = #self[1] local deep_copy = {} for i = 1, numrows do deep_copy[i] = {} for j = 1, numcols do if getmetatable(self[i][j]) == Vector then deep_copy[i][j] = Vector:_new{table.unpack(self[i][j])} else deep_copy[i][j] = self[i][j] end end end return Matrix:_new(deep_copy) end --- Convert to plain Lua table (no metatables) --- @return table function Matrix:to_table() local numrows = #self local numcols = #self[1] local t = {} for i = 1, numrows do t[i] = {} for j = 1, numcols do if getmetatable(self[i][j]) == Vector then t[i][j] = self[i][j]:to_table() else t[i][j] = self[i][j] end end end return t end --- Column reduction of the Matrix to RREF form --- @return Vector|nil The solution vector (last row of RREF) function Matrix:column_reduction() local A = self:deep_copy() local numrows = #A local numcols = #A[1] local eps = 1e-12 local pivot_row = 1 for col = 1, numcols do if pivot_row > numrows then break end local pr = nil for r = pivot_row, numrows do if math.abs(A[r][col]) > eps then pr = r break end end if not pr then goto continue end if col ~= pivot_row then A:swap_columns(col, pivot_row) col = pivot_row end local pivot = A[pivot_row][col] A:scale_column(col, 1 / pivot) for c = 1, numcols do if c ~= col then local factor = -A[pivot_row][c] if math.abs(factor) > eps then A:add_scalar_multiple_of_column(col, factor, c) end end end pivot_row = pivot_row + 1 ::continue:: end local sol = Vector:_new(A[#A]) for _, v in ipairs(sol) do if v ~= v then return nil end end return sol end --- Transpose of Matrix object --- @return Matrix The transposed matrix function Matrix:transpose() local numrows = #self local numcols = #self[1] local transposed = {} for j = 1, numcols do transposed[j] = {} for i = 1, numrows do transposed[j][i] = self[i][j] end end return Matrix:_new(transposed) end --- Inverse of the Matrix (row-vector convention, column GJ) --- @return Matrix|nil The inverse matrix, or nil if not invertible function Matrix:inverse() local n = #self assert(n == #self[1], "Matrix must be square.") local eps = 1e-12 local A = Matrix:_new(self:to_table()) local L_table = {} for i = 1, n do L_table[i] = {} for j = 1, n do L_table[i][j] = (i == j) and 1 or 0 end end local L = Matrix:_new(L_table) for pivot = 1, n do local pivot_col = nil local pivot_row = nil for c = pivot, n do for r = pivot, n do if math.abs(A[r][c]) > eps then pivot_col = c pivot_row = r break end end if pivot_col then break end end if not pivot_col then return nil end if pivot_col ~= pivot then A:swap_columns(pivot_col, pivot) L:swap_columns(pivot_col, pivot) end local p = A[pivot][pivot] A:scale_column(pivot, 1 / p) L:scale_column(pivot, 1 / p) for c = 1, n do if c ~= pivot then local k = -A[pivot][c] if math.abs(k) > eps then A:add_scalar_multiple_of_column(pivot, k, c) L:add_scalar_multiple_of_column(pivot, k, c) end end end end return L end --- Convert to a basis from a simplex --- @return Matrix The basis matrix function Matrix:hto_basis() local numrows = #self local numcols = #self[1] local deep_copy = self:deep_copy() for i = 1, numrows do if i ~= 1 then for j = 1, numcols - 1 do deep_copy[i][j] = deep_copy[i][j] - deep_copy[1][j] end end end return deep_copy end --- Convert to a simplex from a basis --- @return Matrix The simplex matrix function Matrix:hto_simplex() local numrows = #self local numcols = #self[1] local deep_copy = self:deep_copy() for i = 1, numrows do if i ~= 1 then for j = 1, numcols - 1 do deep_copy[i][j] = deep_copy[i][j] + deep_copy[1][j] end end end return deep_copy end --- Compute the minor of the matrix by removing row ii and column jj --- @param ii number The row to remove --- @param jj number The column to remove --- @return number|Vector The minor determinant function Matrix:minor(ii, jj) local numrows = #self local M = {} local row_idx = 0 for i = 1, numrows do if i ~= ii then row_idx = row_idx + 1 M[row_idx] = {} for j = 1, #self[i] do if j ~= jj then table.insert(M[row_idx], self[i][j]) end end end end return Matrix:_new(M):det("row", 1) end --- Compute the determinant of the matrix by cofactor expansion along a row or column --- @param switch string The row/column switch --- @param pos number The position of the row or column --- @return number|Vector The determinant function Matrix:det(switch, pos) local numrows = #self local numcols = #self[1] assert(numrows == numcols, "You tried to take the determinant of a non-square matrix.") if numrows == 2 then local A_mt = getmetatable(self[1][1]) local B_mt = getmetatable(self[2][1]) if A_mt == Vector and B_mt == Vector then -- det = A * d - B * c (column 1 has vectors) return self[1][1]:scale(self[2][2]):sub(self[2][1]:scale(self[1][2])) end A_mt = getmetatable(self[1][2]) B_mt = getmetatable(self[2][2]) if A_mt == Vector and B_mt == Vector then -- det = a * D - b * C (column 2 has vectors) return self[1][2]:scale(self[2][1]):sub(self[2][2]:scale(self[1][1])) end return self[1][1] * self[2][2] - self[1][2] * self[2][1] end if switch == "row" then local i = pos local det = 0 for j = 1, numcols do local sign = (-1)^(i + j) local minor = self:minor(i, j) local s = self[i][j] det = det + s * minor * sign end return det elseif switch == "column" then local j = pos local det if getmetatable(self[1][j]) == Vector then local t = {} for i = 1, numcols + 1 do t[i] = 0 end det = Vector:_new(t) else det = 0 end for i = 1, numrows do local sign = (-1)^(i + j) local minor = self:minor(i, j) local s = self[i][j] if getmetatable(s) == Vector then det = det:add(s:scale(minor * sign)) else det = det + s * minor * sign end end return det end end --- Homogeneous reciprocation of Matrix objects --- @return Matrix The homogeneous reciprocation of self function Matrix:reciprocate_by_homogeneous() local result = {} for i = 1, #self do result[i] = {} local w = self[i][#self[i]] for j = 1, #self[i] - 1 do result[i][j] = self[i][j] / w end result[i][#self[i]] = 1 end return Matrix:_new(result) end --- Multiply two Matrix objects --- @param other Matrix|Vector The RHS --- @return Matrix|Vector The product of self and other --- @param reciprocate boolean|nil If true, apply homogeneous reciprocation for non-4x4 results (default true for non-flag) function Matrix:multiply(other) local flag if getmetatable(other) == Vector then other = Matrix:_new{other:to_table()} flag = true end local Arows = #self local Acols = #self[1] local Bcols = #other[1] local product = {} for row = 1, Arows do product[row] = {} for col = 1, Bcols do product[row][col] = 0 for k = 1, Acols do product[row][col] = product[row][col] + self[row][k] * other[k][col] end end end if flag then return Vector:_new(product[1]) else return Matrix:_new(product) end end --- Get 3D bounding box of Matrix points --- @return table A table with 'min' and 'max' Vector objects function Matrix:get_bbox3() local min_x, min_y, min_z = math.huge, math.huge, math.huge local max_x, max_y, max_z = -math.huge, -math.huge, -math.huge for _, row in ipairs(self) do if row[1] < min_x then min_x = row[1] end if row[2] < min_y then min_y = row[2] end if row[3] < min_z then min_z = row[3] end if row[1] > max_x then max_x = row[1] end if row[2] > max_y then max_y = row[2] end if row[3] > max_z then max_z = row[3] end end return { min = Vector:_new{min_x, min_y, min_z, 1}, max = Vector:_new{max_x, max_y, max_z, 1} } end --- Get 2D bounding box of Matrix points --- @return table A table with 'min' and 'max' Vector objects function Matrix:get_bbox2() local min_x, min_y = math.huge, math.huge local max_x, max_y = -math.huge, -math.huge for _, row in ipairs(self) do if row[1] < min_x then min_x = row[1] end if row[2] < min_y then min_y = row[2] end if row[1] > max_x then max_x = row[1] end if row[2] > max_y then max_y = row[2] end end return { min = Vector:_new{min_x, min_y, 0, 1}, max = Vector:_new{max_x, max_y, 0, 1} } end --- Check if two 3D bounding boxes overlap --- @param other Matrix The other Matrix --- @return boolean True if they overlap, false otherwise function Matrix:bboxes_overlap3(other) local a_bbox = self:get_bbox3() local b_bbox = other:get_bbox3() return not ( a_bbox.max[1] < b_bbox.min[1] or a_bbox.min[1] > b_bbox.max[1] or a_bbox.max[2] < b_bbox.min[2] or a_bbox.min[2] > b_bbox.max[2] or a_bbox.max[3] < b_bbox.min[3] or a_bbox.min[3] > b_bbox.max[3] ) end --- Check if two 2D bounding boxes overlap (using pre-computed bboxes when available) --- @param other Matrix The other Matrix --- @param a_bbox table|nil Pre-computed bbox for self --- @param b_bbox table|nil Pre-computed bbox for other --- @return boolean True if they overlap, false otherwise function Matrix:bboxes_overlap2(other, a_bbox, b_bbox) a_bbox = a_bbox or self:get_bbox2() b_bbox = b_bbox or other:get_bbox2() return not ( a_bbox.max[1] < b_bbox.min[1] or a_bbox.min[1] > b_bbox.max[1] or a_bbox.max[2] < b_bbox.min[2] or a_bbox.min[2] > b_bbox.max[2] ) end --- Compute the homogeneous centroid of the Matrix points --- @return Vector The homogeneous centroid function Matrix:hcentroid() local ns = #self local dim = #self[1] local centroid = Vector:_new(self[1]) for i = 2, ns do centroid = centroid:hadd(Vector:_new(self[i])) end centroid = centroid:hscale(1 / ns) return centroid end --- Sort points in Matrix by angle around their homogeneous centroid --- @return table The sorted points (as plain table, not Matrix) function Matrix:hcentroid_sort() local num = #self local I = self:deep_copy() local centroid = I:hcentroid() local P = Vector:_new(I[1]) local u = Vector:_new(I[2]):hsub(P):hnormalize() local v = Vector:_new(I[3]):hsub(P) local normal = u:hhypercross(v):hnormalize() v = normal:hhypercross(u):hnormalize() local angles = {} for i, p in ipairs(I) do local rel = Vector:_new(p):hsub(centroid) local x = rel:hinner(u) local y = rel:hinner(v) local angle = math.atan2(y, x) angles[i] = {angle = angle, index = i} end table.sort(angles, function(a, b) return a.angle < b.angle end) local sorted = {} for i, a in ipairs(angles) do sorted[i] = I[a.index] end return sorted end --- Check if all elements in the Matrix are numeric --- @return boolean True if all elements are numeric, false otherwise function Matrix:is_numeric() for _, row in ipairs(self) do for _, val in ipairs(row) do if type(val) ~= "number" then return false end end end return true end --- 3D y axis rotation matrix --- @param theta number The rotation angle in radians --- @return Matrix The rotation matrix function Matrix.yrotation3(theta) local c = math.cos(theta) local s = math.sin(theta) return Matrix:_new{ { c, 0, -s, 0}, { 0, 1, 0, 0}, { s, 0, c, 0}, { 0, 0, 0, 1} } end --- 3D translation matrix --- @param dx number Translation along the x-axis --- @param dy number Translation along the y-axis --- @param dz number Translation along the z-axis --- @return Matrix The translation matrix function Matrix.translate3(dx, dy, dz) return Matrix:_new{ {1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {dx, dy, dz, 1} } end --- 3D scaling matrix --- @param sx number Scaling factor along the x-axis --- @param sy number Scaling factor along the y-axis --- @param sz number Scaling factor along the z-axis --- @return Matrix The scaling matrix function Matrix.scale3(sx, sy, sz) return Matrix:_new{ {sx, 0, 0, 0}, {0, sy, 0, 0}, {0, 0, sz, 0}, {0, 0, 0, 1} } end --- 3D x axis rotation matrix --- @param theta number The rotation angle in radians --- @return Matrix The rotation matrix function Matrix.xrotation3(theta) local c = math.cos(theta) local s = math.sin(theta) return Matrix:_new{ {1, 0, 0, 0}, {0, c, s, 0}, {0, -s, c, 0}, {0, 0, 0, 1} } end --- 3D z axis rotation matrix --- @param theta number The rotation angle in radians --- @return Matrix The rotation matrix function Matrix.zrotation3(theta) local c = math.cos(theta) local s = math.sin(theta) return Matrix:_new{ { c, s, 0, 0}, {-s, c, 0, 0}, { 0, 0, 1, 0}, { 0, 0, 0, 1} } end --- 3D ZYZ Euler rotation matrix --- @param alpha number The first rotation angle in radians --- @param beta number The second rotation angle in radians --- @param gamma number The third rotation angle in radians --- @return Matrix The rotation matrix function Matrix.zyzrotation3(alpha, beta, gamma) return Matrix.zrotation3(gamma) :multiply(Matrix.yrotation3(beta)) :multiply(Matrix.zrotation3(alpha)) end --- 3D identity matrix --- @return Matrix The identity matrix function Matrix.identity3() return Matrix:_new{ {1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {0, 0, 0, 1} } end --- Set the Vector class reference (breaks circular dependency). --- @param V table The Vector class function Matrix._set_Vector(V) Vector = V end return Matrix