Cellular Automata in Hyperbolic Space

For discussion of other cellular automata.
User avatar
CARuler
Posts: 1351
Joined: July 30th, 2024, 5:38 pm
Location: A rule-verse in floor rule-verse of the CGOL skyscraper

Re: Cellular Automata in Hyperbolic Space

Post by CARuler »

R2INT wrote: October 27th, 2025, 8:10 pm In hyperbolic space, it is possible for a moving replicator to replicate endlessly without interfering with itself.
yes, in fact there are now 2 examples of this behaviour
example1:

Code: Select all

B2aho/S1ho2a X X0a X1a
example2:

Code: Select all

B2ampx3m/S0a3c X3a X0 X1a
===============================================================================
edit:
i don't think all of these work as symmetries

Code: Select all

permute (OT)
rotate37 (minimum symmetry)
rotate37reflectx (reflect on hexagons only) might not work (MNW)
rotate37reflectp (reflect on heptagons only) MNW
rotate37reflect (isotropic)
rotate67 (psuedo symmetry)
rotate67reflectx MNW
rotate67reflectp MNW
rotate67reflect
likes interesting rules
vist my rules here
also likes weird growth patterns in CA
hyperbolic CA!!!
ADHD user
mostly inactive
Rhombicubocta
Posts: 81
Joined: May 27th, 2026, 9:44 am

Re: Cellular Automata in Hyperbolic Space

Post by Rhombicubocta »

tile5-4.png
tile5-4.png (27.15 KiB) Viewed 105 times
tile5-4.png
tile5-4.png (27.15 KiB) Viewed 105 times
Here is a proposal for the naming of directions in hyperbolic grids.

Let M be the number of sides of the polygons tiling the hyperbolic plane.
The idea is labeling cells via N-ary tree staring from the origin where N is chose so that at least 1/2M of the hyperbolic plane.
In the picture M is 5. This means 1/10 of the plane must be labeled to label all directions.
So we use a base-3 tree because it is the smallest that covers at least 1/10 of the hyperbolic plane.
After each branch the labeling of the next branches is oriented based on the rotated based on the turns taken on N-nary branching tree.
(This in addition to the holonomy.)
For example the sequence (...,0,2,2,2) is the same positionas (...) because that is same going around vertex of the grid. However the orientation of the next branch of the tree would be different. For example (0,2,2,2,2) is the same position as (0) and again with a different orientation of the further branches on the tree. A 0 and than twenty 2s would give you (0) in the same orientation.
User avatar
Andrew
Moderator
Posts: 1064
Joined: June 2nd, 2009, 2:08 am
Location: Melbourne, Australia
Contact:

Re: Cellular Automata in Hyperbolic Space

Post by Andrew »

The hyperconway.py script doesn't work very well on macOS (buttons overlap and right-clicking isn't supported) so I got Claude to convert the Python code to Lua so it runs in Glu (or Glui on iPad):

hyperconway.png
hyperconway.png (389.31 KiB) Viewed 30 times

I also got Claude to implement click-and-drag so you can move a tile to anywhere on the disk. Here is the Lua code in case Entity Valkyrie 2 wants to back port that capability into the Python version:

Code: Select all

--[[
Life-like cellular automata on a hyperbolic tiling of heptagons and hexagons.
Converted from HyperConway 1.9, a Python/tkinter script by Valky River.
Code: https://github.com/ValkyRiver/hyperconway
Examples: https://conwaylife.com/forums/viewtopic.php?t=5300
Lua version for Glu by Claude Pro and Andrew Trevorrow (andrew@trevorrow.com).
--]]

local glu = glu()
local cv = canvas()
-- require "gplus.strict"
local gp = require "gplus"
local cp = require "cplus"
local iOS = glu.os() == "iOS"
if iOS then
    glu.setoption("showexit",0) -- hide the EXIT button
end

local sin = math.sin
local cos = math.cos
local pi = math.pi
local floor = math.floor

local version = "1.9"

local viewwd, viewht = 1280, 726    -- initial viewport size (for a scale of 660)
local minwd, minht = 970, 550       -- minimum viewport size
local scale = 660                   -- all positions and sizes are relative to this

-- colors:
local bgcolor = {236, 236, 236, 255}
local oncolor = {0, 204, 255, 255}
local offcolor = {255, 255, 255, 255}
local onheptcolor = {0, 187, 238, 255}
local offheptcolor = {238, 238, 238, 255}
local highlightcolor = {255, 204, 0, 255}
local highlightheptcolor = {238, 187, 0, 255}
local historycolor = {170, 255, 204, 255}
local historyheptcolor = {153, 238, 187, 255}
local bordercolor = {0, 0, 0, 255}
local textcolor = {0, 0, 0, 255}

-- state:
local universe = {[0] = {}}         -- universe[g] is the set of live cells in generation g
local lastgen = 0                   -- last generation stored in universe
local gen = 0                       -- current generation
local rule = {}                     -- set of transitions like "B3o" and "S2m"
local density = 0.5                 -- density for random fill
local radius = 2                    -- radius for random fill
local history = false               -- show cells that were alive in earlier generations?
local playing = false               -- generating?
local speed = 4                     -- generations per second
local nextstep = 0                  -- time (in millisecs) of next generation while playing

local polys = {}                    -- polygons drawn by DrawUniverse (for hit testing)
local nbrcache = {}                 -- cached results of Neighbors
local heptcache = {}                -- cached results of HeptInfo
local panelx = 0                    -- left edge of rulespanel clip

-- controls:
local resetbutton, prevbutton, nextbutton, playbutton, exitbutton
local speeddown, speedup
local historybutton, periodbutton, exportbutton, importbutton
local originbutton, randombutton, clearbutton
local radiusdown, radiusup, densitydown, densityup
local rulebox = {}                  -- check boxes for each transition

local speedlist = {0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 7, 8, 9,
                   10, 11, 12, 13, 14, 15, 16, 18, 20, 22, 24, 26, 28, 30, 32}

--------------------------------------------------------------------------------

-- CELL FORMAT

-- HEPTAGONS
-- X is the origin heptagon
-- X0, X1, X2, X3, X4, X5, X6 are the heptagons corner from the origin
-- then, the heptagons branch tree - all branches split into 3 (L M R), except if it ends in R

-- HEXAGONS
-- a refers to the hexagon on the left side of the edge between two heptagons
-- if the edge is R, then there is also a hexagon on the right: b

-- the universe only stores live cells

-- neighborhoods (o = ortho, m = meta, p = para, g = gyro)
local hept_formations = {
    {"0000000", "0o"},
    {"1000000", "1o"},
    {"1100000", "2o"},
    {"1010000", "2m"},
    {"1001000", "2p"},
    {"1110000", "3o"},
    {"1101000", "3m"},
    {"1100100", "3p"},
    {"1010100", "3g"},
    {"1111000", "4o"},
    {"1110100", "4m"},
    {"1101100", "4p"},
    {"1101010", "4g"},
    {"1111100", "5o"},
    {"1111010", "5m"},
    {"1110110", "5p"},
    {"1111110", "6o"},
    {"1111111", "7o"},
}

