zer0-image-generator 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +106 -2
  4. data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
  5. data/lib/zer0_image_generator/abc.rb +75 -0
  6. data/lib/zer0_image_generator/all.rb +57 -0
  7. data/lib/zer0_image_generator/claude/client.rb +291 -0
  8. data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
  9. data/lib/zer0_image_generator/cli.rb +318 -0
  10. data/lib/zer0_image_generator/config.rb +192 -0
  11. data/lib/zer0_image_generator/constants.rb +177 -0
  12. data/lib/zer0_image_generator/content.rb +379 -0
  13. data/lib/zer0_image_generator/engine.rb +21 -0
  14. data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
  15. data/lib/zer0_image_generator/freesvg/client.rb +467 -0
  16. data/lib/zer0_image_generator/http.rb +264 -0
  17. data/lib/zer0_image_generator/library.rb +201 -0
  18. data/lib/zer0_image_generator/logging.rb +236 -0
  19. data/lib/zer0_image_generator/preview_generator.py +1072 -100
  20. data/lib/zer0_image_generator/prompt.rb +48 -0
  21. data/lib/zer0_image_generator/providers/base.rb +152 -0
  22. data/lib/zer0_image_generator/providers/gemini.rb +60 -0
  23. data/lib/zer0_image_generator/providers/local.rb +90 -0
  24. data/lib/zer0_image_generator/providers/openai.rb +102 -0
  25. data/lib/zer0_image_generator/providers/stability.rb +64 -0
  26. data/lib/zer0_image_generator/providers/xai.rb +81 -0
  27. data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
  28. data/lib/zer0_image_generator/providers.rb +22 -0
  29. data/lib/zer0_image_generator/runner.rb +573 -0
  30. data/lib/zer0_image_generator/settings.rb +386 -0
  31. data/lib/zer0_image_generator/stats.rb +61 -0
  32. data/lib/zer0_image_generator/support/py_random.rb +189 -0
  33. data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
  34. data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
  35. data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
  36. data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
  37. data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
  38. data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
  39. data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
  40. data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
  41. data/lib/zer0_image_generator/svg/lint.rb +400 -0
  42. data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
  43. data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
  44. data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
  45. data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
  46. data/lib/zer0_image_generator/version.rb +1 -1
  47. metadata +43 -2
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../support/py_random"
4
+
5
+ module Zer0ImageGenerator
6
+ module Svg
7
+ module Generators
8
+ # Faceted low-poly gradient field — port of `tools/svg/examples/lowpoly.py`.
9
+ #
10
+ # A jittered triangle grid (interior vertices perturbed, borders pinned so
11
+ # there are no gaps) painted with a vertical palette gradient, then each
12
+ # facet is brightened/darkened by smooth value noise so the surface reads
13
+ # as folded planes catching light, with an off-center radial "sun" glow.
14
+ # Each facet is stroked in its own fill to close the sub-pixel seams that
15
+ # float rounding leaves between triangles.
16
+ #
17
+ # The committed artwork under `docs/samples/lowpoly-*.svg` was produced by
18
+ # the Python original, so this port is pinned to byte-identical output:
19
+ # the sun/jitter draws come off `Support::PyRandom` in the same order,
20
+ # `math.hypot` is reproduced to the last ULP, and every colour channel is
21
+ # rounded the way CPython's `round()` rounds.
22
+ module Lowpoly
23
+ # vertical gradient stops top -> bottom, then [accent, shadow]
24
+ PALETTES = {
25
+ "dusk" => ["#f5a35c", "#e2565b", "#7a3355", "#2b2240", "#ffd27a", "#161227"].freeze,
26
+ "cyanic" => ["#bfe6e4", "#5bc0be", "#1c7293", "#0b1f3a", "#e8f1f2", "#06101f"].freeze,
27
+ "moss" => ["#e9edc9", "#a3c9a8", "#3e6b48", "#16241b", "#f2e9c9", "#0c1610"].freeze,
28
+ "ember" => ["#ffd166", "#f2542d", "#a11d33", "#2a0e1a", "#fff0c2", "#170610"].freeze,
29
+ }.freeze
30
+
31
+ DEFAULT_PALETTE = "dusk"
32
+
33
+ W = 1200
34
+ H = 800
35
+
36
+ HALF = Rational(1, 2)
37
+ private_constant :HALF
38
+
39
+ class << self
40
+ def value_noise(seed)
41
+ hash = lambda do |ix, iy|
42
+ n = (ix * 374_761_393) + (iy * 668_265_263) + (seed * 40_503)
43
+ n = (n ^ (n >> 13)) * 1_274_126_177
44
+ # `/ 0xFFFF` is true division in Python; fdiv keeps it a Float.
45
+ ((n ^ (n >> 16)) & 0xFFFF).fdiv(0xFFFF)
46
+ end
47
+ smooth = ->(t) { t * t * (3 - (2 * t)) }
48
+ lambda do |x, y|
49
+ ix = x.floor
50
+ iy = y.floor
51
+ fx = smooth.call(x - ix)
52
+ fy = smooth.call(y - iy)
53
+ a = hash.call(ix, iy)
54
+ b = hash.call(ix + 1, iy)
55
+ c = hash.call(ix, iy + 1)
56
+ d = hash.call(ix + 1, iy + 1)
57
+ ((a * (1 - fx)) + (b * fx)) * (1 - fy) + (((c * (1 - fx)) + (d * fx)) * fy)
58
+ end
59
+ end
60
+
61
+ # Returns the finished SVG document, newline-terminated.
62
+ def build(seed, palette, cols = 16, rows = 11)
63
+ rng = Support::PyRandom.new(seed)
64
+ noise = value_noise(seed)
65
+ stops = PALETTES.fetch(palette)
66
+ grad = stops[0, 4].map { |c| hex(c) }
67
+ accent = hex(stops[4])
68
+ shadow = hex(stops[5])
69
+ cw = W.fdiv(cols)
70
+ ch = H.fdiv(rows)
71
+
72
+ # Sun focus in the upper band, off-center by seed.
73
+ sx = rng.uniform(0.15, 0.5) * W
74
+ sy = rng.uniform(0.12, 0.32) * H
75
+ sun_r = rng.uniform(0.30, 0.42) * H
76
+
77
+ pts = {}
78
+ (0..rows).each do |r|
79
+ (0..cols).each do |c|
80
+ x = c * cw
81
+ y = r * ch
82
+ if c.positive? && c < cols && r.positive? && r < rows
83
+ x += rng.uniform(-0.42, 0.42) * cw
84
+ y += rng.uniform(-0.42, 0.42) * ch
85
+ end
86
+ pts[[c, r]] = [x, y]
87
+ end
88
+ end
89
+
90
+ parts = [
91
+ %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{W} #{H}" role="img" ) +
92
+ %(shape-rendering="geometricPrecision">),
93
+ "<title>Low-poly field, seed #{seed}, palette #{palette}</title>",
94
+ ]
95
+
96
+ facet = lambda do |a, b, c|
97
+ cx = (a[0] + b[0] + c[0]) / 3
98
+ cy = (a[1] + b[1] + c[1]) / 3
99
+ base = grad(grad, cy / H)
100
+ # value noise -> plane shading; +/- ~14%
101
+ shade = 0.86 + (0.28 * noise.call(cx / 190, cy / 190))
102
+ col = base.map { |v| v * shade }
103
+ # sun glow: blend toward accent within radius; lift shadow tint low
104
+ dist = hypot(cx - sx, cy - sy)
105
+ col = lerp(col, accent, ((1 - (dist / sun_r))**2) * 0.8) if dist < sun_r
106
+ col = lerp(col, shadow, (cy / H - 0.82) / 0.18 * 0.5) if cy > H * 0.82
107
+ p = "#{fmt(a[0], 1)},#{fmt(a[1], 1)} #{fmt(b[0], 1)},#{fmt(b[1], 1)} " \
108
+ "#{fmt(c[0], 1)},#{fmt(c[1], 1)}"
109
+ rgb = rgb(col)
110
+ %(<polygon points="#{p}" fill="#{rgb}" stroke="#{rgb}" stroke-width="1"/>)
111
+ end
112
+
113
+ rows.times do |r|
114
+ cols.times do |c|
115
+ p00 = pts[[c, r]]
116
+ p10 = pts[[c + 1, r]]
117
+ p01 = pts[[c, r + 1]]
118
+ p11 = pts[[c + 1, r + 1]]
119
+ # alternate the split diagonal so facets don't all lean one way
120
+ if (c + r).even?
121
+ parts << facet.call(p00, p10, p11)
122
+ parts << facet.call(p00, p11, p01)
123
+ else
124
+ parts << facet.call(p00, p10, p01)
125
+ parts << facet.call(p10, p11, p01)
126
+ end
127
+ end
128
+ end
129
+
130
+ parts << "</svg>"
131
+ parts.join("\n") + "\n"
132
+ end
133
+
134
+ private
135
+
136
+ def hex(c)
137
+ [1, 3, 5].map { |i| c[i, 2].to_i(16) }
138
+ end
139
+
140
+ def rgb(t)
141
+ "#" + t.map { |v| format("%02x", [0, [255, round_int(v)].min].max) }.join
142
+ end
143
+
144
+ def lerp(a, b, t)
145
+ Array.new(3) { |i| a[i] + ((b[i] - a[i]) * t) }
146
+ end
147
+
148
+ def grad(stops, t)
149
+ t = [0.0, [1.0, t].min].max
150
+ n = stops.length - 1
151
+ seg = [(t * n).to_i, n - 1].min
152
+ lerp(stops[seg], stops[seg + 1], (t * n) - seg)
153
+ end
154
+
155
+ # Python's `round(v)` (no ndigits) -> nearest int, round-half-to-even.
156
+ def round_int(value)
157
+ neg = value.negative?
158
+ scaled = value.abs.to_r
159
+ whole = scaled.floor
160
+ remainder = scaled - whole
161
+ whole += 1 if remainder > HALF || (remainder == HALF && whole.odd?)
162
+ neg ? -whole : whole
163
+ end
164
+
165
+ # A bit-exact port of CPython 3.8+ `math.hypot` (Hettinger's scaled
166
+ # 2Sum accumulation). Ruby's `Math.hypot` differs in the last ULP,
167
+ # which can flip a rounded colour channel and break byte-identity.
168
+ TWO_POW_27 = 134_217_729.0 # 2**27 + 1, the Veltkamp split constant
169
+ private_constant :TWO_POW_27
170
+
171
+ def two_prod(a, b)
172
+ prod = a * b
173
+ ah = (a * TWO_POW_27) - ((a * TWO_POW_27) - a)
174
+ al = a - ah
175
+ bh = (b * TWO_POW_27) - ((b * TWO_POW_27) - b)
176
+ bl = b - bh
177
+ err = (((ah * bh) - prod) + (ah * bl) + (al * bh)) + (al * bl)
178
+ [prod, err]
179
+ end
180
+
181
+ def hypot(dx, dy)
182
+ coords = [dx.abs, dy.abs]
183
+ max = coords.max
184
+ return max if max.zero? # avoids 0/0 when both are zero
185
+
186
+ _, max_e = Math.frexp(max)
187
+ scale = Math.ldexp(1.0, -max_e)
188
+ csum = 1.0
189
+ frac1 = 0.0
190
+ frac2 = 0.0
191
+ coords.each do |c|
192
+ x = c * scale
193
+ pr, err = two_prod(x, x)
194
+ s = csum + pr
195
+ frac2 += (csum - s) + pr
196
+ csum = s
197
+ frac1 += err
198
+ end
199
+ h = Math.sqrt((csum - 1.0) + (frac1 + frac2))
200
+ # one Newton step on the residual, matching CPython's correction
201
+ pr, err = two_prod(-h, h)
202
+ s = csum + pr
203
+ frac2 += (csum - s) + pr
204
+ csum = s
205
+ frac1 += err
206
+ x = (csum - 1.0) + (frac1 + frac2)
207
+ h += x / (2.0 * h)
208
+ h / scale
209
+ end
210
+
211
+ # Exact correctly-rounded fixed-point, matching Python's `f"{x:.Nf}"`.
212
+ def fmt(value, places)
213
+ negative = value.negative? || (value.zero? && (1.0 / value).negative?)
214
+ scaled = value.abs.to_r * (10**places)
215
+ whole = scaled.floor
216
+ remainder = scaled - whole
217
+ whole += 1 if remainder > HALF || (remainder == HALF && whole.odd?)
218
+
219
+ digits = whole.to_s.rjust(places + 1, "0")
220
+ text = places.zero? ? digits : "#{digits[0...-places]}.#{digits[-places..]}"
221
+ negative ? "-#{text}" : text
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
228
+
229
+ if __FILE__ == $PROGRAM_NAME
230
+ require "fileutils"
231
+
232
+ mod = Zer0ImageGenerator::Svg::Generators::Lowpoly
233
+ opts = { seed: 1, palette: mod::DEFAULT_PALETTE, cols: 16, rows: 11,
234
+ out: ".sweep/lowpoly.svg" }
235
+ argv = ARGV.dup
236
+ until argv.empty?
237
+ flag = argv.shift
238
+ val = argv.shift
239
+ case flag
240
+ when "--seed" then opts[:seed] = Integer(val)
241
+ when "--palette" then opts[:palette] = val
242
+ when "--cols" then opts[:cols] = Integer(val)
243
+ when "--rows" then opts[:rows] = Integer(val)
244
+ when "--out" then opts[:out] = val
245
+ else raise ArgumentError, "unknown flag #{flag.inspect}"
246
+ end
247
+ end
248
+
249
+ dir = File.dirname(opts[:out])
250
+ FileUtils.mkdir_p(dir.empty? ? "." : dir)
251
+ svg = mod.build(opts[:seed], opts[:palette], opts[:cols], opts[:rows])
252
+ File.write(opts[:out], svg)
253
+ puts opts[:out]
254
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../support/py_random"
4
+
5
+ module Zer0ImageGenerator
6
+ module Svg
7
+ module Generators
8
+ # L-system botanical generator — port of `tools/svg/examples/lsystem.py`.
9
+ #
10
+ # Expand an L-system string, turtle-interpret it into branch segments and
11
+ # growth tips, shade by branch depth (thick dark trunk -> thin bright
12
+ # twigs), and drop foliage at the tips. A seeded per-turn angle jitter
13
+ # keeps the plant organic; four rulesets give four species, four palettes
14
+ # re-skin without touching geometry.
15
+ #
16
+ # The committed artwork under `docs/samples/lsystem-*.svg` was produced by
17
+ # the Python original, so this port is pinned to byte-identical output:
18
+ # every draw comes off `Support::PyRandom` in the same order, the
19
+ # depth-sort is stable exactly as CPython's `sorted` is, and every
20
+ # interpolated number reproduces the Python f-string / `round()` text.
21
+ module Lsystem
22
+ # [bg, trunk, branch, leaf, bloom, sky-glow]
23
+ PALETTES = {
24
+ "verdant" => ["#0e1a12", "#4a3327", "#3e6b48", "#8bbf6f", "#f2e9c9", "#1b3326"].freeze,
25
+ "autumn" => ["#1a1410", "#4a2f20", "#8a4b2f", "#d98324", "#f2c14e", "#2a1d14"].freeze,
26
+ "sakura" => ["#171320", "#3d2b3d", "#5a4466", "#c98bb9", "#f7d6e6", "#241b2e"].freeze,
27
+ "frost" => ["#0b1420", "#2a3b4a", "#3e6b7a", "#8bbfd6", "#e8f1f2", "#152532"].freeze,
28
+ }.freeze
29
+
30
+ DEFAULT_PALETTE = "verdant"
31
+
32
+ # axiom, rules, base angle (deg), iterations, step length
33
+ RULESETS = {
34
+ "bush" => ["X", { "X" => "F+[[X]-X]-F[-FX]+X", "F" => "FF" }.freeze, 22.5, 4, 13.0].freeze,
35
+ "fern" => ["X", { "X" => "F-[[X]+X]+F[+FX]-X", "F" => "FF" }.freeze, 20.0, 4, 13.0].freeze,
36
+ "tree" => ["F", { "F" => "FF+[+F-F-F]-[-F+F+F]" }.freeze, 25.0, 4, 9.0].freeze,
37
+ "weed" => ["F", { "F" => "F[+F]F[-F][F]" }.freeze, 22.0, 4, 8.0].freeze,
38
+ }.freeze
39
+
40
+ DEFAULT_SPECIES = "bush"
41
+
42
+ W = 900
43
+ H = 1100
44
+ M = 90
45
+
46
+ HALF = Rational(1, 2)
47
+ private_constant :HALF
48
+
49
+ # `math.radians(x)` is `x * (pi/180.0)` with the constant folded first;
50
+ # replicate that association so the angle matches to the last ULP.
51
+ DEG2RAD = Math::PI / 180.0
52
+ private_constant :DEG2RAD
53
+
54
+ class << self
55
+ def expand(axiom, rules, iters)
56
+ s = axiom
57
+ iters.times { s = s.each_char.map { |c| rules.fetch(c, c) }.join }
58
+ s
59
+ end
60
+
61
+ def interpret(s, angle, step, jitter, rng)
62
+ x = 0.0
63
+ y = 0.0
64
+ a = -90.0
65
+ stack = []
66
+ segs = []
67
+ tips = []
68
+ s.each_char do |c|
69
+ case c
70
+ when "F"
71
+ na = a + rng.uniform(-jitter, jitter)
72
+ nx = x + (step * Math.cos(na * DEG2RAD))
73
+ ny = y + (step * Math.sin(na * DEG2RAD))
74
+ segs << [x, y, nx, ny, stack.length]
75
+ x = nx
76
+ y = ny
77
+ a = na
78
+ when "+"
79
+ a += angle + rng.uniform(-jitter, jitter)
80
+ when "-"
81
+ a -= angle + rng.uniform(-jitter, jitter)
82
+ when "["
83
+ stack << [x, y, a]
84
+ when "]"
85
+ tips << [x, y, stack.length]
86
+ x, y, a = stack.pop
87
+ when "X"
88
+ tips << [x, y, stack.length]
89
+ end
90
+ end
91
+ [segs, tips]
92
+ end
93
+
94
+ # Returns the finished SVG document, newline-terminated.
95
+ def build(seed, species, palette, iters_override = nil)
96
+ rng = Support::PyRandom.new(seed)
97
+ axiom, rules, angle, iters, step = RULESETS.fetch(species)
98
+ # Python `if iters_override:` is falsy for both nil AND 0, so a
99
+ # `--iters 0` keeps the ruleset default just as the original does.
100
+ iters = iters_override if iters_override && !iters_override.zero?
101
+ pal = PALETTES.fetch(palette)
102
+ jitter = 3.0 + (rng.random * 5.0)
103
+ segs, tips = interpret(expand(axiom, rules, iters), angle, step, jitter, rng)
104
+ segs = [[0, 0, 0, -10, 0]] if segs.empty?
105
+
106
+ xs = segs.flat_map { |s| [s[0], s[2]] }
107
+ ys = segs.flat_map { |s| [s[1], s[3]] }
108
+ minx = xs.min
109
+ maxx = xs.max
110
+ miny = ys.min
111
+ maxy = ys.max
112
+ span_x = [maxx - minx, 1e-6].max
113
+ span_y = [maxy - miny, 1e-6].max
114
+ # Python `/` is true division throughout; fdiv guards the fallback
115
+ # path where a span can still be an Integer.
116
+ scale = [(W - (2 * M)).fdiv(span_x), (H - (2 * M)).fdiv(span_y)].min
117
+ ox = ((W - (span_x * scale)) / 2) - (minx * scale)
118
+ oy = H - M - (maxy * scale) # base sits near the bottom margin
119
+
120
+ tx = ->(x) { round_repr(ox + (x * scale), 1) }
121
+ ty = ->(y) { round_repr(oy + (y * scale), 1) }
122
+
123
+ maxd = segs.map { |s| s[4] }.max || 1
124
+ parts = [
125
+ %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{W} #{H}" role="img" ) +
126
+ %(stroke-linecap="round">),
127
+ "<title>L-system #{species}, seed #{seed}, palette #{palette}</title>",
128
+ %(<rect width="#{W}" height="#{H}" fill="#{pal[0]}"/>),
129
+ %(<ellipse cx="#{W / 2}" cy="#{(H * 0.34).to_i}" rx="#{W / 2}" ry="#{(H * 0.34).to_i}" ) +
130
+ %(fill="#{pal[5]}" opacity="0.6"/>),
131
+ %(<ellipse cx="#{W / 2}" cy="#{H - 40}" rx="#{(W * 0.42).to_i}" ry="34" ) +
132
+ %(fill="#{pal[1]}" opacity="0.5"/>),
133
+ ]
134
+
135
+ # Trunk-first (shallow depth first) so deep twigs don't occlude it.
136
+ # CPython's `sorted` is stable; pairing each seg with its index and
137
+ # sorting by [depth, index] reproduces that order (Ruby's sort_by
138
+ # is not stable on its own).
139
+ stable_by_depth(segs).each do |x1, y1, x2, y2, d|
140
+ t = d.fdiv(maxd)
141
+ color = t < 0.28 ? pal[1] : (t < 0.62 ? pal[2] : pal[3])
142
+ w = round_repr([0.6, 11 * ((1 - t)**1.7)].max, 2)
143
+ parts << %(<line x1="#{tx.call(x1)}" y1="#{ty.call(y1)}" ) +
144
+ %(x2="#{tx.call(x2)}" y2="#{ty.call(y2)}" ) +
145
+ %(stroke="#{color}" stroke-width="#{w}"/>)
146
+ end
147
+
148
+ # Foliage at tips: most leaves, a seeded fraction blooms.
149
+ tips.each do |x, y, d|
150
+ next if d < 2
151
+
152
+ r = round_val(rng.uniform(3.5, 7.0) * [scale, 1.0].min * 3, 1)
153
+ if rng.random < 0.16
154
+ parts << %(<circle cx="#{tx.call(x)}" cy="#{ty.call(y)}" r="#{fmt(r * 1.1, 1)}" ) +
155
+ %(fill="#{pal[4]}" opacity="0.95"/>)
156
+ else
157
+ parts << %(<circle cx="#{tx.call(x)}" cy="#{ty.call(y)}" r="#{fmt(r, 1)}" ) +
158
+ %(fill="#{pal[3]}" opacity="0.75"/>)
159
+ end
160
+ end
161
+
162
+ parts << "</svg>"
163
+ parts.join("\n") + "\n"
164
+ end
165
+
166
+ private
167
+
168
+ def stable_by_depth(segs)
169
+ segs.each_with_index.sort_by { |s, i| [s[4], i] }.map(&:first)
170
+ end
171
+
172
+ # Python's `round(x, n)` as a Float (round-half-to-even on the exact
173
+ # binary value); equal to `float(f"{x:.Nf}")`.
174
+ def round_val(value, places)
175
+ Float(fmt(value, places))
176
+ end
177
+
178
+ # Bare `{round(x, n)}` interpolation prints the rounded double's
179
+ # shortest round-tripping repr. Ruby's Float#to_s matches CPython's
180
+ # repr for this magnitude range (coordinates < 1e4, stroke widths).
181
+ def round_repr(value, places)
182
+ round_val(value, places).to_s
183
+ end
184
+
185
+ # Exact correctly-rounded fixed-point, matching Python's `f"{x:.Nf}"`.
186
+ def fmt(value, places)
187
+ negative = value.negative? || (value.zero? && (1.0 / value).negative?)
188
+ scaled = value.abs.to_r * (10**places)
189
+ whole = scaled.floor
190
+ remainder = scaled - whole
191
+ whole += 1 if remainder > HALF || (remainder == HALF && whole.odd?)
192
+
193
+ digits = whole.to_s.rjust(places + 1, "0")
194
+ text = places.zero? ? digits : "#{digits[0...-places]}.#{digits[-places..]}"
195
+ negative ? "-#{text}" : text
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+
203
+ if __FILE__ == $PROGRAM_NAME
204
+ require "fileutils"
205
+
206
+ mod = Zer0ImageGenerator::Svg::Generators::Lsystem
207
+ opts = { seed: 1, species: mod::DEFAULT_SPECIES, palette: mod::DEFAULT_PALETTE,
208
+ iters: nil, out: ".sweep/lsystem.svg" }
209
+ argv = ARGV.dup
210
+ until argv.empty?
211
+ flag = argv.shift
212
+ val = argv.shift
213
+ case flag
214
+ when "--seed" then opts[:seed] = Integer(val)
215
+ when "--species" then opts[:species] = val
216
+ when "--palette" then opts[:palette] = val
217
+ when "--iters" then opts[:iters] = Integer(val)
218
+ when "--out" then opts[:out] = val
219
+ else raise ArgumentError, "unknown flag #{flag.inspect}"
220
+ end
221
+ end
222
+
223
+ dir = File.dirname(opts[:out])
224
+ FileUtils.mkdir_p(dir.empty? ? "." : dir)
225
+ svg = mod.build(opts[:seed], opts[:species], opts[:palette], opts[:iters])
226
+ File.write(opts[:out], svg)
227
+ puts opts[:out]
228
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../support/py_random"
4
+
5
+ module Zer0ImageGenerator
6
+ module Svg
7
+ module Generators
8
+ # Radial-symmetry mandala — port of `tools/svg/examples/mandala.py`.
9
+ #
10
+ # Concentric rings of a motif repeated with N-fold rotational symmetry.
11
+ # Each ring's motif, radius, count, and palette role is seeded; one ring
12
+ # is deliberately offset in phase so the piece isn't a perfect test
13
+ # pattern (house rule: break symmetry in one place). Motifs are defined
14
+ # once in <defs> and instanced with <use> + rotate, so a dense mandala
15
+ # stays a few KB.
16
+ #
17
+ # The committed artwork under `docs/samples/mandala-*.svg` was produced by
18
+ # the Python original, so this port is pinned to byte-identical output:
19
+ # every draw comes off `Support::PyRandom` in the same order, and every
20
+ # interpolated number is formatted to the same width the f-strings used.
21
+ module Mandala
22
+ # [bg, ring-a, ring-b, accent, highlight]
23
+ PALETTES = {
24
+ "jewel" => ["#120a24", "#3d2c8d", "#00a8a8", "#f25f5c", "#ffe066"].freeze,
25
+ "solar" => ["#1a1005", "#7a3b12", "#e08a1e", "#f2c14e", "#fff3d6"].freeze,
26
+ "ocean" => ["#04121f", "#0b3a5b", "#1c88a8", "#5bc0be", "#e8f1f2"].freeze,
27
+ "rose" => ["#1a0f16", "#5a2244", "#a83e6f", "#e58fb0", "#f7e0e8"].freeze,
28
+ }.freeze
29
+
30
+ DEFAULT_PALETTE = "jewel"
31
+
32
+ SIZE = 800
33
+ R_MAX = 356
34
+
35
+ KINDS = ["petal", "diamond", "dot", "spoke"].freeze
36
+ FOLDS = [12, 16, 18, 20, 24].freeze
37
+
38
+ HALF = Rational(1, 2)
39
+ private_constant :HALF
40
+
41
+ class << self
42
+ # Returns the finished SVG document, newline-terminated.
43
+ def build(seed, palette = DEFAULT_PALETTE)
44
+ rng = Support::PyRandom.new(seed)
45
+ pal = PALETTES.fetch(palette)
46
+ # Python's `S / 2` is true division, so the center lands in the
47
+ # markup as "400.0", not "400" — keep it a Float.
48
+ center = SIZE / 2.0
49
+ defs = []
50
+ layers = []
51
+
52
+ fold = rng.choice(FOLDS)
53
+ bands = rng.randint(5, 7)
54
+ gap = R_MAX / (bands + 1).to_f
55
+ radii = Array.new(bands) { |i| gap * (i + 1) } # even bands, no dead space
56
+ quirk = rng.randrange(bands) # the one band that breaks phase
57
+
58
+ parts = [
59
+ %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{SIZE} #{SIZE}" role="img">),
60
+ "<title>Mandala, seed #{seed}, #{fold}-fold, palette #{palette}</title>",
61
+ %(<rect width="#{SIZE}" height="#{SIZE}" fill="#{pal[0]}"/>),
62
+ %(<circle cx="#{center}" cy="#{center}" r="#{R_MAX + 22}" fill="none" ) +
63
+ %(stroke="#{pal[1]}" stroke-width="3"/>),
64
+ %(<circle cx="#{center}" cy="#{center}" r="#{R_MAX + 12}" fill="none" ) +
65
+ %(stroke="#{pal[3]}" stroke-width="1.5" opacity="0.7"/>),
66
+ ]
67
+
68
+ radii.each_with_index do |r, i|
69
+ kind = rng.choice(KINDS)
70
+ count = i.even? ? fold : fold * 2 # denser alternating bands
71
+ color = pal[(i % 3) + 1]
72
+ stroke = rng.random < 0.6 ? pal[4] : "none"
73
+ h = gap * 0.5 * (kind == "dot" ? 0.5 : 0.95)
74
+ w = gap * (%w[petal diamond].include?(kind) ? 0.3 : 0.22)
75
+ mid = "m#{i}"
76
+ defs << motif_def(mid, kind, h, w, color, stroke)
77
+ phase = i == quirk ? 180.0 / count : 0.0
78
+ uses = [%(<g transform="translate(#{center} #{center})">)]
79
+ count.times do |k|
80
+ ang = phase + (k * 360.0 / count)
81
+ uses << %(<use href="##{mid}" transform="rotate(#{fmt(ang, 2)}) ) +
82
+ %(translate(0 #{fmt(-r, 1)})"/>)
83
+ end
84
+ uses << "</g>"
85
+ layers << uses.join("\n")
86
+ end
87
+
88
+ # Center rosette: a small ring of petals, then concentric discs
89
+ defs << motif_def("mc", "petal", gap * 0.55, gap * 0.22, pal[3], pal[4])
90
+ rosette = [%(<g transform="translate(#{center} #{center})">)]
91
+ fold.times do |k|
92
+ rosette << %(<use href="#mc" transform="rotate(#{fmt(k * 360.0 / fold, 2)}) ) +
93
+ %(translate(0 #{fmt(-gap * 0.6, 1)})"/>)
94
+ end
95
+ rosette << "</g>"
96
+ layers << rosette.join("\n")
97
+ layers << %(<circle cx="#{center}" cy="#{center}" r="#{fmt(gap * 0.42, 1)}" fill="#{pal[3]}"/>)
98
+ layers << %(<circle cx="#{center}" cy="#{center}" r="#{fmt(gap * 0.24, 1)}" fill="#{pal[4]}"/>)
99
+ layers << %(<circle cx="#{center}" cy="#{center}" r="#{fmt(gap * 0.09, 1)}" fill="#{pal[0]}"/>)
100
+
101
+ parts << "<defs>\n#{defs.join("\n")}\n</defs>"
102
+ parts.concat(layers)
103
+ parts << "</svg>"
104
+ "#{parts.join("\n")}\n"
105
+ end
106
+
107
+ private
108
+
109
+ # Motif centered at the local origin, long axis radial (±y).
110
+ #
111
+ # `sw` collapses to the empty string when there is no stroke, which
112
+ # leaves a double space in the markup. The samples were generated
113
+ # that way, so the spacing is part of the contract, not a typo.
114
+ def motif_def(mid, kind, h, w, color, stroke)
115
+ sw = stroke == "none" ? "" : %(stroke="#{stroke}" stroke-width="1.5")
116
+ case kind
117
+ when "petal"
118
+ %(<ellipse id="#{mid}" rx="#{fmt(w, 1)}" ry="#{fmt(h, 1)}" ) +
119
+ %(fill="#{color}" #{sw} opacity="0.92"/>)
120
+ when "diamond"
121
+ %(<path id="#{mid}" d="M0 #{fmt(-h, 1)} L #{fmt(w, 1)} 0 ) +
122
+ %(L 0 #{fmt(h, 1)} L #{fmt(-w, 1)} 0 Z" ) +
123
+ %(fill="#{color}" #{sw} opacity="0.92"/>)
124
+ when "dot"
125
+ %(<circle id="#{mid}" r="#{fmt([3.0, w * 0.8].max, 1)}" fill="#{color}" #{sw}/>)
126
+ else
127
+ # spoke: a short radial tick, decorative between bands
128
+ %(<path id="#{mid}" d="M0 #{fmt(-h, 1)} L 0 #{fmt(h, 1)}" stroke="#{color}" ) +
129
+ %(stroke-width="#{fmt([1.5, w * 0.35].max, 1)}" stroke-linecap="round"/>)
130
+ end
131
+ end
132
+
133
+ # Python's `f"{x:.1f}"` correctly rounds the *exact* binary value,
134
+ # half-to-even. Ruby's `format("%.1f", x)` and `Float#round` both
135
+ # round the shortest decimal representation instead, half-away —
136
+ # `13.35` prints "13.3" in Python and "13.4" in Ruby, which is enough
137
+ # to desync a band of motifs from the committed samples. Rational
138
+ # arithmetic gives the exact value, so the rounding decision is made
139
+ # on the same number Python sees.
140
+ def fmt(value, places)
141
+ negative = value.negative? || (value.zero? && (1.0 / value).negative?)
142
+ scaled = value.abs.to_r * (10**places)
143
+ whole = scaled.floor
144
+ remainder = scaled - whole
145
+ whole += 1 if remainder > HALF || (remainder == HALF && whole.odd?)
146
+
147
+ digits = whole.to_s.rjust(places + 1, "0")
148
+ text = places.zero? ? digits : "#{digits[0...-places]}.#{digits[-places..]}"
149
+ negative ? "-#{text}" : text
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end