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,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "local_renderer" # render_local_svg + the shared seed_for/palette/composition scheme
|
|
6
|
+
require_relative "sanitizer" # sanitize_svg + SvgError — the REAL sanitizer, never a copy
|
|
7
|
+
require_relative "rasterizer" # which() for montage discovery; rasterize_svg for the full sweep
|
|
8
|
+
|
|
9
|
+
module Zer0ImageGenerator
|
|
10
|
+
module Svg
|
|
11
|
+
# Parametric adapter over the engine's OWN `local` banner template — the
|
|
12
|
+
# Ruby port of tools/svg/banner-seed.py, plus tools/svg/svg-sweep.sh's
|
|
13
|
+
# contact-sheet stitching.
|
|
14
|
+
#
|
|
15
|
+
# The adapter is deliberately thin: a sweep must judge the REAL `local`
|
|
16
|
+
# output, so it renders through LocalRenderer.render_local_svg and cleans
|
|
17
|
+
# through the real Sanitizer.sanitize_svg — the exact path LocalProvider
|
|
18
|
+
# ships (render_local_svg(slug, seed_for(slug)) -> sanitize_svg). It never
|
|
19
|
+
# reimplements either, so a change to the template is seen on true output
|
|
20
|
+
# instead of a lookalike.
|
|
21
|
+
#
|
|
22
|
+
# BYTE-IDENTITY (see test/ruby/test_banner_seed.rb):
|
|
23
|
+
# * The PRE-sanitize render is byte-identical to the Python adapter's —
|
|
24
|
+
# render_local_svg is a hard determinism contract (seeded LCG only).
|
|
25
|
+
# * The FINAL sanitized string is the SAME DOM but NOT byte-identical: the
|
|
26
|
+
# ported Sanitizer serializes via REXML (single quotes, alphabetized
|
|
27
|
+
# attributes, spaced self-closing tags) where Python uses ElementTree.
|
|
28
|
+
# That serializer difference is owned and documented by the sanitizer
|
|
29
|
+
# slice; this adapter inherits it. The test proves DOM byte-identity by
|
|
30
|
+
# canonicalizing both outputs through REXML.
|
|
31
|
+
module BannerSeed
|
|
32
|
+
# argparse defaults from banner-seed.py.
|
|
33
|
+
DEFAULT_SEED = 1
|
|
34
|
+
DEFAULT_OUT = ".sweep/banner.svg"
|
|
35
|
+
|
|
36
|
+
# OptionParser token for a base-10 integer flag (see main). A bare token
|
|
37
|
+
# object is all OptionParser#accept needs as the type key.
|
|
38
|
+
Base10Int = Object.new
|
|
39
|
+
|
|
40
|
+
# svg-sweep.sh's `montage` knobs, verbatim: a dark contact sheet, filename
|
|
41
|
+
# labels, fixed 400px cells with 8px gutters. The tile column count is
|
|
42
|
+
# per-run (ceil(sqrt(N))), so it is computed, not stored here.
|
|
43
|
+
MONTAGE_LABEL = "%f"
|
|
44
|
+
MONTAGE_BACKGROUND = "#111"
|
|
45
|
+
MONTAGE_FILL = "#eee"
|
|
46
|
+
MONTAGE_GEOMETRY = "400x400+8+8"
|
|
47
|
+
|
|
48
|
+
# Raised when a contact sheet is requested but ImageMagick's `montage` is
|
|
49
|
+
# not on PATH. svg-sweep.sh exits 127 for this; as a library we surface it
|
|
50
|
+
# as an explicit, actionable error rather than an exit code. ImageMagick
|
|
51
|
+
# is an OPTIONAL dependency — everything else here runs without it.
|
|
52
|
+
class MontageMissing < StandardError; end
|
|
53
|
+
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
# seed_for lives on LocalRenderer (the shared seed -> palette/composition
|
|
57
|
+
# contract also used by the claude provider). Re-exposed so the studio and
|
|
58
|
+
# CLI resolve slugs without reaching across modules. zlib.crc32 of the
|
|
59
|
+
# UTF-8 bytes; matches pg.seed_for exactly.
|
|
60
|
+
def seed_for(slug)
|
|
61
|
+
LocalRenderer.seed_for(slug)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# banner-seed.py's (title, seed) rule. A non-empty --slug wins and
|
|
65
|
+
# reproduces that post's banner (title = slug, seed derived as the engine
|
|
66
|
+
# derives it); otherwise a bare --seed drives the palette/composition
|
|
67
|
+
# scheme directly under a synthetic "banner-seed-N" title. Returns
|
|
68
|
+
# [title, seed]. Empty-string slug is falsy exactly as `if args.slug:` is.
|
|
69
|
+
def resolve(seed: DEFAULT_SEED, slug: "")
|
|
70
|
+
if slug.nil? || slug.empty?
|
|
71
|
+
["banner-seed-#{seed}", seed]
|
|
72
|
+
else
|
|
73
|
+
[slug, seed_for(slug)]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Core operation, == pg.sanitize_svg(pg.render_local_svg(title, seed)).
|
|
78
|
+
# Returns [svg_text, notes]; notes is the sanitizer's warnings list.
|
|
79
|
+
def generate(seed: DEFAULT_SEED, slug: "")
|
|
80
|
+
title, resolved = resolve(seed: seed, slug: slug)
|
|
81
|
+
Sanitizer.sanitize_svg(LocalRenderer.render_local_svg(title, resolved))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Just the sanitized SVG string — the studio's common case. Notes are
|
|
85
|
+
# dropped; for the local template they are always empty (it renders SVG
|
|
86
|
+
# the sanitizer accepts untouched). Callers needing notes use #generate.
|
|
87
|
+
def svg(seed: DEFAULT_SEED, slug: "")
|
|
88
|
+
generate(seed: seed, slug: slug).first
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Rails "SVG studio" entry point: hand back one result per seed with no
|
|
92
|
+
# shelling out. Each entry is { seed:, title:, svg:, notes: }. Accepts any
|
|
93
|
+
# Enumerable of seeds (a form may request an arbitrary set, not a range).
|
|
94
|
+
def render_many(seeds)
|
|
95
|
+
seeds.map do |seed|
|
|
96
|
+
svg_text, notes = generate(seed: seed)
|
|
97
|
+
{ seed: seed, title: "banner-seed-#{seed}", svg: svg_text, notes: notes }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# svg-sweep.sh's sweep shape: seeds `first .. first+count-1` (the bash
|
|
102
|
+
# sweeps 1..N). Returns the same { seed:, title:, svg:, notes: } records.
|
|
103
|
+
def sweep(count, first: 1)
|
|
104
|
+
raise ArgumentError, "count must be non-negative" if count.negative?
|
|
105
|
+
|
|
106
|
+
render_many(first...(first + count))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# File-writing half of banner-seed.py: make the parent dir, write UTF-8,
|
|
110
|
+
# emit sanitizer notes to `err`. Returns the out path (what the CLI prints
|
|
111
|
+
# to stdout). Separate from #generate so the studio never touches disk.
|
|
112
|
+
def write(out, seed: DEFAULT_SEED, slug: "", err: $stderr)
|
|
113
|
+
svg_text, notes = generate(seed: seed, slug: slug)
|
|
114
|
+
notes.each { |note| err.puts("sanitizer: #{note}") }
|
|
115
|
+
# os.makedirs(dirname(out) or ".", exist_ok=True): dirname("banner.svg")
|
|
116
|
+
# is "" -> ".", and mkdir_p(".") is a harmless no-op.
|
|
117
|
+
dir = File.dirname(out)
|
|
118
|
+
FileUtils.mkdir_p(dir.empty? ? "." : dir)
|
|
119
|
+
# The SVG string is UTF-8; write its bytes as-is (Python opens utf-8).
|
|
120
|
+
File.write(out, svg_text)
|
|
121
|
+
out
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# CLI port of banner-seed.py's main(): parse --seed/--slug/--out, write the
|
|
125
|
+
# file, print the out path to `out`. Returns a process exit code (0 on
|
|
126
|
+
# success, 2 on a bad argument, mirroring argparse). Exposed so a bin/ shim
|
|
127
|
+
# or a test can drive the exact documented CLI surface without a subprocess.
|
|
128
|
+
def main(argv, out: $stdout, err: $stderr)
|
|
129
|
+
opts = { seed: DEFAULT_SEED, slug: "", out: DEFAULT_OUT }
|
|
130
|
+
parser = OptionParser.new do |p|
|
|
131
|
+
# Base-10, like Python argparse's `type=int` (int()). OptionParser's
|
|
132
|
+
# built-in Integer accepts 0x/0o/0b prefixes, so `--seed 010` would be
|
|
133
|
+
# octal 8 in Ruby but decimal 10 in Python — a different banner.
|
|
134
|
+
p.accept(Base10Int, /\A\s*[-+]?\d+\s*\z/) { |s| Integer(s.strip, 10) }
|
|
135
|
+
p.on("--seed N", Base10Int) { |v| opts[:seed] = v }
|
|
136
|
+
p.on("--slug SLUG", String, "derive the seed from a post slug, as the engine does") do |v|
|
|
137
|
+
opts[:slug] = v
|
|
138
|
+
end
|
|
139
|
+
p.on("--out PATH", String) { |v| opts[:out] = v }
|
|
140
|
+
end
|
|
141
|
+
begin
|
|
142
|
+
parser.parse(argv)
|
|
143
|
+
rescue OptionParser::ParseError => exc
|
|
144
|
+
# argparse writes usage + error to stderr and exits 2. Mirror the exit
|
|
145
|
+
# code with an explicit message; never swallow the parse failure.
|
|
146
|
+
err.puts(exc.message)
|
|
147
|
+
return 2
|
|
148
|
+
end
|
|
149
|
+
out.puts(write(opts[:out], seed: opts[:seed], slug: opts[:slug], err: err))
|
|
150
|
+
0
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# ---- svg-sweep.sh contact-sheet logic --------------------------------
|
|
154
|
+
|
|
155
|
+
# ceil(sqrt(N)) — svg-sweep.sh's column count. Own method so the sweep and
|
|
156
|
+
# any caller building a montage agree on layout. Math.sqrt(N).ceil matches
|
|
157
|
+
# Python's math.ceil(math.sqrt(N)) (both IEEE-754, exact on perfect squares).
|
|
158
|
+
def tile_columns(count)
|
|
159
|
+
Math.sqrt(count).ceil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# `command -v montage` equivalent, via the ported Rasterizer.which (itself
|
|
163
|
+
# shutil.which-equivalent and memoized). nil when ImageMagick is absent.
|
|
164
|
+
def montage_path
|
|
165
|
+
Rasterizer.which("montage")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def montage_available?
|
|
169
|
+
!montage_path.nil?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The exact `montage` argv from svg-sweep.sh, as an array (no shell). Built
|
|
173
|
+
# separately from #contact_sheet so the flag set can be asserted without
|
|
174
|
+
# ImageMagick installed. `bin` defaults to the plain name for that offline
|
|
175
|
+
# inspection; #contact_sheet passes the resolved absolute path.
|
|
176
|
+
def montage_argv(png_paths, out:, columns: nil, bin: "montage")
|
|
177
|
+
cols = columns || tile_columns(png_paths.length)
|
|
178
|
+
[bin, "-label", MONTAGE_LABEL,
|
|
179
|
+
"-background", MONTAGE_BACKGROUND, "-fill", MONTAGE_FILL,
|
|
180
|
+
"-tile", "#{cols}x", "-geometry", MONTAGE_GEOMETRY,
|
|
181
|
+
*png_paths.map(&:to_s), out.to_s]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Stitch PNGs into one labelled contact sheet via ImageMagick `montage`.
|
|
185
|
+
# ImageMagick is optional: when `montage` is absent this raises
|
|
186
|
+
# MontageMissing with an actionable message instead of failing obscurely.
|
|
187
|
+
# Returns the output path on success.
|
|
188
|
+
def contact_sheet(png_paths, out:, columns: nil)
|
|
189
|
+
montage = montage_path
|
|
190
|
+
unless montage
|
|
191
|
+
raise MontageMissing,
|
|
192
|
+
"ImageMagick 'montage' not found on PATH; install ImageMagick " \
|
|
193
|
+
"(e.g. `brew install imagemagick`) to stitch contact sheets"
|
|
194
|
+
end
|
|
195
|
+
raise ArgumentError, "no PNGs to montage" if png_paths.empty?
|
|
196
|
+
|
|
197
|
+
cmd = montage_argv(png_paths, out: out, columns: columns, bin: montage)
|
|
198
|
+
# system returns false on non-zero exit; surface it rather than pretend
|
|
199
|
+
# the sheet was written.
|
|
200
|
+
unless system(*cmd)
|
|
201
|
+
raise "montage exited #{$?.exitstatus} building #{out}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
out
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Full svg-sweep.sh pipeline in Ruby, opt-in: render seeds 1..count to SVG
|
|
208
|
+
# under `dir`, rasterize each to PNG, then montage them into one sheet.
|
|
209
|
+
# Returns the contact-sheet path.
|
|
210
|
+
#
|
|
211
|
+
# The SVG->PNG step delegates to the engine's Rasterizer (another slice);
|
|
212
|
+
# the dev-side svg-render.sh chain (400px, ImageMagick-free) is not
|
|
213
|
+
# separately ported, so this reuses the engine chain and lets `montage`'s
|
|
214
|
+
# -geometry downscale to the same 400px cells. Both external tools are
|
|
215
|
+
# optional: an empty render set or a missing montage raises explicitly.
|
|
216
|
+
# The studio uses #sweep (in-memory) instead of this disk+subprocess path.
|
|
217
|
+
def sweep_contact_sheet(count, dir: ".sweep", preview_dir: ".preview",
|
|
218
|
+
project_root: Dir.pwd)
|
|
219
|
+
FileUtils.rm_rf(dir)
|
|
220
|
+
FileUtils.mkdir_p(dir)
|
|
221
|
+
FileUtils.mkdir_p(preview_dir)
|
|
222
|
+
|
|
223
|
+
pngs = []
|
|
224
|
+
(1..count).each do |seed|
|
|
225
|
+
svg_path = File.join(dir, "seed-#{seed}.svg")
|
|
226
|
+
write(svg_path, seed: seed)
|
|
227
|
+
png_path = File.join(preview_dir, "seed-#{seed}.png")
|
|
228
|
+
# Only montage what actually rasterized; unlike the bash's stale
|
|
229
|
+
# `.preview/seed-*.png` glob, this feeds exactly this run's outputs.
|
|
230
|
+
pngs << png_path if Rasterizer.rasterize_svg(svg_path, png_path, project_root)
|
|
231
|
+
end
|
|
232
|
+
if pngs.empty?
|
|
233
|
+
raise "no PNGs rendered; install a rasterizer " \
|
|
234
|
+
"(resvg / rsvg-convert / inkscape / cairosvg) to sweep"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
contact_sheet(pngs, out: File.join(dir, "contact-sheet.png"))
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../support/py_random"
|
|
4
|
+
|
|
5
|
+
module Zer0ImageGenerator
|
|
6
|
+
module Svg
|
|
7
|
+
module Generators
|
|
8
|
+
# Parametric flow-field — port of `tools/svg/examples/flowfield.py`.
|
|
9
|
+
#
|
|
10
|
+
# Seed a cloud of particles, then walk each one downstream through a
|
|
11
|
+
# value-noise field, emitting the trail as a polyline. The canonical
|
|
12
|
+
# "generator with knobs": lines/steps/scale/palette are the only dials,
|
|
13
|
+
# every coordinate falls out of the seeded stream.
|
|
14
|
+
#
|
|
15
|
+
# The committed artwork under `docs/samples/flowfield-*.svg` and
|
|
16
|
+
# `docs/showcase/variants/flowfield-*.svg` was produced by the Python
|
|
17
|
+
# original, so this port is pinned to byte-identical output: every draw
|
|
18
|
+
# comes off `Support::PyRandom` in the same order the Python drew it, and
|
|
19
|
+
# every interpolated number is formatted to the same width the f-strings
|
|
20
|
+
# used.
|
|
21
|
+
module Flowfield
|
|
22
|
+
PALETTES = {
|
|
23
|
+
"ember" => ["#1a1423", "#f24236", "#f5a623", "#f7e8d3"].freeze,
|
|
24
|
+
"cyanic" => ["#0b132b", "#1c7293", "#5bc0be", "#e8f1f2"].freeze,
|
|
25
|
+
"moss" => ["#16211b", "#3e6b48", "#a3c9a8", "#e9edc9"].freeze,
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
DEFAULT_PALETTE = "cyanic"
|
|
29
|
+
|
|
30
|
+
# Python's `math.tau` is the precomputed double 2*pi; `Math::PI * 2` is
|
|
31
|
+
# the identical bit pattern, so `angle` matches to the last ULP.
|
|
32
|
+
TAU = Math::PI * 2
|
|
33
|
+
|
|
34
|
+
HALF = Rational(1, 2)
|
|
35
|
+
private_constant :HALF
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Cheap hash-based value noise with bilinear interpolation. Returns a
|
|
39
|
+
# callable sampler; the hash is pure integer arithmetic so it ports
|
|
40
|
+
# directly — Ruby and CPython agree on `>>`, `&`, and `^` for the
|
|
41
|
+
# negative lattice coordinates `math.floor` can produce.
|
|
42
|
+
def value_noise(seed)
|
|
43
|
+
hash = lambda do |ix, iy|
|
|
44
|
+
n = (ix * 374_761_393) + (iy * 668_265_263) + (seed * 1_442_695_040_888_963_407)
|
|
45
|
+
n = (n ^ (n >> 13)) * 1_274_126_177
|
|
46
|
+
# `/ 0xFFFF` is true division in Python; fdiv keeps it a Float
|
|
47
|
+
# instead of Ruby's integer division.
|
|
48
|
+
((n ^ (n >> 16)) & 0xFFFF).fdiv(0xFFFF)
|
|
49
|
+
end
|
|
50
|
+
smooth = ->(t) { t * t * (3 - (2 * t)) }
|
|
51
|
+
lambda do |x, y|
|
|
52
|
+
ix = x.floor
|
|
53
|
+
iy = y.floor
|
|
54
|
+
fx = smooth.call(x - ix)
|
|
55
|
+
fy = smooth.call(y - iy)
|
|
56
|
+
a = hash.call(ix, iy)
|
|
57
|
+
b = hash.call(ix + 1, iy)
|
|
58
|
+
c = hash.call(ix, iy + 1)
|
|
59
|
+
d = hash.call(ix + 1, iy + 1)
|
|
60
|
+
((a * (1 - fx)) + (b * fx)) * (1 - fy) + (((c * (1 - fx)) + (d * fx)) * fy)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the finished SVG document, newline-terminated.
|
|
65
|
+
def build(seed, w, h, lines, steps, scale, palette)
|
|
66
|
+
rng = Support::PyRandom.new(seed)
|
|
67
|
+
noise = value_noise(seed)
|
|
68
|
+
colors = PALETTES.fetch(palette)
|
|
69
|
+
out = []
|
|
70
|
+
|
|
71
|
+
lines.times do
|
|
72
|
+
x = rng.uniform(0, w)
|
|
73
|
+
y = rng.uniform(0, h)
|
|
74
|
+
pts = [[x, y]]
|
|
75
|
+
steps.times do
|
|
76
|
+
angle = noise.call(x / scale, y / scale) * TAU * 2
|
|
77
|
+
x += Math.cos(angle) * 4
|
|
78
|
+
y += Math.sin(angle) * 4
|
|
79
|
+
break unless -20 < x && x < w + 20 && -20 < y && y < h + 20
|
|
80
|
+
|
|
81
|
+
pts << [x, y]
|
|
82
|
+
end
|
|
83
|
+
# Too-short trails are dropped BEFORE their stroke/width/opacity
|
|
84
|
+
# draws — mirroring Python's `continue`, so the RNG stays in sync.
|
|
85
|
+
next if pts.length < 8
|
|
86
|
+
|
|
87
|
+
d = "M" + pts.map { |px, py| "#{fmt(px, 0)},#{fmt(py, 0)}" }.join(" L")
|
|
88
|
+
stroke = rng.choice(colors[1..])
|
|
89
|
+
width = rng.uniform(0.4, 2.4)
|
|
90
|
+
op = rng.uniform(0.25, 0.8)
|
|
91
|
+
out << %(<path d="#{d}" fill="none" stroke="#{stroke}" ) +
|
|
92
|
+
%(stroke-width="#{fmt(width, 2)}" stroke-opacity="#{fmt(op, 2)}" ) +
|
|
93
|
+
%(stroke-linecap="round"/>)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
%(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{w} #{h}" ) +
|
|
97
|
+
"role=\"img\">\n" \
|
|
98
|
+
" <title>Flow field, seed #{seed}, palette #{palette}</title>\n" +
|
|
99
|
+
%( <rect width="#{w}" height="#{h}" fill="#{colors[0]}"/>) + "\n " +
|
|
100
|
+
out.join("\n ") + "\n</svg>\n"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Exact correctly-rounded fixed-point, matching Python's `f"{x:.Nf}"`
|
|
104
|
+
# (round-half-to-even on the exact binary value). Ruby's `%f`/`round`
|
|
105
|
+
# round the shortest decimal instead, which diverges on ties.
|
|
106
|
+
def fmt(value, places)
|
|
107
|
+
negative = value.negative? || (value.zero? && (1.0 / value).negative?)
|
|
108
|
+
scaled = value.abs.to_r * (10**places)
|
|
109
|
+
whole = scaled.floor
|
|
110
|
+
remainder = scaled - whole
|
|
111
|
+
whole += 1 if remainder > HALF || (remainder == HALF && whole.odd?)
|
|
112
|
+
|
|
113
|
+
digits = whole.to_s.rjust(places + 1, "0")
|
|
114
|
+
text = places.zero? ? digits : "#{digits[0...-places]}.#{digits[-places..]}"
|
|
115
|
+
negative ? "-#{text}" : text
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if __FILE__ == $PROGRAM_NAME
|
|
124
|
+
require "fileutils"
|
|
125
|
+
|
|
126
|
+
opts = { seed: 1, out: "art/flowfield.svg", width: 800, height: 800,
|
|
127
|
+
lines: 240, steps: 70, scale: 180.0,
|
|
128
|
+
palette: Zer0ImageGenerator::Svg::Generators::Flowfield::DEFAULT_PALETTE }
|
|
129
|
+
argv = ARGV.dup
|
|
130
|
+
until argv.empty?
|
|
131
|
+
flag = argv.shift
|
|
132
|
+
val = argv.shift
|
|
133
|
+
case flag
|
|
134
|
+
when "--seed" then opts[:seed] = Integer(val)
|
|
135
|
+
when "--out" then opts[:out] = val
|
|
136
|
+
when "--width" then opts[:width] = Integer(val)
|
|
137
|
+
when "--height" then opts[:height] = Integer(val)
|
|
138
|
+
when "--lines" then opts[:lines] = Integer(val)
|
|
139
|
+
when "--steps" then opts[:steps] = Integer(val)
|
|
140
|
+
when "--scale" then opts[:scale] = Float(val)
|
|
141
|
+
when "--palette" then opts[:palette] = val
|
|
142
|
+
else raise ArgumentError, "unknown flag #{flag.inspect}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
dir = File.dirname(opts[:out])
|
|
147
|
+
FileUtils.mkdir_p(dir.empty? ? "." : dir)
|
|
148
|
+
svg = Zer0ImageGenerator::Svg::Generators::Flowfield.build(
|
|
149
|
+
opts[:seed], opts[:width], opts[:height], opts[:lines],
|
|
150
|
+
opts[:steps], opts[:scale], opts[:palette]
|
|
151
|
+
)
|
|
152
|
+
File.write(opts[:out], svg)
|
|
153
|
+
puts opts[:out]
|
|
154
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../support/py_random"
|
|
4
|
+
# The retro pixel toolkit is a sibling slice
|
|
5
|
+
# (lib/zer0_image_generator/svg/pixel_kit.rb). Load it unless a caller — a test
|
|
6
|
+
# harness pinning byte-identity — has already supplied the namespace; the
|
|
7
|
+
# `defined?` guard lets that stub stand in without a second load, and a genuine
|
|
8
|
+
# absence still surfaces as an explicit LoadError from require_relative.
|
|
9
|
+
require_relative "../pixel_kit" unless defined?(Zer0ImageGenerator::Svg::PixelKit)
|
|
10
|
+
|
|
11
|
+
module Zer0ImageGenerator
|
|
12
|
+
module Svg
|
|
13
|
+
module Generators
|
|
14
|
+
# Procedural fixed-shooter scene — port of `tools/svg/examples/invaders.py`.
|
|
15
|
+
#
|
|
16
|
+
# Each wave's alien species are *generated*: random cells in a half-grid,
|
|
17
|
+
# mirrored (hmirror) — bilateral symmetry is what makes random blobs read
|
|
18
|
+
# as creatures. One alien is mid-explosion; the symmetry break is the
|
|
19
|
+
# subject.
|
|
20
|
+
#
|
|
21
|
+
# Byte-identity contract: `docs/samples/invaders-*.svg` and
|
|
22
|
+
# `docs/showcase/variants/invaders-dusk-3.svg` were produced here, so every
|
|
23
|
+
# draw must come off `Support::PyRandom` in the Python order. `alien` does
|
|
24
|
+
# rejection sampling, so the number of draws is data-dependent — the
|
|
25
|
+
# density predicate has to match exactly or the whole stream desyncs.
|
|
26
|
+
module Invaders
|
|
27
|
+
SHIP = [
|
|
28
|
+
".....#.....",
|
|
29
|
+
"....l#l....",
|
|
30
|
+
".l.l###l.l.",
|
|
31
|
+
"###########",
|
|
32
|
+
"l#l#l#l#l#l",
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
BUNKER = [
|
|
36
|
+
".########.",
|
|
37
|
+
"##########",
|
|
38
|
+
"##########",
|
|
39
|
+
"###....###",
|
|
40
|
+
"##......##",
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
MINI_SHIP = [
|
|
44
|
+
"..#..",
|
|
45
|
+
".###.",
|
|
46
|
+
"#####",
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
BOOM = [
|
|
50
|
+
"#..#.#..#",
|
|
51
|
+
".#..#..#.",
|
|
52
|
+
"..#####..",
|
|
53
|
+
".#..#..#.",
|
|
54
|
+
"#..#.#..#",
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
DEFAULT_PALETTE = "cyanic"
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
# Random half-grid, mirrored. Reject sprites too sparse or too dense
|
|
61
|
+
# to read as a creature. The bounds are `0.35..0.75` of the half-grid
|
|
62
|
+
# cell count as floats, compared against the integer mass — mirror the
|
|
63
|
+
# comparison exactly, since a stricter/looser predicate changes how
|
|
64
|
+
# many times the loop redraws and shifts every later value.
|
|
65
|
+
def alien(rng, half_w = 6, height = 8)
|
|
66
|
+
loop do
|
|
67
|
+
rows = Array.new(height) do
|
|
68
|
+
Array.new(half_w) { rng.random < 0.55 ? "#" : "." }.join
|
|
69
|
+
end
|
|
70
|
+
mass = rows.sum { |row| row.count("#") }
|
|
71
|
+
lo = 0.35 * half_w * height
|
|
72
|
+
hi = 0.75 * half_w * height
|
|
73
|
+
return Svg::PixelKit.hmirror(rows) if mass >= lo && mass <= hi
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build(seed, palette = DEFAULT_PALETTE)
|
|
78
|
+
rng = Support::PyRandom.new(seed)
|
|
79
|
+
pal = Svg::PixelKit::PALETTES.fetch(palette)
|
|
80
|
+
canvas = Svg::Canvas.new(64, 48, px: 12, bg: pal[0])
|
|
81
|
+
|
|
82
|
+
# Starfield, sparse and uneven — heavier toward the top. The color
|
|
83
|
+
# draw happens only when the placement test passes; keep it inside
|
|
84
|
+
# the branch so the stream matches.
|
|
85
|
+
70.times do
|
|
86
|
+
col = rng.randrange(64)
|
|
87
|
+
row = rng.randrange(44)
|
|
88
|
+
if rng.random < ((1.0 - (row / 44.0)) * 0.8)
|
|
89
|
+
canvas.set(col, row, rng.random < 0.6 ? pal[1] : pal[2])
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Three ranks of aliens, one species per rank, 5 across
|
|
94
|
+
species = Array.new(3) { alien(rng) }
|
|
95
|
+
rank_color = [pal[5], pal[4], pal[3]]
|
|
96
|
+
hit = [rng.randrange(3), rng.randrange(5)] # one alien mid-explosion
|
|
97
|
+
3.times do |rank|
|
|
98
|
+
5.times do |i|
|
|
99
|
+
col = 1 + (i * 13)
|
|
100
|
+
row = 5 + (rank * 10)
|
|
101
|
+
if [rank, i] == hit
|
|
102
|
+
canvas.blit(BOOM, { "#" => pal[4] }, col + 1, row + 1)
|
|
103
|
+
else
|
|
104
|
+
canvas.blit(species[rank], { "#" => rank_color[rank] }, col, row)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A shot on its way up from the player
|
|
110
|
+
shot_col = 1 + (rng.randrange(5) * 13) + 5
|
|
111
|
+
canvas.fill(shot_col, 33, 1, 3, pal[5])
|
|
112
|
+
|
|
113
|
+
# Bunkers, ship, ground, lives
|
|
114
|
+
3.times { |i| canvas.blit(BUNKER, { "#" => pal[2] }, 5 + (i * 22), 36) }
|
|
115
|
+
canvas.blit(SHIP, { "#" => pal[4], "l" => pal[3] }, 26, 41)
|
|
116
|
+
canvas.fill(0, 46, 64, 1, pal[2])
|
|
117
|
+
3.times { |i| canvas.blit(MINI_SHIP, { "#" => pal[2] }, 2 + (i * 7), 1) }
|
|
118
|
+
|
|
119
|
+
canvas.to_svg("Invader wave, seed #{seed}, palette #{palette}", crt: true)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|