local hex_formations = {
    {"000000", "0a"},
    {"100000", "1x"},
    {"010000", "1h"},
    {"110000", "2a"},
    {"101000", "2x"},
    {"010100", "2h"},
    {"100100", "2c"},
    {"111000", "3a"},
    {"110001", "3b"},
    {"101100", "3c"},
    {"110100", "3d"},
    {"101010", "3x"},
    {"010101", "3h"},
    {"111100", "4a"},
    {"110101", "4x"},
    {"111010", "4h"},
    {"110110", "4c"},
    {"111101", "5x"},
    {"111110", "5h"},
    {"111111", "6a"},
}

-- map neighborhood patterns to formation names
local hept_map = {}
for _, f in ipairs(hept_formations) do hept_map[f[1]] = f[2] end
local hex_map = {}
for _, f in ipairs(hex_formations) do hex_map[f[1]] = f[2] end

-- CELL NAMES

-- the cells within distance 7 of the origin heptagon in one of its 7 sectors
-- (prefix each name with "X0".."X6" to get the actual cells)
local cellnames = {
    -- distance 1
    "a",
    -- distance 2
    "",
    "Rb",
    -- distance 3
    "La",
    "Ma",
    "Ra",
    "R",
    -- distance 4
    "L",
    "LRb",
    "M",
    "MRb",
    "RLa",
    "RRa",
    "RRb",
    -- distance 5
    "LLa",
    "LMa",
    "LRa",
    "LR",
    "MLa",
    "MMa",
    "MRa",
    "MR",
    "MRRb",
    "RL",
    "RLRb",
    "RR",
    -- distance 6
    "LL",
    "LLRb",
    "LM",
    "LMRb",
    "LRLa",
    "LRRa",
    "LRRb",
    "ML",
    "MLRb",
    "MM",
    "MMRb",
    "MRLa",
    "MRRa",
    "MRR",
    "RLLa",
    "RLMa",
    "RLRa",
    "RLR",
    "RRLa",
    "RRRa",
    "RRRb",
    -- distance 7
    "LLLa",
    "LLMa",
    "LLRa",
    "LLR",
    "LMLa",
    "LMMa",
    "LMRa",
    "LMR",
    "LMRRb",
    "LRL",
    "LRLRb",
    "LRR",
    "MLLa",
    "MLMa",
    "MLRa",
    "MLR",
    "MMLa",
    "MMMa",
    "MMRa",
    "MMR",
    "MMRRb",
    "MRL",
    "MRLRb",
    "MRRLa",
    "MRRRa",
    "MRRRb",
    "RLL",
    "RLLRb",
    "RLM",
    "RLMRb",
    "RLRLa",
    "RLRRa",
    "RLRRb",
    "RRL",
    "RRLRb",
    "RRR",
}

local numcells = {0, 1, 3, 7, 14, 26, 47, 83}   -- number of cellnames entries within each distance

