----------------------------------------------------------------------- -- @module luahyperbolic-tilings -- Pure Lua hyperbolic geometry -- -- License: -- Public Domain / CC0 1.0 Universal -- 2026 Damien Mégy -- This software is released into the public domain. -- You may use, modify, and distribute it freely, without restriction. -- -- SPDX-License-Identifier: CC0-1.0 -- ----------------------------------------------------------------------- -- ============ BEGIN MODULE "LUAGYPERBOLIC-TILINGS" ============ local complex = require("complex") local core = require("luahyperbolic-core") local tikz = require("luahyperbolic-tikz") local m = {} m.module = "luahyperbolic-tilings" -- for quantization (geodesic propagation) m.QUANTIZATION_SCALE = 1e9 local PI = 3.1415926535898 local min = math.min local max = math.max local sin = math.sin local cos = math.cos local atan2 = math.atan2 local exp = math.exp local log = math.log local sqrt = math.sqrt local abs = math.abs local sinh = math.sinh local cosh = math.cosh local tanh = math.tanh local atanh = function(x) return 0.5 * log((1 + x) / (1 - x)) end local acosh = function(x) return log(x + sqrt(x*x - 1)) end local asinh = function(x) return log(x + sqrt(x*x + 1)) end local function _quantize(x) return math.floor(x * m.QUANTIZATION_SCALE + 0.5) end local function _quantize_normalize_pair(a, b) local ar = _quantize(a.re) local ai = _quantize(a.im) local br = _quantize(b.re) local bi = _quantize(b.im) -- lexicographic sort on integers if br < ar or (br == ar and bi < ai) then ar, br = br, ar ai, bi = bi, ai end return ar .. ":" .. ai .. ":" .. br .. ":" .. bi end -- ================== TILINGS ============== -- TODO : use better algorithm... use the group and work with words function m.propagateGeodesics(geodesics, depth, MAX_ENDPOINT_DISTANCE) MAX_ENDPOINT_DISTANCE = MAX_ENDPOINT_DISTANCE or 0.01 core._assert(#geodesics > 1, "must have at least 2 geodesics") local reflections = {} for i, side in ipairs(geodesics) do reflections[i] = core.reflection(side[1], side[2]) end local seen = {} local frontier = {} for _, g in ipairs(geodesics) do local key = _quantize_normalize_pair(g[1], g[2]) if not seen[key] then seen[key] = true frontier[#frontier + 1] = { p1 = g[1], p2 = g[2], last = nil -- no reflection yet } end end for _ = 1, depth do local new_frontier = {} for _, g in ipairs(frontier) do for i, refl in ipairs(reflections) do -- avoid immediate inverse reflection if g.last ~= i then local p1_new = refl(g.p1) local p2_new = refl(g.p2) if complex.abs(p1_new - p2_new) > MAX_ENDPOINT_DISTANCE then local key = _quantize_normalize_pair(p1_new, p2_new) if not seen[key] then seen[key] = true new_frontier[#new_frontier + 1] = { p1 = p1_new, p2 = p2_new, last = i } end end end end end for _, g in ipairs(new_frontier) do geodesics[#geodesics + 1] = { g.p1, g.p2 } end frontier = new_frontier end return geodesics end function m.fillTilingFromTriangle(A, B, C, depth, options) -- fills with "even odd rule" options = options or "black" local geodesics = { {core.endpoints(A,B)}, {core.endpoints(B,C)}, {core.endpoints(C,A)} } geodesics = m.propagateGeodesics(geodesics, depth) local shapes = {} for _,pair in ipairs(geodesics) do table.insert(shapes,tikz.tikz_shape_closed_line(pair[1], pair[2])) end local shapes_string = table.concat(shapes, " ") tikz.tikzPrintf( "\\fill[even odd rule, %s] (0,0) circle (1) %s ;", options, shapes_string ) end function m.drawTilingFromTriangle(A, B, C, depth, options) options = options or tikz.GEODESIC_STYLE local geodesics = { {core.endpoints(A,B)}, {core.endpoints(B,C)}, {core.endpoints(C,A)} } geodesics = m.propagateGeodesics(geodesics, depth) local shapes = {} for _,pair in ipairs(geodesics) do table.insert(shapes,tikz.tikz_shape_segment(pair[1], pair[2])) end local shapes_string = table.concat(shapes, " ") tikz.tikzPrintf( "\\draw[%s] (0,0) circle (1) %s ;", options, shapes_string ) end function m.drawTilingFromAngles(alpha, beta, gamma, depth, options) local A, B, C = core.triangleWithAngles(alpha, beta, gamma) m.drawTilingFromTriangle(A, B, C, depth, options) end function m.fillTilingFromAngles(alpha, beta, gamma, depth, options) local A, B, C = core.triangleWithAngles(alpha, beta, gamma) m.fillTilingFromTriangle(A, B, C, depth, options) end -- ============ END MODULE "TILINGS" ============ return m