{"spec_id":"maze-circular","library":"makie","language":"julia","code":"# anyplot.ai\n# maze-circular: Circular Maze Puzzle\n# Library: makie 0.21.9 | Julia 1.11.9\n# Quality: 92/100 | Created: 2026-09-02\n\nusing CairoMakie\nusing Colors\nusing Random\n\nRandom.seed!(42)\n\n# --- Theme tokens -------------------------------------------------------------\nconst THEME    = get(ENV, \"ANYPLOT_THEME\", \"light\")\nconst PAGE_BG  = THEME == \"light\" ? colorant\"#FAF8F1\" : colorant\"#1A1A17\"\nconst INK      = THEME == \"light\" ? colorant\"#1A1A17\" : colorant\"#F0EFE8\"\nconst INK_SOFT = THEME == \"light\" ? colorant\"#4A4A44\" : colorant\"#B8B7B0\"\nconst BRAND    = colorant\"#009E73\"  # Imprint palette position 1 — entry/goal accent\n\n# --- Maze parameters -----------------------------------------------------------\n# 8 rings keeps the puzzle within the spec's recommended 5-10 range. Sector count\n# doubles every 3 rings from the hub outward (3 -> 6 -> 12) so cell width stays\n# roughly consistent from the cramped center to the roomier rim, instead of a\n# fixed sector count leaving the innermost rings a dense cluster of narrow wedges.\nrings        = 8\nring_sectors = [3, 3, 3, 6, 6, 6, 12, 12]   # ring 1 = innermost (hub side)\ndifficulty   = \"medium\"\nentry_sector = 1                    # sector (on the outer ring) hosting the opening\nhub_radius   = 1.0\nring_width   = 1.0\nn_arc        = 12                   # points per drawn arc segment\n\nradii = [hub_radius + i * ring_width for i in 0:rings]  # radii[1] = hub boundary\ndθ    = [2π / ring_sectors[i] for i in 1:rings]          # sector angle, per ring\n\n# Node ids: 1 = center hub, then each ring's sectors packed consecutively.\nring_offset = vcat(0, cumsum(ring_sectors))              # ring_offset[i] = cells before ring i\nnode_id(ring, sector) = 1 + ring_offset[ring] + sector\n\npolar(r, θ) = Point2f(r * cos(θ), r * sin(θ))\n\n# --- Union-Find (disjoint set), used by the randomized-Kruskal maze carver ----\nfunction find_root(parent, x)\n    while parent[x] != x\n        parent[x] = parent[parent[x]]\n        x = parent[x]\n    end\n    return x\nend\n\nfunction union_cells!(parent, a, b)\n    ra, rb = find_root(parent, a), find_root(parent, b)\n    ra == rb && return false\n    parent[ra] = rb\n    return true\nend\n\n# --- Candidate connections between adjacent cells -----------------------------\n# Each entry is a wall between two cells; shape encodes how to draw it if the\n# connection stays closed: an :arc (fixed radius, spans an angle range) for\n# ring-to-ring boundaries, or a :line (fixed angle, spans a radius range) for\n# sector-to-sector boundaries.\nedges = NamedTuple{(:a, :b, :shape, :p1, :p2, :p3),Tuple{Int,Int,Symbol,Float64,Float64,Float64}}[]\n\n# Hub <-> innermost ring.\nfor s in 1:ring_sectors[1]\n    θ1, θ2 = (s - 1) * dθ[1], s * dθ[1]\n    push!(edges, (a = 1, b = node_id(1, s), shape = :arc, p1 = radii[1], p2 = θ1, p3 = θ2))\nend\n\n# Ring i <-> ring i+1. `ratio` is 1 when sector count stays flat, or 2 where it\n# doubles outward — each inner cell then borders exactly `ratio` outer cells.\nfor i in 1:(rings - 1)\n    ratio = ring_sectors[i + 1] ÷ ring_sectors[i]\n    for s in 1:ring_sectors[i], k in 1:ratio\n        s_out = (s - 1) * ratio + k\n        θ1, θ2 = (s_out - 1) * dθ[i + 1], s_out * dθ[i + 1]\n        push!(edges, (a = node_id(i, s), b = node_id(i + 1, s_out), shape = :arc, p1 = radii[i + 1], p2 = θ1, p3 = θ2))\n    end\nend\n\n# Sector <-> next sector within the same ring.\nfor i in 1:rings, s in 1:ring_sectors[i]\n    s2 = s % ring_sectors[i] + 1\n    θ = s * dθ[i]\n    push!(edges, (a = node_id(i, s), b = node_id(i, s2), shape = :line, p1 = θ, p2 = radii[i], p3 = radii[i + 1]))\nend\n\n# --- Carve the maze: randomized Kruskal spanning tree over the cell graph ----\n# A spanning tree connects every cell with exactly one path between any two —\n# guaranteeing exactly one solvable path from the entry to the center goal.\nn_nodes = 1 + sum(ring_sectors)\nparent  = collect(1:n_nodes)\nwalls   = similar(edges, 0)\nfor e in shuffle(edges)\n    union_cells!(parent, e.a, e.b) || push!(walls, e)\nend\n\n# --- Wall geometry: one polyline per closed connection, NaN-separated -------\nwall_pts = Point2f[]\nfor e in walls\n    if e.shape == :arc\n        for θ in range(e.p2, e.p3; length = n_arc)\n            push!(wall_pts, polar(e.p1, θ))\n        end\n    else\n        push!(wall_pts, polar(e.p2, e.p1))\n        push!(wall_pts, polar(e.p3, e.p1))\n    end\n    push!(wall_pts, Point2f(NaN, NaN))\nend\n\nouter_r       = radii[end]\nouter_sectors = ring_sectors[rings]\nfor s in 1:outer_sectors\n    if s != entry_sector\n        for θ in range((s - 1) * dθ[rings], s * dθ[rings]; length = n_arc)\n            push!(wall_pts, polar(outer_r, θ))\n        end\n        push!(wall_pts, Point2f(NaN, NaN))\n    end\nend\n\n# --- Figure --------------------------------------------------------------------\nfig = Figure(\n    size            = (1200, 1200),\n    fontsize        = 14,\n    backgroundcolor = PAGE_BG,\n)\n\ntitle_str = \"maze-circular · julia · makie · anyplot.ai\"\n\nax = Axis(\n    fig[1, 1];\n    title           = title_str,\n    titlesize       = 24,\n    titlecolor      = INK,\n    subtitle        = \"$(rings) rings · $(difficulty) difficulty · single solution\",\n    subtitlesize    = 15,\n    subtitlecolor   = INK_SOFT,\n    aspect          = DataAspect(),\n    backgroundcolor = PAGE_BG,\n)\nhidedecorations!(ax)\nhidespines!(ax)\n\nlines!(ax, wall_pts; color = INK, linewidth = 4.5)\n\n# --- Goal marker at the center --------------------------------------------------\npoly!(ax, Circle(Point2f(0, 0), hub_radius * 0.82); color = (BRAND, 0.12), strokewidth = 0)\nscatter!(ax, [Point2f(0, 0)]; marker = :star5, markersize = 30, color = BRAND, strokewidth = 0)\ntext!(ax, 0.0, -hub_radius * 0.55; text = \"GOAL\", align = (:center, :center), color = INK, fontsize = 14)\n\n# --- Entry marker on the outer boundary ----------------------------------------\nentry_angle = (entry_sector - 0.5) * dθ[rings]\nr_tail, r_tip = outer_r + 1.0, outer_r + 0.15\nx0, y0 = r_tail * cos(entry_angle), r_tail * sin(entry_angle)\nx1, y1 = r_tip * cos(entry_angle), r_tip * sin(entry_angle)\narrows!(ax, [x0], [y0], [x1 - x0], [y1 - y0]; color = BRAND, linewidth = 4.5, arrowsize = 22)\nlabel_x, label_y = (outer_r + 1.55) * cos(entry_angle), (outer_r + 1.55) * sin(entry_angle)\ntext!(ax, label_x, label_y; text = \"START\", align = (:center, :center), color = INK, fontsize = 14)\n\n# --- Content-hugging canvas limits ---------------------------------------------\n# Fit the view snugly around everything actually drawn (walls + entry marker +\n# labels) instead of padding every side uniformly — a uniform pad leaves extra\n# empty space opposite the one-sided START arrow, since that side needs less.\ncontent_x = Float64[x0, x1, label_x]\ncontent_y = Float64[y0, y1, label_y]\nfor p in wall_pts\n    isfinite(p[1]) || continue\n    push!(content_x, p[1])\n    push!(content_y, p[2])\nend\nlabel_margin = 0.55  # room for the START / GOAL glyphs beyond their anchor point\nxmin, xmax = minimum(content_x) - label_margin, maximum(content_x) + label_margin\nymin, ymax = minimum(content_y) - label_margin, maximum(content_y) + label_margin\nhalf = max(xmax - xmin, ymax - ymin) / 2\ncx, cy = (xmin + xmax) / 2, (ymin + ymax) / 2\nxlims!(ax, cx - half, cx + half)\nylims!(ax, cy - half, cy + half)\n\n# --- Save ------------------------------------------------------------------\nsave(\"plot-$(THEME).png\", fig; px_per_unit = 2)\n"}