-- these points are only used by DrawFormation to draw the neighborhood diagrams
-- (the 1st number is distance from center, the 2nd is angle in units of pi/7,
-- except for a8..d9 where it's in units of pi/3)
local pt = {
    a0 = {0.187, 0.000},
    a1 = {0.342, 0.000},
    b1 = {0.431, 0.648},
    a8 = {0.163, 0.500},
    a9 = {0.457, 0.000},
    b9 = {0.421, 0.283},
    c9 = {0.319, 0.534},
    d9 = {0.409, 0.836},
}

--------------------------------------------------------------------------------

-- EXACT GEOMETRY

-- the tiling is drawn in the Poincare disk model of the hyperbolic plane,
-- where points are complex numbers x+iy inside the unit circle

local function sinh(x) return (math.exp(x) - math.exp(-x))/2 end
local function tanh(x) local e = math.exp(2*x); return (e-1)/(e+1) end
local function asinh(x) return math.log(x + math.sqrt(x*x+1)) end
local function acosh(x) return math.log(x + math.sqrt(x*x-1)) end

-- the heptagon centers form a {3,7} tiling of equilateral triangles with angles 2pi/7,
-- and each heptagon's vertices lie on the lines joining its center to its 7 neighbors
local hepttheta = 2*pi/7
local hdist = acosh(cos(hepttheta) / (1 - cos(hepttheta)))  -- distance between adjacent centers

-- find the heptagon circumradius that makes a heptagon's edges the same length as the
-- hexagon-hexagon edges (the middle parts of the lines joining adjacent centers)
local heptradius
do
    local lo, hi = 0, hdist/2
    for _ = 1, 60 do
        local r = (lo + hi)/2
        if 2*asinh(sinh(r)*sin(pi/7)) > hdist - 2*r then hi = r else lo = r end
    end
    heptradius = (lo + hi)/2
end

-- a point at hyperbolic distance d from the disk's center is tanh(d/2) from the center
local nbrdist = tanh(hdist/2)       -- Poincare distance from a center to an adjacent center
local vertdist = tanh(heptradius/2) -- Poincare distance from a center to its vertices
local maxdist = 4.0                 -- only show heptagons within this distance of the disk's center

-- the view is defined by a heptagon (the anchor) and its position and orientation:
local anchor = "X"                  -- the heptagon closest to the disk's center
local anchorx, anchory = 0, 0       -- position of the anchor's center
local anchora = 0                   -- angle from the anchor to its 1st adjacent heptagon
local anchors = 1                   -- 1 if its adjacent heptagons go counterclockwise, else -1
local frames = {}                   -- frames[h] has the position and orientation of heptagon h
local shown = {}                    -- the heptagons within maxdist (set by BuildTiling)
local tilingchanged = true          -- does BuildTiling need to be called?

-- the original layout has angle 0 pointing up and angles increasing clockwise

--------------------------------------------------------------------------------

function Count(set)
    local n = 0
    for _ in pairs(set) do n = n + 1 end
    return n
end

--------------------------------------------------------------------------------

function SortedCells(set)
    local cells = {}
    for cell in pairs(set) do cells[#cells+1] = cell end
    table.sort(cells)
    return cells
end

--------------------------------------------------------------------------------

function Shape(cell)
    -- return the number of sides of the given cell
    local last = cell:sub(-1)
    if last == "a" or last == "b" then
        return 6
    else
        return 7
    end
end

--------------------------------------------------------------------------------

-- some helpers for the code below (which mimics the Python string indexing)

local function last(s) return s:sub(-1) end
local function last2(s) return s:sub(-2,-2) end         -- s[-2] in Python
local function last3(s) return s:sub(-3,-3) end         -- s[-3] in Python
local function chop(s, n) return s:sub(1,-n-1) end      -- s[0:-n] in Python
local function digit(s) return tonumber(s:sub(2,2)) end -- int(s[1]) in Python
local function isdigit(ch) return ch:find("^[0-6]$") ~= nil end
local rep = string.rep

--------------------------------------------------------------------------------

function StripChar(s, ch)
    -- remove any trailing ch chars from s and return the result and how many were removed
    local n = 0
    while last(s) == ch do
        s = chop(s, 1)
        n = n + 1
    end
    return s, n
end

--------------------------------------------------------------------------------

function OppRight(cell2, n)
    -- find the opposite heptagon for a cell (stripped of trailing R's)
    if isdigit(last(cell2)) then
        return "X"..((digit(cell2)+1)%7)..rep("L", n)
    elseif last(cell2) == "L" then
        if last2(cell2) == "R" then
            return chop(cell2,1).."R"..rep("L", n)
        else
            return chop(cell2,1).."M"..rep("L", n)
        end
    elseif last(cell2) == "M" then
        return chop(cell2,1).."R"..rep("L", n)
    end
    return ""
end

--------------------------------------------------------------------------------

function OppLeft(cell2, n)
    -- find the opposite heptagon for a cell (stripped of trailing L's)
    if isdigit(last(cell2)) then
        return "X"..((digit(cell2)-1)%7)..rep("R", n)
    elseif last(cell2) == "M" then
        return chop(cell2,1).."L"..rep("R", n)
    elseif last(cell2) == "R" then
        if last2(cell2) == "R" then
            return chop(cell2,1).."L"..rep("R", n)
        else
            return chop(cell2,1).."M"..rep("R", n)
        end
    end
    return ""
end

--------------------------------------------------------------------------------

function FindNeighbors(cell)
    local c1 = chop(cell,1)
    local c2 = chop(cell,2)

    -- HEXAGON CASE (always returns hex-hept-hex-hept-hex-hept)
    if last(cell) == "b" then
        local cell2, recurse = StripChar(c1, "R")
        local opp = OppRight(cell2, recurse-1)
        return {c1.."a", c1, opp.."La", opp, opp.."a", c2}
    elseif last(cell) == "a" and last2(cell) == "R" then
        if last3(cell) == "R" then
            return {c2.."La", c2.."L", c2.."LRb", c1, c1.."b", c2}
        else
            return {c2.."Ma", c2.."M", c2.."MRb", c1, c1.."b", c2}
        end
    elseif last(cell) == "a" and last2(cell) == "M" then
        return {c2.."La", c2.."L", c2.."LRb", c1, c2.."Ra", c2}
    elseif last(cell) == "a" and last2(cell) == "L" then
        local cell2, recurse = StripChar(c1, "L")
        local opp = OppLeft(cell2, recurse)
        if last3(cell) == "R" then
            return {opp.."b", opp, opp.."Rb", c1, c2.."Ra", c2}
        else
            return {opp.."b", opp, opp.."Rb", c1, c2.."Ma", c2}
        end
    elseif last(cell) == "a" and isdigit(last2(cell)) then
        local d = digit(cell)
        local prev = "X"..((d-1)%7)
        return {prev.."a", prev, prev.."Rb", c1, "X"..((d+1)%7).."a", "X"}

    -- HEPTAGON CASE
    elseif last(cell) == "R" then
        local cell2, recurse = StripChar(cell, "R")
        local opp = OppRight(cell2, recurse)
        if last2(cell) == "R" then
            return {cell.."La", cell.."Ra", cell.."Rb", opp.."a", cell.."b", cell.."a", c1.."LRb"}
        else
            return {cell.."La", cell.."Ra", cell.."Rb", opp.."a", cell.."b", cell.."a", c1.."MRb"}
        end
    elseif last(cell) == "M" then
        return {cell.."La", cell.."Ma", cell.."Ra", cell.."Rb", c1.."Ra", cell.."a", c1.."LRb"}
    elseif last(cell) == "L" then
        local cell2, recurse = StripChar(cell, "L")
        local opp = OppLeft(cell2, recurse+1)
        if last2(cell) == "R" then
            return {cell.."La", cell.."Ma", cell.."Ra", cell.."Rb", c1.."Ra", cell.."a", opp.."b"}
        else
            return {cell.."La", cell.."Ma", cell.."Ra", cell.."Rb", c1.."Ma", cell.."a", opp.."b"}
        end
    elseif isdigit(last(cell)) then
        local d = digit(cell)
        return {cell.."La", cell.."Ma", cell.."Ra", cell.."Rb", "X"..((d+1)%7).."a", cell.."a", "X"..((d-1)%7).."Rb"}
    elseif cell == "X" then
        return {"X0a", "X1a", "X2a", "X3a", "X4a", "X5a", "X6a"}
    end
    return {}
end

--------------------------------------------------------------------------------

function Neighbors(cell)
    -- return a list of the cells adjacent to the given cell
    -- (callers must not modify the list)
    local nbrs = nbrcache[cell]
    if not nbrs then
        nbrs = FindNeighbors(cell)
        nbrcache[cell] = nbrs
    end
    return nbrs
end

--------------------------------------------------------------------------------


function Formation(cell, live)
    -- return the name of the formation of live cells around the given cell
    -- (or nil if it isn't found)
    local nbrs = Neighbors(cell)
    local n = #nbrs
    if n ~= 6 and n ~= 7 then return nil end
    local f2 = {}   -- the neighbor states repeated twice
    for k = 1, n do
        local state = live[nbrs[k]] and "1" or "0"
        f2[k] = state
        f2[k+n] = state
    end
    local f3 = {}   -- f2 (minus its last entry) in reverse order
    for k = 1, 2*n-1 do f3[k] = f2[2*n-k] end
    if n == 7 then
        for offset = 0, 6 do
            local name = hept_map[table.concat(f2, "", offset+1, offset+7)]
            if name then return name end
            name = hept_map[table.concat(f3, "", offset+1, offset+7)]
            if name then return name end
        end
    else
        for offset = 0, 4, 2 do
            local name = hex_map[table.concat(f2, "", offset+1, offset+6)]
            if name then return name end
            name = hex_map[table.concat(f3, "", offset+1, offset+6)]
            if name then return name end
        end
    end
    return nil
end

--------------------------------------------------------------------------------

function Advance(live)
    -- return the set of live cells in the next generation
    local candidates = {}
    for cell in pairs(live) do
        candidates[cell] = true
        for _, nb in ipairs(Neighbors(cell)) do candidates[nb] = true end
    end
    local newlive = {}
    for cell in pairs(candidates) do
        local f = Formation(cell, live)
        if f then
            if live[cell] then
                if rule["S"..f] then newlive[cell] = true end
            else
                if rule["B"..f] then newlive[cell] = true end
            end
        end
    end
    return newlive
end

--------------------------------------------------------------------------------


-- rule strings like "B2o3/S23" are made up of these groups
local outer_total = {"B1", "B2", "B3", "B4", "B5", "B6", "B7", "S0", "S1", "S2", "S3", "S4", "S5", "S6", "S7"}
local total_formations = {3, 7, 10, 8, 5, 2, 1, 2, 3, 7, 10, 8, 5, 2, 1}
local formation_symbol = {"hox", "chompax", "pogchambdx", "champgox", "phomx", "oa", "o",
                          "oa", "hox", "chompax", "pogchambdx", "champgox", "phomx", "oa", "o"}
local outer_index = {}
for k, s in ipairs(outer_total) do outer_index[s] = k end

--------------------------------------------------------------------------------

function CompressRule()
    local groups = {}
    for k = 1, #outer_total do groups[k] = {} end
    for t in pairs(rule) do
        local k = outer_index[t:sub(1,2)]
        if k then table.insert(groups[k], t:sub(3,3)) end
    end
    local s = "B"
    for k, letters in ipairs(groups) do
        if k == 8 then s = s.."/S" end
        if #letters > 0 then
            s = s..outer_total[k]:sub(2,2)
            if #letters ~= total_formations[k] then
                table.sort(letters)
                s = s..table.concat(letters)
            end
        end
    end
    return s
end

--------------------------------------------------------------------------------

function DecompressRule(s)
    -- return the set of transitions in the given rule string
    local newrule = {}
    local bs, outer = "", "0"
    for c = 1, #s do
        local ch = s:sub(c,c)
        if ch == "B" then
            bs = "B"
        elseif ch == "/" or ch == "S" then
            bs = "S"
        elseif ch:find("^[0-7]$") then
            outer = ch
            local nextch = s:sub(c+1,c+1)
            if c == #s or not ("pogchambdx"):find(nextch, 1, true) then
                -- no letters follow so include all formations
                local k = outer_index[bs..outer]
                if not k then error("bad rule") end
                for letter in formation_symbol[k]:gmatch(".") do
                    newrule[bs..outer..letter] = true
                end
            end
        else
            newrule[bs..outer..ch] = true
        end
    end
    return newrule
end

--------------------------------------------------------------------------------

function DrawPolygon(xy, fillrgba)
    -- fill the given polygon and draw its outline
    local n = #xy
    local pts = {}
    local cx, cy = 0, 0
    for k = 1, n, 2 do
        pts[k] = floor(xy[k] + 0.5)
        pts[k+1] = floor(xy[k+1] + 0.5)
        cx = cx + xy[k]
        cy = cy + xy[k+1]
    end
    cx = floor(cx / (n/2) + 0.5)
    cy = floor(cy / (n/2) + 0.5)

    -- fill a fan of triangles from the center of the polygon
    cv.blend(0)
    cv.rgba(fillrgba)
    for k = 1, n, 2 do
        local k2 = k + 2
        if k2 > n then k2 = 1 end
        cv.fill(cx, cy, pts[k], pts[k+1], pts[k2], pts[k2+1])
        cv.line(cx, cy, pts[k], pts[k+1])   -- hide any gaps between triangles
    end

    -- draw the antialiased outline
    pts[n+1] = pts[1]
    pts[n+2] = pts[2]
    cv.rgba(bordercolor)
    cv.blend(1)
    cv.line(table.unpack(pts))
    cv.blend(0)
end

--------------------------------------------------------------------------------

function InsidePolygon(x, y, xy)
    local inside = false
    local n = #xy
    local jx, jy = xy[n-1], xy[n]
    for k = 1, n, 2 do
        local ix, iy = xy[k], xy[k+1]
        if ((iy > y) ~= (jy > y)) and (x < (jx-ix) * (y-iy) / (jy-iy) + ix) then
            inside = not inside
        end
        jx, jy = ix, iy
    end
    return inside
end

--------------------------------------------------------------------------------

function CellUnder(x, y)
    -- return the drawn cell containing the given canvas position (or nil)
    for _, p in ipairs(polys) do
        if InsidePolygon(x, y, p.xy) then return p.cell end
    end
    return nil
end

--------------------------------------------------------------------------------

function CellAt(x, y)
    -- return the cell containing the given canvas position
    local cell = CellUnder(x, y)
    if cell then return cell end
    -- not inside any polygon so return the cell with the closest center
    local mincell, mind = nil, math.huge
    for _, p in ipairs(polys) do
        local n = #p.xy
        local cx, cy = 0, 0
        for k = 1, n, 2 do
            cx = cx + p.xy[k]
            cy = cy + p.xy[k+1]
        end
        cx = cx / (n/2)
        cy = cy / (n/2)
        local d = (cx-x)^2 + (cy-y)^2
        if d < mind then
            mincell, mind = p.cell, d
        end
    end
    return mincell
end

--------------------------------------------------------------------------------

function Translate(ax, ay, zx, zy)
    -- return (z + a) / (1 + conj(a) z), the hyperbolic translation that moves 0 to a
    local nx, ny = zx + ax, zy + ay
    local dx, dy = 1 + ax*zx + ay*zy, ax*zy - ay*zx
    local d = dx*dx + dy*dy
    return (nx*dx + ny*dy) / d, (ny*dx - nx*dy) / d
end

--------------------------------------------------------------------------------

function Untranslate(ax, ay, zx, zy)
    -- the inverse of Translate (it moves a to 0)
    return Translate(-ax, -ay, zx, zy)
end

--------------------------------------------------------------------------------

function HeptInfo(h)
    -- return the 7 hexagons around heptagon h (in cyclic order) and the 7 heptagons
    -- adjacent to h, where hepts[j] lies between hexes[j] and hexes[j+1]
    local info = heptcache[h]
    if info then return info end
    local hexes = Neighbors(h)
    local hepts, index = {}, {}
    for j = 1, 7 do
        -- a hexagon's neighbors alternate between hexagons and heptagons
        local a = Neighbors(hexes[j])
        local b = Neighbors(hexes[j%7 + 1])
        for _, x in ipairs({a[2], a[4], a[6]}) do
            if x ~= h and (x == b[2] or x == b[4] or x == b[6]) then
                hepts[j] = x
                index[x] = j
            end
        end
    end
    info = {hexes = hexes, hepts = hepts, index = index}
    heptcache[h] = info
    return info
end

--------------------------------------------------------------------------------

function AddAdjacent(h, frames)
    -- add frames for any heptagons adjacent to h (which must have a frame)
    -- and return a list of the heptagons that were added
    local f = frames[h]
    local info = HeptInfo(h)
    local newhepts = {}
    for j = 1, 7 do
        local q = info.hepts[j]
        if not frames[q] then
            local angle = f.a + f.s*(j-1)*hepttheta
            local qx, qy = Translate(f.x, f.y, nbrdist*cos(angle), nbrdist*sin(angle))
            frames[q] = {x = qx, y = qy}
            newhepts[#newhepts+1] = q
        end
    end
    -- find the orientation of each new heptagon from the positions of h
    -- and the heptagon after h in its list of adjacent heptagons
    for _, q in ipairs(newhepts) do
        local qf = frames[q]
        local qinfo = HeptInfo(q)
        local k = qinfo.index[h]
        local nf = frames[qinfo.hepts[k%7 + 1]]
        local hx, hy = Untranslate(qf.x, qf.y, f.x, f.y)
        local nx, ny = Untranslate(qf.x, qf.y, nf.x, nf.y)
        local hangle = math.atan(hy, hx)
        if (math.atan(ny, nx) - hangle) % (2*pi) < pi then qf.s = 1 else qf.s = -1 end
        qf.a = hangle - qf.s*(k-1)*hepttheta
    end
    return newhepts
end

--------------------------------------------------------------------------------

function CenterAnchor()
    -- if the anchor isn't the heptagon closest to the disk's center (eg. after a fast drag)
    -- then keep moving it to whichever adjacent heptagon is closer to the center
    local f = {x = anchorx, y = anchory, a = anchora, s = anchors}
    for _ = 1, 1000 do
        local temp = {[anchor] = f}
        AddAdjacent(anchor, temp)
        local best, bestr = anchor, f.x*f.x + f.y*f.y
        for q, qf in pairs(temp) do
            local r = qf.x*qf.x + qf.y*qf.y
            if r < bestr then best, bestr = q, r end
        end
        if best == anchor then break end
        anchor, f = best, temp[best]
    end
    anchorx, anchory, anchora, anchors = f.x, f.y, f.a, f.s
end

--------------------------------------------------------------------------------

function BuildTiling()
    -- find the position and orientation of every heptagon within maxdist of the
    -- disk's center (and the heptagons adjacent to them)
    CenterAnchor()
    frames = {[anchor] = {x = anchorx, y = anchory, a = anchora, s = anchors}}
    shown = {}
    local maxr = tanh(maxdist/2)
    local queue = {anchor}
    local n = 1
    while n <= #queue do
        local h = queue[n]
        n = n + 1
        local f = frames[h]
        if f.x*f.x + f.y*f.y <= maxr*maxr then
            shown[#shown+1] = h
            for _, q in ipairs(AddAdjacent(h, frames)) do
                queue[#queue+1] = q
            end
        end
    end

    -- make the heptagon closest to the disk's center the new anchor
    -- (this doesn't change the view but it avoids losing precision)
    local best, bestr = anchor, math.huge
    for _, h in ipairs(shown) do
        local f = frames[h]
        local r = f.x*f.x + f.y*f.y
        if r < bestr then best, bestr = h, r end
    end
    local f = frames[best]
    anchor, anchorx, anchory, anchora, anchors = best, f.x, f.y, f.a, f.s
    tilingchanged = false
end

--------------------------------------------------------------------------------

function HeptVertices(f)
    -- return the 7 vertices of the heptagon with the given frame
    -- (vertex k is on the line to adjacent heptagon k)
    if not f.verts then
        local verts = {}
        for k = 1, 7 do
            local angle = f.a + f.s*(k-1)*hepttheta
            verts[2*k-1], verts[2*k] = Translate(f.x, f.y, vertdist*cos(angle), vertdist*sin(angle))
        end
        f.verts = verts
    end
    return f.verts
end

--------------------------------------------------------------------------------

function TransformView(transform)
    -- apply the given isometry to the view
    -- (we track where the anchor's center and a point in the direction of anchora move to)
    local bx, by = Translate(anchorx, anchory, 0.5*cos(anchora), 0.5*sin(anchora))
    anchorx, anchory = transform(anchorx, anchory)
    bx, by = transform(bx, by)
    local lx, ly = Untranslate(anchorx, anchory, bx, by)
    anchora = math.atan(ly, lx)
    tilingchanged = true
end

--------------------------------------------------------------------------------

function RotateView(cx, cy, angle)
    -- rotate the view by the given angle about the point cx,cy
    local c, s = cos(angle), sin(angle)
    TransformView(function (zx, zy)
        local wx, wy = Untranslate(cx, cy, zx, zy)
        return Translate(cx, cy, wx*c - wy*s, wx*s + wy*c)
    end)
end

--------------------------------------------------------------------------------

function ToScreen(zx, zy)
    -- convert a point in the disk to canvas coordinates
    local half = scale/2
    local mid = scale*0.55
    return mid + zy*half, mid - zx*half
end

--------------------------------------------------------------------------------

function FromScreen(x, y)
    -- convert canvas coordinates to a point in the disk
    local half = scale/2
    local mid = scale*0.55
    local zx, zy = (mid - y)/half, (x - mid)/half
    local r = math.sqrt(zx*zx + zy*zy)
    if r > 0.995 then
        -- keep the point inside the disk
        zx, zy = zx*0.995/r, zy*0.995/r
    end
    return zx, zy
end

--------------------------------------------------------------------------------

function DrawCell(cell, pts, live, histset)
    -- draw the cell with the given vertices (points in the disk)
    local xy = {}
    local minx, miny, maxx, maxy = math.huge, math.huge, -math.huge, -math.huge
    for k = 1, #pts, 2 do
        local x, y = ToScreen(pts[k], pts[k+1])
        xy[k], xy[k+1] = x, y
        minx = math.min(minx, x)
        maxx = math.max(maxx, x)
        miny = math.min(miny, y)
        maxy = math.max(maxy, y)
    end
    -- don't bother drawing tiny cells near the edge of the disk
    if maxx - minx < 2 and maxy - miny < 2 then return end

    local hex = Shape(cell) == 6
    local color
    if live[cell] then
        color = hex and oncolor or onheptcolor
    elseif histset and histset[cell] then
        color = hex and historycolor or historyheptcolor
    else
        color = hex and offcolor or offheptcolor
    end
    DrawPolygon(xy, color)
    polys[#polys+1] = {cell = cell, xy = xy}
end
--------------------------------------------------------------------------------

function DrawUniverse()
    local live = universe[gen]
    local histset = nil
    if history then
        -- find all cells that were alive in earlier generations
        histset = {}
        for g = 0, gen-1 do
            for cell in pairs(universe[g]) do histset[cell] = true end
        end
    end

    if tilingchanged then BuildTiling() end
    polys = {}
    local drawn = {}
    for _, h in ipairs(shown) do
        local f = frames[h]
        DrawCell(h, HeptVertices(f), live, histset)

        -- draw the hexagons around h
        local info = HeptInfo(h)
        for j = 1, 7 do
            local hex = info.hexes[j]
            if not drawn[hex] then
                drawn[hex] = true
                -- hex lies between h and its adjacent heptagons q1 and q2
                local j1 = (j-2)%7 + 1
                local q1, q2 = info.hepts[j1], info.hepts[j]
                local v = HeptVertices(f)
                local v1 = HeptVertices(frames[q1])
                local v2 = HeptVertices(frames[q2])
                local i1 = HeptInfo(q1).index
                local i2 = HeptInfo(q2).index
                local pts = {}
                local function add(verts, k)
                    pts[#pts+1] = verts[2*k-1]
                    pts[#pts+1] = verts[2*k]
                end
                add(v, j1)
                add(v, j)
                add(v2, i2[h])
                add(v2, i2[q1])
                add(v1, i1[q2])
                add(v1, i1[h])
                DrawCell(hex, pts, live, histset)
            end
        end
    end
end
--------------------------------------------------------------------------------

function DrawFormation(pattern, x, y)
    -- draw a neighborhood diagram in the rules panel
    local cx = 1.15*scale + (scale/5)*x - panelx
    local cy = 0.1*scale + (scale/10)*y
    local size = scale/10

    local xy
    local function addpt(p, angle)
        xy[#xy+1] = p[1] * sin(angle) * size + cx
        xy[#xy+1] = p[1] * -cos(angle) * size + cy
    end

    local a0, a1, b1 = pt.a0, pt.a1, pt.b1
    local a8, a9, b9, c9, d9 = pt.a8, pt.a9, pt.b9, pt.c9, pt.d9
    if #pattern == 7 then
        local u = pi/7
        xy = {}
        for k = 0, 6 do addpt(a0, (a0[2]+2*k) * u) end
        DrawPolygon(xy, highlightheptcolor)
        for gon = 0, 6 do
            local g2 = gon*2
            xy = {}
            addpt(a1, (a1[2]+g2) * u)
            addpt(a0, (a0[2]+g2) * u)
            addpt(a0, (a0[2]-2+g2) * u)
            addpt(a1, (a1[2]-2+g2) * u)
            addpt(b1, (b1[2]-2+g2) * u)
            addpt(b1, (-b1[2]+g2) * u)
            DrawPolygon(xy, pattern:sub(gon+1,gon+1) == "1" and oncolor or offcolor)
        end
    else
        local u = pi/3
        xy = {}
        for k = 0, 5 do addpt(a8, (a8[2]+k) * u) end
        DrawPolygon(xy, highlightcolor)
        for gon = 0, 5 do
            local on = pattern:sub(gon+1,gon+1) == "1"
            xy = {}
            if gon % 2 == 0 then
                -- hexagon
                addpt(a8, (a8[2]-2+gon) * u)
                addpt(c9, (c9[2]-2+gon) * u)
                addpt(d9, (d9[2]-2+gon) * u)
                addpt(d9, (-d9[2]+gon) * u)
                addpt(c9, (-c9[2]+gon) * u)
                addpt(a8, (-a8[2]+gon) * u)
                DrawPolygon(xy, on and oncolor or offcolor)
            else
                -- heptagon
                addpt(a9, (a9[2]+gon-1) * u)
                addpt(b9, (b9[2]+gon-1) * u)
                addpt(c9, (c9[2]+gon-1) * u)
                addpt(a8, (a8[2]+gon-1) * u)
                addpt(a8, (-a8[2]+gon-1) * u)
                addpt(c9, (-c9[2]+gon-1) * u)
                addpt(b9, (-b9[2]+gon-1) * u)
                DrawPolygon(xy, on and onheptcolor or offheptcolor)
            end
        end
    end
end

--------------------------------------------------------------------------------

function CreateRulesPanel()
    -- draw the neighborhood diagrams into a clip (they never change)
    panelx = floor(1.09*scale)
    cv.create(math.max(1, viewwd-panelx), viewht, "rulespanel")
    local oldtarget = cv.target("rulespanel")
    cv.rgba(bgcolor)
    cv.blend(0)
    cv.fill()
    for num, f in ipairs(hept_formations) do
        DrawFormation(f[1], (num-1)//9, (num-1)%9)
    end
    for num, f in ipairs(hex_formations) do
        DrawFormation(f[1], (num-1)//10 + 2, (num-1)%10)
    end
    cv.target(oldtarget)
end

--------------------------------------------------------------------------------

function TruncateHistory()
    -- remove all generations after the current one
    for g = gen+1, lastgen do universe[g] = nil end
    lastgen = gen
end

--------------------------------------------------------------------------------

function SetRule(checkbox)
    local t = checkbox.transition
    if rule[t] then rule[t] = nil else rule[t] = true end
    TruncateHistory()
    Refresh()
end

--------------------------------------------------------------------------------

function ResetGeneration()
    playing = false
    gen = 0
    Refresh()
end

--------------------------------------------------------------------------------

function PrevGeneration()
    playing = false
    if gen > 0 then
        gen = gen - 1
        Refresh()
    end
end

--------------------------------------------------------------------------------

function NextGeneration()
    universe[gen+1] = Advance(universe[gen])
    gen = gen + 1
    if gen > lastgen then lastgen = gen end
end

--------------------------------------------------------------------------------

function StepGeneration()
    playing = false
    NextGeneration()
    Refresh()
end

--------------------------------------------------------------------------------

function TogglePlay()
    playing = not playing
    if playing then
        nextstep = glu.millisecs() + 1000/speed
        NextGeneration()
        Refresh()
    end
end

--------------------------------------------------------------------------------

function ChangeSpeed(delta)
    for k, s in ipairs(speedlist) do
        if s == speed then
            if k+delta >= 1 and k+delta <= #speedlist then
                speed = speedlist[k+delta]
            end
            break
        end
    end
    Refresh()
end

--------------------------------------------------------------------------------

function RandomFillOptions(option)
    if option == "big" and radius < 7 then
        radius = radius + 1
    elseif option == "small" and radius > 0 then
        radius = radius - 1
    elseif option == "down" and density > 0 then
        density = floor((density - 0.05)*20 + 0.5) / 20
    elseif option == "up" and density < 1 then
        density = floor((density + 0.05)*20 + 0.5) / 20
    end
    Refresh()
end

--------------------------------------------------------------------------------

function RandomFill(rad, dens)
    local live = {}
    if math.random() < dens then live["X"] = true end
    for offset = 0, 6 do
        for num = 1, numcells[rad+1] do
            if math.random() < dens then
                live["X"..offset..cellnames[num]] = true
            end
        end
    end
    universe = {[0] = live}
    gen = 0
    lastgen = 0
    Refresh()
end

--------------------------------------------------------------------------------

function ShowHistory()
    history = not history
    Refresh()
end

--------------------------------------------------------------------------------

function Origin()
    anchor = "X"
    anchorx, anchory = 0, 0
    anchora = 0
    anchors = 1
    tilingchanged = true
    Refresh()
end
--------------------------------------------------------------------------------

function ExportCode()
    local s = CompressRule()
    local cells = SortedCells(universe[gen])
    if #cells > 0 then s = s.." "..table.concat(cells, " ") end
    glu.setclipboard(s)
end

--------------------------------------------------------------------------------

function ImportCode()
    playing = false
    local code = glu.getclipboard() or ""
    local items = {}
    for item in code:gmatch("%S+") do items[#items+1] = item end
    if #items == 0 or items[1]:sub(1,1) ~= "B" then
        glu.warn("Clipboard doesn't contain a HyperConway code.")
        return
    end
    local ok, newrule = pcall(DecompressRule, items[1])
    if not ok then
        glu.warn("Clipboard contains an invalid rule.")
        return
    end
    local live = {}
    for k = 2, #items do
        -- ignore any invalid cell names
        local cell = items[k]
        if cell == "X" or cell:find("^X[0-6][LMR]*[ab]?$") then live[cell] = true end
    end
    rule = newrule
    universe = {[0] = live}
    gen = 0
    lastgen = 0
    Origin()    -- calls Refresh()
end

--------------------------------------------------------------------------------

function CanonicalKey(set)
    return table.concat(SortedCells(set), " ")
end

--------------------------------------------------------------------------------

function DetectPeriod()
    playing = false
    local gen_threshold = 512
    local pop_threshold = 1024
    cv.cursor("wait")

    local current = universe[gen]
    local index = {[CanonicalKey(current)] = 0}
    local pops = {Count(current)}
    local title, info
    for g = 0, gen_threshold-1 do
        current = Advance(current)
        local pop = Count(current)
        if pop >= pop_threshold then
            title = "Potential explosion detected"
            info = "The pattern reached a population of "..pop.." in generation "..(gen+g+1)
            break
        end
        local key = CanonicalKey(current)
        local k = index[key]
        if k then
            local period = g - k + 1
            local minpop = math.huge
            for p = k+1, #pops do minpop = math.min(minpop, pops[p]) end
            if pop == 0 then
                title = "Spark/diehard detected"
            elseif period == 1 then
                title = minpop.."-cell still life detected"
            else
                title = minpop.."P"..period.." detected"
            end
            info = "The pattern stabilizes on generation "..(gen+k)
            break
        end
        index[key] = g+1
        pops[#pops+1] = pop
    end
    if not title then
        local lo, hi = math.huge, 0
        for _, p in ipairs(pops) do
            lo = math.min(lo, p)
            hi = math.max(hi, p)
        end
        title = "No periodicity detected within "..gen_threshold.." generations"
        info = "Lowest population reached: "..lo..", highest population reached: "..hi
    end

    cv.cursor("arrow")
    glu.note(title.."\n\n"..info)
end

--------------------------------------------------------------------------------

function ToggleCell(x, y)
    local cell = CellAt(x, y)
    if not cell then return end
    local live = universe[gen]
    if live[cell] then live[cell] = nil else live[cell] = true end
    TruncateHistory()
    Refresh()
end

--------------------------------------------------------------------------------

function ChangeCenter(x, y, dist)
    -- dist is the squared distance from the disk's center (1 is the boundary)
    local cell = CellAt(x, y)
    if not cell or Shape(cell) ~= 7 or not frames[cell] then return end
    local f = frames[cell]

    if dist <= 0.05 then
        -- clicked the center heptagon so rotate the tiling by pi/7 about its center
        RotateView(f.x, f.y, pi/7)
    else
        -- move the clicked heptagon to the disk's center
        local cx, cy = f.x, f.y
        local bx, by = Translate(cx, cy, 0.5*cos(f.a), 0.5*sin(f.a))
        TransformView(function (zx, zy) return Untranslate(cx, cy, zx, zy) end)
        -- then rotate it so its vertices are at multiples of pi/7 (like the origin heptagon)
        local lx, ly = Untranslate(cx, cy, bx, by)
        local angle = math.atan(ly, lx)
        RotateView(0, 0, floor(angle/(pi/7) + 0.5)*(pi/7) - angle)
    end
    Refresh()
end
--------------------------------------------------------------------------------

function CreateControls()
    -- create buttons and check boxes with sizes based on the current scale
    cp.buttonht = math.max(14, floor(scale/30 + 0.5))
    cp.radius = cp.buttonht // 2
    cp.textfontsize = math.max(7, floor(scale/66 + 0.5))
    cp.textgap = math.max(6, floor(scale/66 + 0.5))
    cp.labelrgba = cp.black

    exitbutton = cp.button("Exit", glu.exit)
    resetbutton = cp.button("\u{25C4}\u{25C4} ", ResetGeneration)
    prevbutton = cp.button("\u{25C4} ", PrevGeneration)
    nextbutton = cp.button(" \u{25BA}", StepGeneration)
    playbutton = cp.button("Play/Pause", TogglePlay)
    speeddown = cp.button("<", ChangeSpeed, {-1})
    speedup = cp.button(">", ChangeSpeed, {1})

    historybutton = cp.button("History", ShowHistory)
    periodbutton = cp.button("Detect period", DetectPeriod)
    exportbutton = cp.button("Export to clipboard", ExportCode)
    importbutton = cp.button("Import from clipboard", ImportCode)
    originbutton = cp.button("Origin", Origin)

    randombutton = cp.button("Random", function () RandomFill(radius, density) end)
    radiusdown = cp.button("<", RandomFillOptions, {"small"})
    radiusup = cp.button(">", RandomFillOptions, {"big"})
    densitydown = cp.button("<", RandomFillOptions, {"down"})
    densityup = cp.button(">", RandomFillOptions, {"up"})
    clearbutton = cp.button("Clear", function () RandomFill(0, 0) end)

    -- create a check box for every birth and survival transition
    cp.buttonht = math.max(13, floor(scale/35 + 0.5))
    cp.radius = cp.buttonht // 4
    rulebox = {}
    local function addbox(transition, x, y)
        local box = cp.checkbox(transition, SetRule)
        box.transition = transition
        box.px = x
        box.py = y
        rulebox[#rulebox+1] = box
    end
    for num, f in ipairs(hept_formations) do
        local col, row = (num-1)//9, (num-1)%9
        local x = 1.2*scale + col*0.2*scale
        if num == 1 then
            addbox("S"..f[2], x, 0.085*scale + row*0.1*scale)
        else
            addbox("B"..f[2], x, 0.070*scale + row*0.1*scale)
            addbox("S"..f[2], x, 0.106*scale + row*0.1*scale)
        end
    end
    for num, f in ipairs(hex_formations) do
        local col, row = (num-1)//10 + 2, (num-1)%10
        local x = 1.2*scale + col*0.2*scale
        if num == 1 then
            addbox("S"..f[2], x, 0.085*scale + row*0.1*scale)
        else
            addbox("B"..f[2], x, 0.070*scale + row*0.1*scale)
            addbox("S"..f[2], x, 0.106*scale + row*0.1*scale)
        end
    end
end

--------------------------------------------------------------------------------

function DrawText(s, x, y)
    -- draw the given text at x,y
    local w, h = cv.text("temptext", s)
    cv.paste("temptext", x, y)
end

--------------------------------------------------------------------------------

function CenterText(s, x, y)
    -- draw the given text centered at x,y
    local w, h = cv.text("temptext", s)
    cv.paste("temptext", floor(x - w/2 + 0.5), floor(y - h/2 + 0.5))
end

--------------------------------------------------------------------------------

function DrawControls()
    local function px(f) return floor(f*scale + 0.5) end
    local gap = math.max(2, scale//200)
    
    local bigfont = math.max(8, floor(scale/50))
    cv.font("default-bold", bigfont)
    cv.rgba(textcolor)
    cv.blend(1)
    
    DrawText("Generation = "..gen, 20, 0.02*scale)
    DrawText("Population = "..Count(universe[gen]), 20, 0.05*scale)
    
    local smallfont = math.max(7, floor(scale/70))
    cv.font("default-bold", smallfont)
    
    CenterText("HyperConway "..version.." by Valky River", viewwd/2, 0.025*scale)
    exitbutton.show((viewwd - exitbutton.wd)/2, 0.045*scale)
    
    if iOS then
        originbutton.show(20, px(0.83))
    else
        CenterText("Right-click center", 1.003*scale, 0.99*scale)
        CenterText("heptagon to rotate", 1.001*scale, 1.01*scale)
        CenterText("Right-click a near-center", 0.972*scale, 1.04*scale)
        CenterText("heptagon to change center", 0.963*scale, 1.06*scale)
        originbutton.show(0.963*scale, px(0.93))
    end

    -- generation buttons
    local y = px(0.09)
    resetbutton.show(20, y)
    prevbutton.show(resetbutton.x + resetbutton.wd + gap, y)
    nextbutton.show(prevbutton.x + prevbutton.wd + gap, y)
    playbutton.show(20, px(0.14))
    speeddown.show(20, px(0.185))
    speedup.show(speeddown.x + speeddown.wd + gap, px(0.185))
    CenterText(string.format("%g / s", speed), (speeddown.x + speedup.x + speedup.wd)/2,
               px(0.195) + cp.buttonht + smallfont)

    -- buttons at bottom left
    historybutton.show(20, px(0.88))
    periodbutton.show(20, px(0.93))
    exportbutton.show(20, px(0.98))
    importbutton.show(20, px(1.03))

    -- random fill buttons at bottom right
    randombutton.show(px(1.12), px(0.97))
    clearbutton.show(px(1.12) + (randombutton.wd-clearbutton.wd)/2, px(1.02))
    local x = math.max(px(1.23), randombutton.x + randombutton.wd + 3*gap)
    radiusdown.show(x, px(0.97))
    radiusup.show(radiusdown.x + radiusdown.wd + gap, px(0.97))
    x = math.max(px(1.31), radiusup.x + radiusup.wd + 3*gap)
    densitydown.show(x, px(0.97))
    densityup.show(densitydown.x + densitydown.wd + gap, px(0.97))
    local rx = (radiusdown.x + radiusup.x + radiusup.wd)/2
    local dx = (densitydown.x + densityup.x + densityup.wd)/2
    CenterText("Radius", rx, px(1.025))
    CenterText(tostring(radius), rx, px(1.045))
    CenterText("Density", dx, px(1.025))
    CenterText(string.format("%g", density), dx, px(1.045))

    -- transition check boxes
    cv.rgba(cp.white)
    cv.blend(0)
    for _, box in ipairs(rulebox) do
        local x, y = floor(box.px), floor(box.py)
        box.show(x, y, rule[box.transition] == true)
        if not rule[box.transition] then
            -- make button white so ticked transitions stand out more
            cv.flood(x+5, y+5)
        end
    end
end

--------------------------------------------------------------------------------

function Refresh()
    cv.rgba(bgcolor)
    cv.blend(0)
    cv.fill()
    cv.paste("rulespanel", panelx, 0)
    DrawUniverse()
    DrawControls()
    -- the canvas is fully opaque so we can use cv.update()
    cv.update()
end

--------------------------------------------------------------------------------

function SetScale()
    scale = math.min(viewwd/1.94, viewht/1.1)
end

--------------------------------------------------------------------------------

function CheckViewSize()
    -- if the viewport size has changed then resize the canvas and everything in it
    local newwd, newht = glu.getview()
    if newwd ~= viewwd or newht ~= viewht then
        if not iOS and (newwd < minwd or newht < minht) then
            -- don't let the viewport get too small
            newwd = math.max(newwd, minwd)
            newht = math.max(newht, minht)
            glu.setview(newwd, newht)
        end
        viewwd, viewht = newwd, newht
        cv.resize(viewwd, viewht)
        SetScale()
        cp.disable_all()    -- prevent old button locations being pressed
        CreateControls()
        CreateRulesPanel()
        Refresh()
    end
end

--------------------------------------------------------------------------------

function HandleKey(event)
    if     event == "key return none" then TogglePlay()
    elseif event == "key right none" then StepGeneration()
    elseif event == "key left none" then PrevGeneration()
    elseif event == "key space none" then StepGeneration()
    elseif event == "key z none" then PrevGeneration()
    elseif event == "key 0 none" then ResetGeneration()
    elseif event == "key escape none" then glu.exit()
    end
end

--------------------------------------------------------------------------------

function DiskRadius(x, y)
    -- return the distance from the disk's center to x,y (1 is the boundary)
    local half = scale/2
    local mid = scale*0.55
    local dx, dy = (x - mid)/half, (y - mid)/half
    return math.sqrt(dx*dx + dy*dy)
end

--------------------------------------------------------------------------------

function DragTiling(x, y, newx, newy)
    -- move the tiling so the clicked point x,y stays under the mouse until the button
    -- is released (newx,newy is the mouse position that started the drag)
    local zx, zy = FromScreen(x, y)
    local prevx, prevy = x, y
    -- ignore mouse positions too close to the disk's edge (where a tiny mouse movement
    -- can move the tiling a huge distance) unless the drag started at least that close
    local maxradius = math.min(0.99, math.max(0.95, DiskRadius(x, y)))
    while true do
        if not newx then
            local event = glu.getevent()
            if event:find("^mup") then break end
            newx, newy = cv.getxy()
        end
        if newx >= 0 and (newx ~= prevx or newy ~= prevy) and DiskRadius(newx, newy) <= maxradius then
            local wx, wy = FromScreen(newx, newy)
            -- translate along the line through z and w so that z moves to w
            local ux, uy = Untranslate(zx, zy, wx, wy)
            local ax, ay = zx, zy
            TransformView(function (px, py)
                px, py = Untranslate(ax, ay, px, py)
                px, py = Translate(ux, uy, px, py)
                return Translate(ax, ay, px, py)
            end)
            zx, zy = wx, wy
            prevx, prevy = newx, newy
            Refresh()
        end
        newx = nil
    end
end
--------------------------------------------------------------------------------

function ClickOrDrag(x, y)
    -- if the mouse moves more than dragmin pixels before the button is released then
    -- drag the tiling, otherwise toggle the state of the clicked cell
    -- (this also lets Glui users drag the tiling with a finger)
    local dragmin = 2
    while true do
        local event = glu.getevent(0)
        if event:find("^mup") then
            ToggleCell(x, y)
            return
        end
        local newx, newy = cv.getxy()
        if newx >= 0 and (math.abs(newx - x) > dragmin or math.abs(newy - y) > dragmin) then
            -- start dragging from the original click so the clicked tile stays under the mouse
            DragTiling(x, y, newx, newy)
            return
        end
    end
end

--------------------------------------------------------------------------------

function HandleClick(event)
    local _, x, y, button, mods = gp.split(event)
    x = tonumber(x)
    y = tonumber(y)
    local dx = (x - scale*0.55) / (scale/2)
    local dy = (y - scale*0.55) / (scale/2)
    local dist = dx*dx + dy*dy
    if button == "right" or mods == "ctrl" then
        if dist <= 0.4 then ChangeCenter(x, y, dist) end
    elseif button == "left" then
        -- any drawn tile can be clicked or dragged, even a tiny one at the edge of the disk
        if CellUnder(x, y) and DiskRadius(x, y) < 0.99 then ClickOrDrag(x, y) end
    end
end

--------------------------------------------------------------------------------

function EventLoop()
    while true do
        local event = cp.process( glu.getevent() )
        if #event == 0 then
            CheckViewSize()
            if playing and glu.millisecs() >= nextstep then
                nextstep = glu.millisecs() + 1000/speed
                NextGeneration()
                Refresh()
            end
        elseif event:find("^key") then
            HandleKey(event)
        elseif event:find("^cclick") then
            HandleClick(event)
        end
    end
end

--------------------------------------------------------------------------------

function Main()
    glu.settitle("HyperConway "..version)
    glu.setoption("getescape", 1)
    glu.setcolor("viewport", bgcolor[1], bgcolor[2], bgcolor[3])
    math.randomseed(os.time())

    if not iOS then glu.setview(viewwd, viewht) end
    viewwd, viewht = glu.getview()
    cv.create(viewwd, viewht)
    SetScale()
    CreateControls()
    CreateRulesPanel()
    Refresh()
    EventLoop()
end

--------------------------------------------------------------------------------

function Finish()
    cv.delete()
end

--------------------------------------------------------------------------------

gp.call(Main, Finish)
Use Glu or Glui to explore CA rules on non-periodic tilings: DominoLife and HatLife and SpectreLife
Post Reply