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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +106 -2
- data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
- data/lib/zer0_image_generator/abc.rb +75 -0
- data/lib/zer0_image_generator/all.rb +57 -0
- data/lib/zer0_image_generator/claude/client.rb +291 -0
- data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
- data/lib/zer0_image_generator/cli.rb +318 -0
- data/lib/zer0_image_generator/config.rb +192 -0
- data/lib/zer0_image_generator/constants.rb +177 -0
- data/lib/zer0_image_generator/content.rb +379 -0
- data/lib/zer0_image_generator/engine.rb +21 -0
- data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
- data/lib/zer0_image_generator/freesvg/client.rb +467 -0
- data/lib/zer0_image_generator/http.rb +264 -0
- data/lib/zer0_image_generator/library.rb +201 -0
- data/lib/zer0_image_generator/logging.rb +236 -0
- data/lib/zer0_image_generator/preview_generator.py +1072 -100
- data/lib/zer0_image_generator/prompt.rb +48 -0
- data/lib/zer0_image_generator/providers/base.rb +152 -0
- data/lib/zer0_image_generator/providers/gemini.rb +60 -0
- data/lib/zer0_image_generator/providers/local.rb +90 -0
- data/lib/zer0_image_generator/providers/openai.rb +102 -0
- data/lib/zer0_image_generator/providers/stability.rb +64 -0
- data/lib/zer0_image_generator/providers/xai.rb +81 -0
- data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
- data/lib/zer0_image_generator/providers.rb +22 -0
- data/lib/zer0_image_generator/runner.rb +573 -0
- data/lib/zer0_image_generator/settings.rb +386 -0
- data/lib/zer0_image_generator/stats.rb +61 -0
- data/lib/zer0_image_generator/support/py_random.rb +189 -0
- data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
- data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
- data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
- data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
- data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
- data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
- data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
- data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
- data/lib/zer0_image_generator/svg/lint.rb +400 -0
- data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
- data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
- data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
- data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
- data/lib/zer0_image_generator/version.rb +1 -1
- 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
|