diff --git a/arts/gen/src/gen/BarnsleyIFS.kt b/arts/gen/src/gen/BarnsleyIFS.kt new file mode 100644 index 00000000..56e5e6e4 --- /dev/null +++ b/arts/gen/src/gen/BarnsleyIFS.kt @@ -0,0 +1,143 @@ +package gen + +import dev.oblac.gart.Gart +import dev.oblac.gart.Gartmap +import dev.oblac.gart.math.* +import kotlin.math.ln +import kotlin.math.max +import kotlin.random.Random +import org.jetbrains.skia.Image + +private val SIZE: Int = System.getProperty("GART_SIZE")?.toIntOrNull() ?: 1024 +private val SEED: Long = System.getProperty("GART_SEED")?.toLongOrNull() ?: Random.nextLong() + +// ── SYSTEM · Iterated function system attractor ──────────────────────────────── +// The chaos game: pick one of several contractive affine maps at random each step +// and hop the point through it. Millions of hops settle onto a fractal attractor. +// Hit counts accumulate into a density buffer, log-tone-mapped and coloured through +// the "Electric Grape" ramp over dark — filaments glow violet→cyan from sparse→dense. +private class Affine( + val a: Float, val b: Float, val cc: Float, val dd: Float, + val e: Float, val f: Float, val p: Float +) + +fun main() { + println("seed=$SEED") + val rng = Random(SEED) + + val gart = Gart.of("BarnsleyIFS", SIZE, SIZE) + val d = gart.d + + // ── build a stable, contractive affine set ──────────────────────────────── + // Half the time use the classic Barnsley fern; otherwise synthesise 3–4 + // verified-contractive random maps so each seed yields a distinct attractor. + val maps = ArrayList() + if (rng.rndb()) { + // classic Barnsley fern + maps.add(Affine(0f, 0f, 0f, 0.16f, 0f, 0f, 0.01f)) + maps.add(Affine(0.85f, 0.04f, -0.04f, 0.85f, 0f, 1.60f, 0.85f)) + maps.add(Affine(0.20f, -0.26f, 0.23f, 0.22f, 0f, 1.60f, 0.07f)) + maps.add(Affine(-0.15f, 0.28f, 0.26f, 0.24f, 0f, 0.44f, 0.07f)) + println(" using classic Barnsley fern") + } else { + val nMaps = rng.rndi(3, 5) + var totalP = 0f + val raw = ArrayList() + repeat(nMaps) { + // contractive: keep linear part scaled so its rough spectral size < 1 + var a: Float; var b: Float; var cc: Float; var dd: Float + while (true) { + val scale = rng.rndf(0.35f, 0.62f) + val rot = rng.rndf(0f, TAUf) + val shear = rng.rndf(-0.25f, 0.25f) + a = scale * kotlin.math.cos(rot) + b = -scale * kotlin.math.sin(rot) + shear + cc = scale * kotlin.math.sin(rot) + dd = scale * kotlin.math.cos(rot) + // crude contractivity check via Frobenius norm < 1.0 + val fro = kotlin.math.sqrt(a * a + b * b + cc * cc + dd * dd) + if (fro < 0.98f) break + } + val e = rng.rndf(-0.5f, 0.5f) + val f = rng.rndf(-0.5f, 0.5f) + val pw = rng.rndf(0.5f, 1.5f) + totalP += pw + raw.add(Affine(a, b, cc, dd, e, f, pw)) + } + for (m in raw) maps.add(Affine(m.a, m.b, m.cc, m.dd, m.e, m.f, m.p / totalP)) + println(" synthesised $nMaps contractive maps") + } + + // cumulative probabilities for selection + val cum = FloatArray(maps.size) + var acc = 0f + for (i in maps.indices) { acc += maps[i].p; cum[i] = acc } + fun pick(): Affine { + val r = rng.nextFloat() * acc + for (i in maps.indices) if (r <= cum[i]) return maps[i] + return maps.last() + } + + // ── pre-pass: find attractor bounds ────────────────────────────────────── + var x = 0f; var y = 0f + repeat(1000) { val m = pick(); val nx = m.a * x + m.b * y + m.e; val ny = m.cc * x + m.dd * y + m.f; x = nx; y = ny } + var minX = Float.MAX_VALUE; var maxX = -Float.MAX_VALUE + var minY = Float.MAX_VALUE; var maxY = -Float.MAX_VALUE + repeat(40000) { + val m = pick() + val nx = m.a * x + m.b * y + m.e + val ny = m.cc * x + m.dd * y + m.f + x = nx; y = ny + if (x < minX) minX = x; if (x > maxX) maxX = x + if (y < minY) minY = y; if (y > maxY) maxY = y + } + val spanX = (maxX - minX).coerceAtLeast(1e-4f) + val spanY = (maxY - minY).coerceAtLeast(1e-4f) + // fit into frame with a margin, preserving aspect (uniform scale) + val margin = SIZE * 0.08f + val avail = SIZE - 2f * margin + val scl = (avail / max(spanX, spanY)) + val offX = margin + (avail - spanX * scl) * 0.5f + val offY = margin + (avail - spanY * scl) * 0.5f + println(" bounds x[$minX,$maxX] y[$minY,$maxY]") + + // ── full render pass: accumulate density ───────────────────────────────── + val w = d.w; val h = d.h + val density = IntArray(w * h) + val iters = 8_000_000 + x = 0f; y = 0f + var maxHits = 1 + for (i in 0 until iters) { + val m = pick() + val nx = m.a * x + m.b * y + m.e + val ny = m.cc * x + m.dd * y + m.f + x = nx; y = ny + if (i < 20) continue // settle onto the attractor + // map to pixel: y flips so fern grows upward + val px = ((x - minX) * scl + offX).toInt() + val py = (h - 1 - ((y - minY) * scl + offY)).toInt() + if (px < 0 || py < 0 || px >= w || py >= h) continue + val idx = py * w + px + val v = density[idx] + 1 + density[idx] = v + if (v > maxHits) maxHits = v + } + println(" rendered, maxHits=$maxHits") + + // ── log tone-map → Electric Grape ramp over dark ───────────────────────── + val ramp = Coolors.electricGrape.expand(256) + val ground = 0xFF05030F.toInt() + val gm = Gartmap(gart.gartvas()) + val logMax = ln((maxHits + 1).toFloat()) + val px = gm.pixels + for (i in px.indices) { + val hits = density[i] + if (hits == 0) { px[i] = ground; continue } + val t = (ln((hits + 1).toFloat()) / logMax).coerceIn(0f, 1f) + px[i] = ramp.bound(t * (ramp.size - 1)) + } + + val img: Image = gm.image() + val finalv = bloom(gart, img, ground, SIZE * 0.003f, grain = 0.04f) + gart.saveImage(finalv) +} diff --git a/arts/gen/src/gen/LeniaBloom.kt b/arts/gen/src/gen/LeniaBloom.kt new file mode 100644 index 00000000..8495079a --- /dev/null +++ b/arts/gen/src/gen/LeniaBloom.kt @@ -0,0 +1,138 @@ +package gen + +import dev.oblac.gart.Gart +import dev.oblac.gart.Gartmap +import dev.oblac.gart.color.argb +import dev.oblac.gart.math.* +import kotlin.math.exp +import kotlin.math.floor +import kotlin.math.hypot +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sqrt +import kotlin.random.Random +import org.jetbrains.skia.Image + +private val SIZE: Int = System.getProperty("GART_SIZE")?.toIntOrNull() ?: 1024 +private val SEED: Long = System.getProperty("GART_SEED")?.toLongOrNull() ?: Random.nextLong() + +// ── SYSTEM · Lenia continuous cellular automata ──────────────────────────────── +// Lenia (Bert Chan): a continuous-state, continuous-kernel generalisation of Life. +// A smooth ring kernel is convolved against the field each step and run through a +// Gaussian growth law; gliders and lobed "orbium"-like organisms emerge and crawl. +// Simulated cheaply at low res, then upscaled into a Gartmap and coloured through +// the "Molten" ramp over a dark ground so living tissue glows. +fun main() { + println("seed=$SEED") + val rng = Random(SEED) + + val gart = Gart.of("LeniaBloom", SIZE, SIZE) + val d = gart.d + + // ── simulation grid (resolution independent of SIZE) ────────────────────── + val N = 320 // sim grid is N×N, toroidal + val R = 13 // kernel radius + val mu = 0.15f // growth centre + val sigma = 0.045f // growth width (razor-thin Orbium sigma=0.017 + // makes random seeds decay to zero; widening + // it lands a robust, persistent lobed regime) + val dt = 0.10f // time step + val steps = 150 + + var field = FloatArray(N * N) + val next = FloatArray(N * N) + + // ── precompute the smooth ring kernel (normalised to sum 1) ─────────────── + // Shell radius beta=0.5, bell-shaped radial profile peaking at half R. + val kR = R + val kSize = 2 * kR + 1 + val kernel = FloatArray(kSize * kSize) + var kSum = 0f + for (dy in -kR..kR) for (dx in -kR..kR) { + val r = hypot(dx.toFloat(), dy.toFloat()) / kR // 0..~1.41 + var w = 0f + if (r > 0.0f && r < 1.0f) { + // single-bump kernel: exp(-(r-0.5)^2 / (2*0.15^2)) + val a = (r - 0.5f) + w = exp(-(a * a) / (2f * 0.15f * 0.15f)) + } + kernel[(dy + kR) * kSize + (dx + kR)] = w + kSum += w + } + for (i in kernel.indices) kernel[i] /= kSum + + // ── seed a handful of random blobs, off-centre with empty space ─────────── + fun seedBlob(cxN: Int, cyN: Int, rad: Int, dens: Float) { + for (dy in -rad..rad) for (dx in -rad..rad) { + if (dx * dx + dy * dy > rad * rad) continue + val x = ((cxN + dx) % N + N) % N + val y = ((cyN + dy) % N + N) % N + val fall = 1f - hypot(dx.toFloat(), dy.toFloat()) / rad + field[y * N + x] = min(1f, field[y * N + x] + dens * fall * rng.rndf(0.5f, 1.0f)) + } + } + // cluster the organisms toward the lower-left third — leave the rest open + val blobs = rng.rndi(5, 9) + repeat(blobs) { + val bx = rng.rndi(N / 6, N * 3 / 5) + val by = rng.rndi(N * 2 / 5, N * 9 / 10) + seedBlob(bx, by, rng.rndi(R, R * 2), rng.rndf(0.6f, 1.0f)) + } + + // ── run the CA ───────────────────────────────────────────────────────── + fun growth(u: Float): Float { + val a = (u - mu) + return 2f * exp(-(a * a) / (2f * sigma * sigma)) - 1f + } + repeat(steps) { + for (y in 0 until N) { + for (x in 0 until N) { + var u = 0f + var ki = 0 + for (dy in -kR..kR) { + val yy = ((y + dy) % N + N) % N * N + for (dx in -kR..kR) { + val xx = ((x + dx) % N + N) % N + u += field[yy + xx] * kernel[ki++] + } + } + val v = field[y * N + x] + dt * growth(u) + next[y * N + x] = if (v < 0f) 0f else if (v > 1f) 1f else v + } + } + System.arraycopy(next, 0, field, 0, field.size) + } + + // measure the living field range for a tighter tone map + var fmax = 1e-6f + for (v in field) if (v > fmax) fmax = v + println(" lenia ran $steps steps, fmax=$fmax") + + // ── upscale into a Gartmap, colour through Molten over dark ────────────── + val ramp = Coolors.molten.expand(256) + val gm = Gartmap(gart.gartvas()) + val ground = 0xFF0A0306.toInt() + val scale = SIZE.toFloat() / N + for (py in 0 until d.h) { + for (px in 0 until d.w) { + // bilinear sample of the sim field (toroidal) + val sx = px / scale + val sy = py / scale + val x0 = floor(sx).toInt(); val y0 = floor(sy).toInt() + val fx = sx - x0; val fy = sy - y0 + val x1 = (x0 + 1) % N; val y1 = (y0 + 1) % N + val xa = ((x0 % N) + N) % N; val ya = ((y0 % N) + N) % N + val v00 = field[ya * N + xa]; val v10 = field[ya * N + x1] + val v01 = field[y1 * N + xa]; val v11 = field[y1 * N + x1] + val v = (v00 * (1 - fx) + v10 * fx) * (1 - fy) + (v01 * (1 - fx) + v11 * fx) * fy + val t = (v / fmax).coerceIn(0f, 1f) + val col = if (t < 0.04f) ground + else ramp.bound((sqrt(t) * (ramp.size - 1))) + gm[px, py] = col + } + } + + val img: Image = gm.image() + val finalv = bloom(gart, img, ground, SIZE * 0.006f, grain = 0.06f) + gart.saveImage(finalv) +} diff --git a/arts/gen/src/gen/MarbleAgate.kt b/arts/gen/src/gen/MarbleAgate.kt new file mode 100644 index 00000000..12f1547d --- /dev/null +++ b/arts/gen/src/gen/MarbleAgate.kt @@ -0,0 +1,122 @@ +package gen + +import dev.oblac.gart.Gart +import dev.oblac.gart.Gartmap +import dev.oblac.gart.color.Palette +import dev.oblac.gart.math.* +import dev.oblac.gart.noise.OpenSimplexNoise +import kotlin.math.hypot +import kotlin.random.Random + +private val SIZE: Int = System.getProperty("GART_SIZE")?.toIntOrNull() ?: 1024 +private val SEED: Long = System.getProperty("GART_SEED")?.toLongOrNull() ?: Random.nextLong() + +// ── SYSTEM · Domain-warped fBm agate / marble slab ───────────────────────────── +// A sliced-agate per-pixel field. For each pixel we evaluate multi-octave fBm from +// OpenSimplexNoise, then domain-warp it twice — q = fbm(p + warp1), then +// value = fbm(p + 4*q) — to get swirling agate bands. The value is quantised into +// ~18 discrete bands mapped through "Sunset".expand(256) for concentric striations, +// with a thin darker etched line wherever the band index changes between neighbours. +// Band frequency is modulated radially around an OFF-CENTRE focus so the bands tighten +// into a nodule like a geode. Light/mid ground, grainOnly finish. + +private lateinit var noise: OpenSimplexNoise + +private fun fbm(x: Float, y: Float, octaves: Int): Float { + var freq = 1f + var amp = 0.5f + var sum = 0f + var norm = 0f + for (i in 0 until octaves) { + sum += amp * noise.random2D(x * freq, y * freq) + norm += amp + freq *= 2.0f + amp *= 0.5f + } + return sum / norm // ~[-1,1] +} + +fun main() { + println("seed=$SEED") + val rng = Random(SEED) + + val gart = Gart.of("MarbleAgate", SIZE, SIZE) + val d = gart.d + noise = OpenSimplexNoise(SEED) + + val ramp: Palette = Coolors.sunset.expand(256) + val bands = rng.rndi(16, 22) // discrete agate striations + val baseFreq = 1.9f / SIZE // base spatial frequency of the field + + // off-centre geode focus + val fx = d.cx + (rng.nextFloat() - 0.5f) * SIZE * 0.42f + val fy = d.cy + (rng.nextFloat() - 0.5f) * SIZE * 0.42f + val maxR = hypot(SIZE.toFloat(), SIZE.toFloat()) + + // random orientation offsets so each seed lands in a different region of noise + val ox = rng.rndf(-1000f, 1000f) + val oy = rng.rndf(-1000f, 1000f) + val warpOx = rng.rndf(-1000f, 1000f) + val warpOy = rng.rndf(-1000f, 1000f) + + val gm = Gartmap(gart.gartvas()) + val bandIdx = IntArray(d.w * d.h) + val bandVal = FloatArray(d.w * d.h) + + // ── pass 1: compute warped fBm + band index per pixel ───────────────────── + for (py in 0 until d.h) { + for (px in 0 until d.w) { + // distance from the off-centre geode focus, 0 at focus + val dr = hypot(px - fx, py - fy) / maxR + val sx = (px + ox) + val sy = (py + oy) + + // domain warp: two-stage like Inigo Quilez's pattern() + val qx = fbm(sx * baseFreq + warpOx * baseFreq, sy * baseFreq, 4) + val qy = fbm(sx * baseFreq + 5.2f, sy * baseFreq + warpOy * baseFreq + 1.3f, 4) + + val warpAmt = SIZE * 0.55f + var v = fbm( + sx * baseFreq + warpAmt * baseFreq * qx, + sy * baseFreq + warpAmt * baseFreq * qy, + 5 + ) + v = v * 0.5f + 0.5f // → [0,1] + + // Radial frequency modulation: bands TIGHTEN toward the focus so they pack + // into a nodule. The ring phase advances faster (smaller divisor) near focus. + val ringFreq = 7.5f + 26f * (1f - dr) * (1f - dr) + val phase = v * 2.6f + dr * ringFreq + val folded = (phase - kotlin.math.floor(phase)) // sawtooth → repeating bands + + val bi = (folded * bands).toInt().coerceIn(0, bands - 1) + bandIdx[py * d.w + px] = bi + bandVal[py * d.w + px] = folded + } + } + + // ── pass 2: colour each band + etch darker boundary lines ───────────────── + val lineCol = 0xFF2A1A18.toInt() + for (py in 0 until d.h) { + for (px in 0 until d.w) { + val i = py * d.w + px + val bi = bandIdx[i] + // map band through the ramp; add subtle within-band shimmer from the value + val tonal = (bi.toFloat() / (bands - 1)).coerceIn(0f, 1f) + val shimmer = (bandVal[i] - (bi.toFloat() / bands)) * 0.6f + val rampPos = (tonal + shimmer * 0.12f).coerceIn(0f, 1f) * (ramp.size - 1) + var col = ramp.bound(rampPos) + + // etched contour: darken where band index changes vs right/down neighbour + val edge = (px + 1 < d.w && bandIdx[i + 1] != bi) || + (py + 1 < d.h && bandIdx[i + d.w] != bi) + if (edge) col = darken(col, 0.45f) + gm[px, py] = col + } + } + + gm.drawToCanvas() + val finalv = grainOnly(gart, gm.image(), grain = 0.05f) + gart.saveImage(finalv) + println(" agate done ($bands bands, focus=($fx,$fy))") +} diff --git a/arts/gen/src/gen/PenroseTiling.kt b/arts/gen/src/gen/PenroseTiling.kt new file mode 100644 index 00000000..c3eca13b --- /dev/null +++ b/arts/gen/src/gen/PenroseTiling.kt @@ -0,0 +1,143 @@ +package gen + +import dev.oblac.gart.Gart +import dev.oblac.gart.color.Palette +import dev.oblac.gart.color.lerpColor +import dev.oblac.gart.gfx.alpha +import dev.oblac.gart.gfx.closedPathOf +import dev.oblac.gart.gfx.fillOf +import dev.oblac.gart.gfx.pathOf +import dev.oblac.gart.gfx.strokeOf +import dev.oblac.gart.math.* +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.sin +import kotlin.random.Random +import org.jetbrains.skia.PaintMode +import org.jetbrains.skia.Point + +private val SIZE: Int = System.getProperty("GART_SIZE")?.toIntOrNull() ?: 1024 +private val SEED: Long = System.getProperty("GART_SEED")?.toLongOrNull() ?: Random.nextLong() + +// ── SYSTEM · Penrose P3 rhombus tiling by deflation ──────────────────────────── +// The aperiodic Penrose P3 tiling built the classic way: subdivide Robinson +// triangles. Each half-rhombus triangle is type 0 (acute / "thin" half) or type 1 +// (obtuse / "thick" half) with three vertices. We seed a fivefold wheel of ~10 +// triangles around the centre, then apply the golden-ratio deflation rule 6–7 times +// (each triangle splits into 2–3 children at ratio 1/φ). Final triangles are paired +// back into rhombi, filled from "Navy & Gold" — the two rhombus types get distinct +// gold/blue tones, varied by distance from centre — over a dark navy ground with thin +// gold edge strokes so the fivefold aperiodic structure glows. Bloom finish. + +private const val PHI = 1.618033988749895 +private val INV_PHI = (1.0 / PHI) + +// A Robinson half-triangle. type 0 = thin (acute 36°), type 1 = thick (obtuse 36°). +// Vertices a (apex), b, c following the standard de Bruijn / Bartholdi construction. +private class Tri(val type: Int, val a: Cpx, val b: Cpx, val c: Cpx) + +private class Cpx(val re: Double, val im: Double) { + operator fun plus(o: Cpx) = Cpx(re + o.re, im + o.im) + operator fun minus(o: Cpx) = Cpx(re - o.re, im - o.im) + operator fun times(s: Double) = Cpx(re * s, im * s) +} + +// linear interpolation a + (b-a)*t in the complex plane +private fun lerpC(a: Cpx, b: Cpx, t: Double) = a + (b - a) * t + +private fun subdivide(tris: List): List { + val out = ArrayList(tris.size * 3) + for (tr in tris) { + val a = tr.a; val b = tr.b; val c = tr.c + if (tr.type == 0) { + // thin (acute) → 2 children + val p = lerpC(a, b, INV_PHI) + out.add(Tri(0, c, p, b)) + out.add(Tri(1, p, c, a)) + } else { + // thick (obtuse) → 3 children + val q = lerpC(b, a, INV_PHI) + val r = lerpC(b, c, INV_PHI) + out.add(Tri(1, r, c, a)) + out.add(Tri(1, q, r, b)) + out.add(Tri(0, r, q, a)) + } + } + return out +} + +fun main() { + println("seed=$SEED") + val rng = Random(SEED) + + val gart = Gart.of("PenroseTiling", SIZE, SIZE) + val d = gart.d + val palette: Palette = Coolors.navyGold + + val ground = 0xFF000814.toInt() + + // tones for the two rhombus types, lightened toward gold near centre + val thinNear = 0xFFFFD60A.toInt() // bright gold + val thinFar = 0xFF6E5A12.toInt() // muted gold + val thickNear = 0xFF1F6FB8.toInt() // luminous blue + val thickFar = 0xFF001D3D.toInt() // deep navy + val edgeCol = 0xFFFFC300.toInt() // gold edge stroke + + // ── seed wheel: 10 thick triangles around centre ────────────────────────── + val cx = d.cx.toDouble(); val cy = d.cy.toDouble() + val R = SIZE * 0.72 // generous so the pattern overfills the frame + val centre = Cpx(cx, cy) + var tris = ArrayList() + for (i in 0 until 10) { + var b = Cpx(cos((2 * i - 1) * Math.PI / 10), sin((2 * i - 1) * Math.PI / 10)) * R + centre + var cc = Cpx(cos((2 * i + 1) * Math.PI / 10), sin((2 * i + 1) * Math.PI / 10)) * R + centre + if (i % 2 == 0) { val t = b; b = cc; cc = t } // mirror alternate wedges + tris.add(Tri(1, centre, b, cc)) + } + + // ── deflate ──────────────────────────────────────────────────────────────── + val generations = 7 + var cur: List = tris + repeat(generations) { cur = subdivide(cur) } + println(" ${cur.size} half-triangles after $generations deflations") + + // ── render ──────────────────────────────────────────────────────────────── + val buf = gart.gartvas() + val cnv = buf.canvas + cnv.clear(ground) + + val maxDist = SIZE * 0.62f + fun pt(z: Cpx) = Point(z.re.toFloat(), z.im.toFloat()) + + val edge = strokeOf(edgeCol, (SIZE * 0.0011f).coerceAtLeast(0.6f)).apply { + mode = PaintMode.STROKE; isAntiAlias = true + }.alpha(150) + + // draw each half-triangle as a filled triangle; pairing happens visually because + // matched halves share the long edge and the same type colour → seamless rhombi. + for (tr in cur) { + // rhombus centroid distance from canvas centre drives the tone + val mx = ((tr.a.re + tr.b.re + tr.c.re) / 3.0).toFloat() + val my = ((tr.a.im + tr.b.im + tr.c.im) / 3.0).toFloat() + val dist = hypot(mx - d.cx, my - d.cy) + val t = (dist / maxDist).coerceIn(0f, 1f) + val jitter = (rng.nextFloat() - 0.5f) * 0.10f + val tt = (t + jitter).coerceIn(0f, 1f) + val fill = if (tr.type == 0) lerpColor(thinNear, thinFar, tt) + else lerpColor(thickNear, thickFar, tt) + + val path = closedPathOf(listOf(pt(tr.a), pt(tr.b), pt(tr.c))) + cnv.drawPath(path, fillOf(fill).apply { isAntiAlias = true }) + } + + // gold edges drawn on top: only the two short rhombus edges (a-b and a-c) so the + // internal long diagonals between paired halves stay invisible → clean rhombi. + for (tr in cur) { + val path = pathOf(listOf(pt(tr.b), pt(tr.a), pt(tr.c))) + cnv.drawPath(path, edge) + } + + val finalv = bloom(gart, buf.snapshot(), ground, SIZE * 0.004f, grain = 0.05f) + gart.saveImage(finalv) + println(" Penrose done") +} diff --git a/arts/gen/src/gen/SubstrateCracks.kt b/arts/gen/src/gen/SubstrateCracks.kt new file mode 100644 index 00000000..dc57d4e5 --- /dev/null +++ b/arts/gen/src/gen/SubstrateCracks.kt @@ -0,0 +1,159 @@ +package gen + +import dev.oblac.gart.Gart +import dev.oblac.gart.gfx.alpha +import dev.oblac.gart.gfx.drawLine +import dev.oblac.gart.gfx.strokeOf +import dev.oblac.gart.math.* +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random +import org.jetbrains.skia.Paint +import org.jetbrains.skia.PaintStrokeCap +import org.jetbrains.skia.Point + +private val SIZE: Int = System.getProperty("GART_SIZE")?.toIntOrNull() ?: 1024 +private val SEED: Long = System.getProperty("GART_SEED")?.toLongOrNull() ?: Random.nextLong() + +// ── SYSTEM · Substrate crack propagation ─────────────────────────────────────── +// Jared Tarbell's "Substrate": cracks crawl in straight lines until they strike an +// existing crack, where they stop and spawn a child at a perpendicular angle off a +// random already-drawn crack. The result is a fractured rectilinear mosaic of cells. +// Each crack lays translucent perpendicular sand strokes as it grows so the cells +// fill with soft tonal washes; crisp ink lines ride on top. Ember on dark ink. +private class Crack(var x: Float, var y: Float, var ang: Float, val colT: Float) { + var alive = true + var px = x; var py = y // previous pixel position (for line segs) +} + +fun main() { + println("seed=$SEED") + val rng = Random(SEED) + + val gart = Gart.of("SubstrateCracks", SIZE, SIZE) + val d = gart.d + + val out = gart.gartvas() + val c = out.canvas + val ground = 0xFF04151F.toInt() // ink ground + c.clear(ground) + + val ramp = Coolors.inkEmber.expand(256) + + // ── occupancy grid: angle of the crack occupying each cell (NaN = empty) ── + // Coarser than full res keeps memory/cost sane but still partitions cleanly. + val gw = (SIZE * 0.5f).toInt() // grid resolution + val gh = gw + val gscale = gw.toFloat() / SIZE + val occ = FloatArray(gw * gh) { Float.NaN } + + fun gridIdx(x: Float, y: Float): Int { + val gx = (x * gscale).toInt() + val gy = (y * gscale).toInt() + if (gx < 0 || gy < 0 || gx >= gw || gy >= gh) return -1 + return gy * gw + gx + } + + val cracks = ArrayList() + val step = SIZE * 0.0016f // advance distance per tick + val maxCracks = 520 + val washW = SIZE * 0.018f // half-length of perpendicular wash stroke + + fun spawn(seedFromExisting: Boolean) { + var x: Float; var y: Float; var ang: Float + if (seedFromExisting && cracks.isNotEmpty()) { + // pick a random occupied grid cell, sprout perpendicular to its crack + var tries = 0 + var idx = -1 + while (tries < 40) { + val i = rng.nextInt(gw * gh) + if (!occ[i].isNaN()) { idx = i; break } + tries++ + } + if (idx < 0) { spawn(false); return } + val gx = idx % gw; val gy = idx / gw + x = (gx / gscale) + rng.rndf(-2f, 2f) + y = (gy / gscale) + rng.rndf(-2f, 2f) + val base = occ[idx] + ang = base + (if (rng.rndb()) PI.toFloat() / 2f else -PI.toFloat() / 2f) + + rng.rndf(-0.16f, 0.16f) + } else { + x = rng.rndf(SIZE * 0.1f, SIZE * 0.9f) + y = rng.rndf(SIZE * 0.1f, SIZE * 0.9f) + ang = rng.rndf(0f, TAUf) + } + cracks.add(Crack(x, y, ang, rng.rndf(0.45f, 1.0f))) + } + + // a few seed cracks at random angles + repeat(3) { spawn(false) } + + val inkPaint = strokeOf(0xFFEFD6AC.toInt(), SIZE * 0.0011f) + inkPaint.strokeCap = PaintStrokeCap.ROUND + inkPaint.isAntiAlias = true + + var guard = 0 + while (cracks.count { it.alive } > 0 && guard < 60000) { + guard++ + var spawnedThisTick = 0 + // snapshot the count: spawn() appends to `cracks`, so iterate by index over the + // cracks that exist at tick start (new ones are processed next tick) — a + // for-each over the live list would throw ConcurrentModificationException. + val tickCount = cracks.size + for (ci in 0 until tickCount) { + val cr = cracks[ci] + if (!cr.alive) continue + cr.px = cr.x; cr.py = cr.y + cr.x += cos(cr.ang) * step + cr.y += sin(cr.ang) * step + + // out of frame → die + if (cr.x < 0f || cr.y < 0f || cr.x >= SIZE || cr.y >= SIZE) { + cr.alive = false + if (cracks.size < maxCracks) { spawn(true); spawnedThisTick++ } + continue + } + val idx = gridIdx(cr.x, cr.y) + if (idx < 0) { cr.alive = false; continue } + + // collision with an existing, different crack → stop + spawn child + val here = occ[idx] + if (!here.isNaN()) { + cr.alive = false + if (cracks.size < maxCracks) { spawn(true); spawnedThisTick++ } + continue + } + occ[idx] = cr.ang + + // lay a translucent perpendicular sand wash so the cell fills with tone + val perp = cr.ang + PI.toFloat() / 2f + val wl = washW * rng.rndf(0.3f, 1.0f) + val wx0 = cr.x + cos(perp) * wl + val wy0 = cr.y + sin(perp) * wl + val wx1 = cr.x - cos(perp) * wl + val wy1 = cr.y - sin(perp) * wl + val wcol = ramp.bound((0.4f + 0.5f * cr.colT) * (ramp.size - 1)) + val wp: Paint = strokeOf(wcol, SIZE * 0.0009f).alpha(10) + wp.strokeCap = PaintStrokeCap.ROUND + c.drawLine(Point(wx0, wy0), Point(wx1, wy1), wp) + + // crisp ink line segment for this advance + val ink: Paint = strokeOf( + ramp.bound((0.55f + 0.45f * cr.colT) * (ramp.size - 1)), + SIZE * 0.0012f + ).alpha(220) + ink.strokeCap = PaintStrokeCap.ROUND + ink.isAntiAlias = true + c.drawLine(Point(cr.px, cr.py), Point(cr.x, cr.y), ink) + } + // keep the field replenished while there's headroom + if (cracks.count { it.alive } < 6 && cracks.size < maxCracks) { + repeat(2) { spawn(true) } + } + } + println(" ${cracks.size} cracks, guard=$guard") + + val finalv = bloom(gart, out.snapshot(), ground, SIZE * 0.0035f, grain = 0.06f) + gart.saveImage(finalv) +} diff --git a/arts/gen/src/gen/WaveCollapse.kt b/arts/gen/src/gen/WaveCollapse.kt new file mode 100644 index 00000000..8c8384e6 --- /dev/null +++ b/arts/gen/src/gen/WaveCollapse.kt @@ -0,0 +1,308 @@ +package gen + +import dev.oblac.gart.Gart +import dev.oblac.gart.color.Palette +import dev.oblac.gart.gfx.drawArc +import dev.oblac.gart.gfx.drawLine +import dev.oblac.gart.gfx.fillOf +import dev.oblac.gart.gfx.strokeOf +import dev.oblac.gart.math.* +import dev.oblac.gart.noise.OpenSimplexNoise +import kotlin.random.Random +import org.jetbrains.skia.Canvas +import org.jetbrains.skia.PaintMode +import org.jetbrains.skia.PaintStrokeCap +import org.jetbrains.skia.Point +import org.jetbrains.skia.Rect + +private val SIZE: Int = System.getProperty("GART_SIZE")?.toIntOrNull() ?: 1024 +private val SEED: Long = System.getProperty("GART_SEED")?.toLongOrNull() ?: Random.nextLong() + +// ── SYSTEM · Wave-Function-Collapse circuit tapestry ─────────────────────────── +// A genuine WFC / model-synthesis run over a square grid of "pipe/circuit" tiles. +// Each tile carries N/E/S/W socket booleans; adjacency requires touching edges to +// agree (both open or both closed). Standard observe → collapse → propagate loop: +// pick the min-entropy undecided cell, collapse to a weighted-random tile, propagate +// the open/closed constraints to neighbours via a stack. On contradiction we don't +// crash — we fall back to the least-constrained pick for that cell and keep going. +// The collapsed field is rendered as crisp ink line-work (thick rounded pipe strokes, +// filled junction nodes) tinted from "Patriot Gold" by a low-freq simplex field so +// regions harmonise into a dense circuit-board labyrinth with emergent connected paths. + +// Tile = sockets in order N, E, S, W (true == open / pipe exits that edge), plus weight. +private class Tile( + val n: Boolean, val e: Boolean, val s: Boolean, val w: Boolean, + val weight: Float, +) + +// Base prototypes; we rotate them to fill the full socket space. +private fun buildTiles(): List { + val out = ArrayList() + fun add(n: Boolean, e: Boolean, s: Boolean, w: Boolean, weight: Float) { + // emit the 4 rotations, de-duplicated by socket signature + var (cn, ce, cs, cw) = listOf(n, e, s, w) + val seen = HashSet() + repeat(4) { + val sig = (if (cn) 1 else 0) or (if (ce) 2 else 0) or (if (cs) 4 else 0) or (if (cw) 8 else 0) + if (seen.add(sig)) out.add(Tile(cn, ce, cs, cw, weight)) + // rotate 90° clockwise: N<-W, E<-N, S<-E, W<-S + val nn = cw; val ne = cn; val ns = ce; val nw = cs + cn = nn; ce = ne; cs = ns; cw = nw + } + } + add(false, false, false, false, 0.6f) // blank (rest space) + add(true, false, true, false, 2.2f) // straight + add(true, true, false, false, 2.4f) // corner / elbow + add(true, true, true, false, 1.3f) // T-junction + add(true, true, true, true, 0.7f) // cross + add(true, false, false, false, 1.0f) // terminal / stub + return out +} + +private lateinit var rng: Random +private lateinit var noise: OpenSimplexNoise +private lateinit var ramp: Palette +private lateinit var tiles: List + +// open-edge of tile t in direction dir (0=N,1=E,2=S,3=W) +private fun openOf(t: Tile, dir: Int): Boolean = when (dir) { + 0 -> t.n; 1 -> t.e; 2 -> t.s; else -> t.w +} + +private const val N = 0; private const val E = 1; private const val S = 2; private const val W = 3 +private val DX = intArrayOf(0, 1, 0, -1) +private val DY = intArrayOf(-1, 0, 1, 0) +private val OPP = intArrayOf(S, W, N, E) + +fun main() { + println("seed=$SEED") + rng = Random(SEED) + + val gart = Gart.of("WaveCollapse", SIZE, SIZE) + val d = gart.d + noise = OpenSimplexNoise(SEED) + ramp = Coolors.patriotGold.expand(256) + tiles = buildTiles() + val tcount = tiles.size + + val ground = 0xFFEFE7CC.toInt() + val ink = 0xFF1A2330.toInt() + + // ── grid ──────────────────────────────────────────────────────────────── + val cells = 48 + // possibility set per cell: a boolean mask over tiles + val poss = Array(cells * cells) { BooleanArray(tcount) { true } } + val collapsed = IntArray(cells * cells) { -1 } + + fun idx(x: Int, y: Int) = y * cells + x + fun inB(x: Int, y: Int) = x in 0 until cells && y in 0 until cells + + fun count(i: Int): Int = poss[i].count { it } + + // propagate constraints from a just-changed cell using a stack + fun propagate(start: Int) { + val stack = ArrayDeque() + stack.addLast(start) + while (stack.isNotEmpty()) { + val ci = stack.removeLast() + val cx = ci % cells; val cy = ci / cells + for (dir in 0 until 4) { + val nx = cx + DX[dir]; val ny = cy + DY[dir] + if (!inB(nx, ny)) continue + val ni = idx(nx, ny) + if (collapsed[ni] >= 0) continue + // which "open" states for the shared edge are still supported by ci? + var allowOpen = false; var allowClosed = false + val cm = poss[ci] + for (t in 0 until tcount) { + if (!cm[t]) continue + if (openOf(tiles[t], dir)) allowOpen = true else allowClosed = true + if (allowOpen && allowClosed) break + } + if (allowOpen && allowClosed) continue // neighbour unconstrained on this edge + // restrict neighbour: its facing edge (OPP[dir]) must match what's allowed + var changed = false + val nm = poss[ni] + for (t in 0 until tcount) { + if (!nm[t]) continue + val no = openOf(tiles[t], OPP[dir]) + val ok = (no && allowOpen) || (!no && allowClosed) + if (!ok) { nm[t] = false; changed = true } + } + if (changed) { + // contradiction guard: never let a cell empty out + if (nm.none { it }) { + // fallback — re-open the least-bad single option so we never crash + var best = 0; var bestW = -1f + for (t in 0 until tcount) { + val no = openOf(tiles[t], OPP[dir]) + val ok = (no && allowOpen) || (!no && allowClosed) + if (ok && tiles[t].weight > bestW) { bestW = tiles[t].weight; best = t } + } + nm[best] = true + } + stack.addLast(ni) + } + } + } + } + + fun collapseCell(i: Int) { + val mask = poss[i] + // weighted random over remaining options, biased a touch by the simplex + // field so regions favour denser or sparser circuitry coherently. + val cx = (i % cells).toFloat(); val cy = (i / cells).toFloat() + val field = noise.random2D(cx * 0.06f, cy * 0.06f) * 0.5f + 0.5f + var total = 0f + for (t in 0 until tcount) if (mask[t]) { + val openCount = (if (tiles[t].n) 1 else 0) + (if (tiles[t].e) 1 else 0) + + (if (tiles[t].s) 1 else 0) + (if (tiles[t].w) 1 else 0) + val densityBias = 1f + (field - 0.5f) * 0.6f * (openCount - 2) + total += tiles[t].weight * densityBias.coerceAtLeast(0.05f) + } + var r = rng.nextFloat() * total + var chosen = -1 + for (t in 0 until tcount) if (mask[t]) { + val openCount = (if (tiles[t].n) 1 else 0) + (if (tiles[t].e) 1 else 0) + + (if (tiles[t].s) 1 else 0) + (if (tiles[t].w) 1 else 0) + val densityBias = 1f + (field - 0.5f) * 0.6f * (openCount - 2) + r -= tiles[t].weight * densityBias.coerceAtLeast(0.05f) + if (r <= 0f) { chosen = t; break } + } + if (chosen < 0) chosen = (0 until tcount).first { mask[it] } + for (t in 0 until tcount) mask[t] = t == chosen + collapsed[i] = chosen + } + + // border cells must have closed outer edges so paths read as contained + for (x in 0 until cells) for (y in 0 until cells) { + val m = poss[idx(x, y)] + for (t in 0 until tcount) { + if (!m[t]) continue + if ((y == 0 && tiles[t].n) || (y == cells - 1 && tiles[t].s) || + (x == 0 && tiles[t].w) || (x == cells - 1 && tiles[t].e)) m[t] = false + } + if (m.none { it }) m[0] = true // blank tile is index 0 + } + for (x in 0 until cells) for (y in 0 until cells) { + if (x == 0 || y == 0) propagate(idx(x, y)) + } + + // ── observe loop ────────────────────────────────────────────────────────── + var remaining = cells * cells + // some may already be forced to a single option from border propagation + for (i in 0 until cells * cells) if (count(i) == 1 && collapsed[i] < 0) { + collapsed[i] = (0 until tcount).first { poss[i][it] }; remaining-- + } + var guard = cells * cells * 4 + while (remaining > 0 && guard-- > 0) { + // min-entropy: fewest remaining options (>1), tiny noise to break ties + var bestI = -1; var bestC = Int.MAX_VALUE; var bestNoise = 0f + for (i in 0 until cells * cells) { + if (collapsed[i] >= 0) continue + val c = count(i) + val jitter = rng.nextFloat() * 0.5f + if (c < bestC || (c == bestC && jitter > bestNoise)) { + bestC = c; bestI = i; bestNoise = jitter + } + } + if (bestI < 0) break + collapseCell(bestI) + remaining-- + propagate(bestI) + // sweep up any cells pinned to a single option by propagation + for (i in 0 until cells * cells) if (collapsed[i] < 0 && count(i) == 1) { + collapsed[i] = (0 until tcount).first { poss[i][it] }; remaining-- + } + } + // any stragglers (shouldn't happen): force blank + for (i in 0 until cells * cells) if (collapsed[i] < 0) { + collapsed[i] = (0 until tcount).firstOrNull { poss[i][it] } ?: 0 + } + + // ── render ──────────────────────────────────────────────────────────────── + val buf = gart.gartvas() + val c = buf.canvas + c.clear(ground) + + val cellSize = SIZE.toFloat() / cells + val pipeW = cellSize * 0.34f + val nodeR = cellSize * 0.20f + + fun colorAt(gx: Int, gy: Int): Int { + val nv = (noise.random2D(gx * 0.045f + 11f, gy * 0.045f).toFloat() * 0.5f + 0.5f).coerceIn(0f, 1f) + return ramp.bound(nv * (ramp.size - 1)) + } + + for (gy in 0 until cells) for (gx in 0 until cells) { + val t = tiles[collapsed[idx(gx, gy)]] + val ox = gx * cellSize; val oy = gy * cellSize + val mx = ox + cellSize / 2f; val my = oy + cellSize / 2f + val col = colorAt(gx, gy) + // soft drop of ink underlay for crispness, then colour + val under = strokeOf(ink, pipeW * 1.32f).apply { + mode = PaintMode.STROKE; strokeCap = PaintStrokeCap.ROUND; isAntiAlias = true + } + val pipe = strokeOf(col, pipeW).apply { + mode = PaintMode.STROKE; strokeCap = PaintStrokeCap.ROUND; isAntiAlias = true + } + val open = booleanArrayOf(t.n, t.e, t.s, t.w) + val ends = arrayOf( + Point(mx, oy), Point(ox + cellSize, my), Point(mx, oy + cellSize), Point(ox, my) + ) + val openCount = open.count { it } + // draw underlay then colour, both passes + for (pass in 0..1) { + val paint = if (pass == 0) under else pipe + if (openCount == 2 && ((open[N] && open[S]) || (open[E] && open[W]))) { + // straight: single line through centre + if (open[N] && open[S]) c.drawLine(ends[N], ends[S], paint) + else c.drawLine(ends[E], ends[W], paint) + } else if (openCount == 2) { + // corner: arc between the two open edges for a smooth elbow + // determine which corner and draw a quarter arc centred on it + val (a, b) = run { + val ix = (0..3).filter { open[it] } + Pair(ix[0], ix[1]) + } + drawElbow(c, ox, oy, cellSize, a, b, paint) + } else { + // T, cross, terminal, blank: spokes from centre to each open edge + for (dir in 0 until 4) if (open[dir]) c.drawLine(Point(mx, my), ends[dir], paint) + } + } + // junction node on T/cross, small cap on terminal + if (openCount >= 3) { + c.drawCircle(mx, my, nodeR, fillOf(ink)) + c.drawCircle(mx, my, nodeR * 0.6f, fillOf(col)) + } else if (openCount == 1) { + c.drawCircle(mx, my, nodeR * 0.72f, fillOf(ink)) + } + } + + // bold framing border + val frame = strokeOf(ink, SIZE * 0.016f).apply { mode = PaintMode.STROKE; isAntiAlias = true } + val inset = SIZE * 0.012f + c.drawRect(Rect.makeLTRB(inset, inset, SIZE - inset, SIZE - inset), frame) + val frameGold = strokeOf(0xFFFCBF49.toInt(), SIZE * 0.005f).apply { mode = PaintMode.STROKE; isAntiAlias = true } + val inset2 = SIZE * 0.022f + c.drawRect(Rect.makeLTRB(inset2, inset2, SIZE - inset2, SIZE - inset2), frameGold) + + val finalv = grainOnly(gart, buf.snapshot(), grain = 0.055f) + gart.saveImage(finalv) + println(" WFC done (${cells}x$cells grid, $tcount tiles)") +} + +// quarter-circle elbow connecting two adjacent open edges of a cell +private fun drawElbow(c: Canvas, ox: Float, oy: Float, s: Float, a: Int, b: Int, paint: org.jetbrains.skia.Paint) { + val r = s / 2f + // corner shared by directions a,b → arc centre at that corner, radius r + val set = setOf(a, b) + val (cxp, cyp, start) = when { + set == setOf(N, E) -> Triple(ox + s, oy, 90f) // centre top-right + set == setOf(E, S) -> Triple(ox + s, oy + s, 180f) // centre bottom-right + set == setOf(S, W) -> Triple(ox, oy + s, 270f) // centre bottom-left + else -> Triple(ox, oy, 0f) // N,W centre top-left + } + c.drawArc(Rect.makeLTRB(cxp - r, cyp - r, cxp + r, cyp + r), start, 90f, false, paint) +} diff --git a/out/BarnsleyIFS.png b/out/BarnsleyIFS.png new file mode 100644 index 00000000..68864f9c Binary files /dev/null and b/out/BarnsleyIFS.png differ diff --git a/out/LeniaBloom.png b/out/LeniaBloom.png new file mode 100644 index 00000000..bd00c9eb Binary files /dev/null and b/out/LeniaBloom.png differ diff --git a/out/MarbleAgate.png b/out/MarbleAgate.png new file mode 100644 index 00000000..26aab467 Binary files /dev/null and b/out/MarbleAgate.png differ diff --git a/out/PenroseTiling.png b/out/PenroseTiling.png new file mode 100644 index 00000000..56143d11 Binary files /dev/null and b/out/PenroseTiling.png differ diff --git a/out/SubstrateCracks.png b/out/SubstrateCracks.png new file mode 100644 index 00000000..5a52940a Binary files /dev/null and b/out/SubstrateCracks.png differ diff --git a/out/WaveCollapse.png b/out/WaveCollapse.png new file mode 100644 index 00000000..a9410697 Binary files /dev/null and b/out/WaveCollapse.png differ