changed background
This commit is contained in:
@@ -186,3 +186,86 @@ button {
|
|||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── sky ────────────────────────────────────────────────────────────────
|
||||||
|
Generated, not photographed. tools/background.py renders every style into
|
||||||
|
img/bg/; inc/header.php enumerates that directory and emits one custom
|
||||||
|
property per style, so --bg-img follows html[data-bg]. Only the matching
|
||||||
|
rule's url() is fetched. Each is Bayer ordered-dithered to a few grey
|
||||||
|
levels and upscaled with pixelated rendering so the cells stay crunchy.
|
||||||
|
Regenerate everything: python tools/background.py all */
|
||||||
|
:root {
|
||||||
|
--bg: #060606;
|
||||||
|
/* opacity of the text-column scrim; raise for busier background styles */
|
||||||
|
--scrim: .82;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { position: relative; }
|
||||||
|
|
||||||
|
/* background layer */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -2;
|
||||||
|
background: #060606 var(--bg-img) center center / cover no-repeat;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Readability scrim + diagonal hatch.
|
||||||
|
The first layer is a feathered vertical band, ~880px wide and centred on
|
||||||
|
the 680px text column, sitting above the artwork (z-index -2) but below
|
||||||
|
the content. It gives every background style a consistent dark bed to
|
||||||
|
read against instead of relying on each one being quiet in the middle,
|
||||||
|
and because the edges fade out there is no visible panel or card. */
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(4, 4, 4, var(--scrim)) 14%,
|
||||||
|
rgba(4, 4, 4, var(--scrim)) 86%,
|
||||||
|
transparent 100%),
|
||||||
|
radial-gradient(ellipse 70% 60% at 50% 38%, rgba(4, 4, 4, .32), transparent 82%),
|
||||||
|
repeating-linear-gradient(-45deg, rgba(240, 240, 240, .045) 0 1px, transparent 1px 8px);
|
||||||
|
background-size: 880px 100%, auto, auto;
|
||||||
|
background-position: center top, center, center;
|
||||||
|
background-repeat: no-repeat, no-repeat, repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cards sat on flat #161616 before; let the sky read through them */
|
||||||
|
.status-card,
|
||||||
|
.spotify-card,
|
||||||
|
.post-body pre { background-color: rgba(6, 6, 6, .66); }
|
||||||
|
input, textarea { background: rgba(6, 6, 6, .66); }
|
||||||
|
|
||||||
|
/* guestbook sign button */
|
||||||
|
.btn-sign {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
background: var(--fg);
|
||||||
|
color: var(--bg);
|
||||||
|
border: 1px solid var(--fg);
|
||||||
|
padding: .5rem 1.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .12s ease, color .12s ease, transform .08s ease;
|
||||||
|
}
|
||||||
|
.btn-sign .arrow { transition: transform .15s ease; }
|
||||||
|
.btn-sign:hover:not(:disabled) { background: transparent; color: var(--fg); }
|
||||||
|
.btn-sign:hover:not(:disabled) .arrow { transform: translateX(3px); }
|
||||||
|
.btn-sign:active:not(:disabled) { transform: translateY(1px); }
|
||||||
|
.btn-sign:focus-visible { outline: 2px solid var(--muted); outline-offset: 2px; }
|
||||||
|
.btn-sign:disabled { opacity: .55; cursor: progress; }
|
||||||
|
|
||||||
|
/* htmx swaps the label while the request is in flight */
|
||||||
|
.btn-sign .pending { display: none; }
|
||||||
|
.btn-sign.htmx-request .idle { display: none; }
|
||||||
|
.btn-sign.htmx-request .pending { display: inline; }
|
||||||
|
.btn-sign.htmx-request .arrow { display: none; }
|
||||||
|
|||||||
+7
-1
@@ -98,12 +98,18 @@ require __DIR__ . '/inc/header.php';
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form hx-post="/guestbook.php" hx-target="#entries" hx-swap="innerHTML"
|
<form hx-post="/guestbook.php" hx-target="#entries" hx-swap="innerHTML"
|
||||||
|
hx-indicator="#sign" hx-disabled-elt="#sign"
|
||||||
|
hx-on::after-request="if (event.detail.successful) this.reset()"
|
||||||
method="post" action="/guestbook.php">
|
method="post" action="/guestbook.php">
|
||||||
<input name="name" placeholder="name" maxlength="40" required>
|
<input name="name" placeholder="name" maxlength="40" required>
|
||||||
<textarea name="msg" placeholder="say something…" maxlength="250" required></textarea>
|
<textarea name="msg" placeholder="say something…" maxlength="250" required></textarea>
|
||||||
<input name="url" tabindex="-1" autocomplete="off" aria-hidden="true"
|
<input name="url" tabindex="-1" autocomplete="off" aria-hidden="true"
|
||||||
style="position:absolute;left:-9999px" placeholder="leave this empty">
|
style="position:absolute;left:-9999px" placeholder="leave this empty">
|
||||||
<button type="submit">sign</button>
|
<button type="submit" id="sign" class="btn-sign">
|
||||||
|
<span class="idle">sign</span>
|
||||||
|
<span class="pending">signing…</span>
|
||||||
|
<span class="arrow" aria-hidden="true">→</span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div id="entries">
|
<div id="entries">
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Backgrounds are just PNGs in img/bg/, generated by tools/background.py.
|
||||||
|
* Drop a new one in and it joins the cycle automatically -- this list drives
|
||||||
|
* both the CSS custom properties and the footer cycler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function bg_styles(): array
|
||||||
|
{
|
||||||
|
static $names = null;
|
||||||
|
if ($names !== null) {
|
||||||
|
return $names;
|
||||||
|
}
|
||||||
|
$files = glob(__DIR__ . '/../img/bg/*.png') ?: [];
|
||||||
|
$names = array_map(static fn($f) => pathinfo($f, PATHINFO_FILENAME), $files);
|
||||||
|
sort($names);
|
||||||
|
return $names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bg_default(): string
|
||||||
|
{
|
||||||
|
$all = bg_styles();
|
||||||
|
return in_array('flow', $all, true) ? 'flow' : ($all[0] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A different style on every page load. */
|
||||||
|
function bg_pick(): string
|
||||||
|
{
|
||||||
|
$all = bg_styles();
|
||||||
|
return $all ? $all[random_int(0, count($all) - 1)] : '';
|
||||||
|
}
|
||||||
@@ -10,5 +10,21 @@ $build = preg_match('/[0-9a-f]{7,40}/i', $build, $m) ? $m[0] : ($build ?: 'dev')
|
|||||||
</div>
|
</div>
|
||||||
<small>© <?= date('Y') ?> · build: <a href="<?= htmlspecialchars(REPO_URL) ?>"><?= htmlspecialchars($build) ?></a></small>
|
<small>© <?= date('Y') ?> · build: <a href="<?= htmlspecialchars(REPO_URL) ?>"><?= htmlspecialchars($build) ?></a></small>
|
||||||
</footer>
|
</footer>
|
||||||
|
<?php require_once __DIR__ . '/backgrounds.php'; $bgs = bg_styles(); ?>
|
||||||
|
<?php if (count($bgs) > 1): ?>
|
||||||
|
<script>
|
||||||
|
/* Unlabelled: press b to flick through the backgrounds without reloading.
|
||||||
|
Not persisted -- the server picks a fresh one on every load anyway. */
|
||||||
|
(function () {
|
||||||
|
var all = <?= json_encode($bgs) ?>, root = document.documentElement;
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key !== 'b' || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
var t = e.target;
|
||||||
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||||
|
root.dataset.bg = all[(all.indexOf(root.dataset.bg) + 1) % all.length];
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+20
-2
@@ -1,12 +1,30 @@
|
|||||||
<?php require_once __DIR__ . '/../config.php'; ?>
|
<?php
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/backgrounds.php';
|
||||||
|
$bgs = bg_styles();
|
||||||
|
$bgPick = bg_pick();
|
||||||
|
// the page picks a style per request, so don't let a cached copy pin one
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en"<?= $bgPick !== '' ? ' data-bg="' . htmlspecialchars($bgPick) . '"' : '' ?>>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title><?= htmlspecialchars($page_title ?? SITE_TITLE) ?></title>
|
<title><?= htmlspecialchars($page_title ?? SITE_TITLE) ?></title>
|
||||||
<link rel="icon" href="/img/favicon.ico" sizes="any">
|
<link rel="icon" href="/img/favicon.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<?php if ($bgs): ?>
|
||||||
|
<style>
|
||||||
|
/* One custom property per style; data-bg on <html> selects which.
|
||||||
|
Only the matching rule's url() is ever fetched, so the styles that
|
||||||
|
are not showing cost nothing but a line of CSS. */
|
||||||
|
:root { --bg-img: url("/img/bg/<?= rawurlencode(bg_default()) ?>.png"); }
|
||||||
|
<?php foreach ($bgs as $b): ?>
|
||||||
|
html[data-bg="<?= htmlspecialchars($b) ?>"] { --bg-img: url("/img/bg/<?= rawurlencode($b) ?>.png"); }
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</style>
|
||||||
|
<?php endif; ?>
|
||||||
<script src="/js/htmx.min.js" defer></script>
|
<script src="/js/htmx.min.js" defer></script>
|
||||||
<script src="/js/idiomorph-ext.min.js" defer></script>
|
<script src="/js/idiomorph-ext.min.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,299 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the dithered backgrounds for 0xa0.dev.
|
||||||
|
|
||||||
|
python tools/background.py all # every style -> img/bg/
|
||||||
|
python tools/background.py attractor # just one
|
||||||
|
python tools/background.py flow out.png # explicit destination
|
||||||
|
python tools/background.py forest img/bg/forest-2.png 21 # pick a seed
|
||||||
|
|
||||||
|
All procedural, no source photos. Each is Bayer ordered-dithered to a few
|
||||||
|
grey levels so it matches the terminal aesthetic and stays small. Drop a new
|
||||||
|
PNG into img/bg/ and the site picks it up automatically -- inc/header.php
|
||||||
|
enumerates that directory, so nothing else needs editing.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
import sys, os
|
||||||
|
|
||||||
|
W, H = 1400, 875
|
||||||
|
OUT = 'img/bg'
|
||||||
|
|
||||||
|
|
||||||
|
# -- helpers ----------------------------------------------------------
|
||||||
|
def vnoise(res, seed, w=W, h=H):
|
||||||
|
r = np.random.default_rng(seed).random((res + 1, res + 1))
|
||||||
|
im = Image.fromarray((r * 255).astype(np.uint8), 'L').resize((w, h), Image.BICUBIC)
|
||||||
|
return np.asarray(im, dtype=np.float64) / 255.0
|
||||||
|
|
||||||
|
|
||||||
|
def fbm(octaves=5, base=3, seed=1, w=W, h=H):
|
||||||
|
o = np.zeros((h, w)); a = 1.0; t = 0.0
|
||||||
|
for i in range(octaves):
|
||||||
|
o += a * vnoise(base * 2 ** i, seed + i * 101, w, h); t += a; a *= 0.5
|
||||||
|
return o / t
|
||||||
|
|
||||||
|
|
||||||
|
def bayer(n):
|
||||||
|
m = np.array([[0.0]]); s = 1
|
||||||
|
while s < n:
|
||||||
|
m = np.block([[4 * m, 4 * m + 2], [4 * m + 3, 4 * m + 1]]); s *= 2
|
||||||
|
return (m + 0.5) / (s * s)
|
||||||
|
|
||||||
|
|
||||||
|
def calm(strength=0.55, width=0.32, shape=None):
|
||||||
|
"""Damp the middle of the frame, where the text column sits."""
|
||||||
|
h, w = shape or (H, W)
|
||||||
|
_, xx = np.mgrid[0:h, 0:w]
|
||||||
|
return (1 - strength) + strength * np.clip(np.abs(xx / w - 0.5) / width, 0, 1) ** 1.3
|
||||||
|
|
||||||
|
|
||||||
|
def seg(c, x0, y0, x1, y1, val=1.0):
|
||||||
|
h, w = c.shape
|
||||||
|
n = int(max(abs(x1 - x0), abs(y1 - y0))) + 1
|
||||||
|
xs = np.linspace(x0, x1, n).astype(int); ys = np.linspace(y0, y1, n).astype(int)
|
||||||
|
ok = (xs >= 0) & (xs < w) & (ys >= 0) & (ys < h)
|
||||||
|
np.add.at(c, (ys[ok], xs[ok]), val)
|
||||||
|
|
||||||
|
|
||||||
|
def dither(v, levels=4, mn=4, lo=6, hi=196, mode='bayer'):
|
||||||
|
"""Quantise to `levels` greys.
|
||||||
|
|
||||||
|
mode 'bayer': ordered dither against an n x n Bayer matrix. The matrix
|
||||||
|
size caps how many densities are distinguishable -- 4x4 gives 17 steps,
|
||||||
|
8x8 gives 65 -- so bigger matrices band far less on smooth gradients at
|
||||||
|
the cost of a finer, less chunky texture.
|
||||||
|
|
||||||
|
mode 'fs': Floyd-Steinberg error diffusion. No repeating pattern at all,
|
||||||
|
so gradients are smooth and organic, but it loses the woven/print look.
|
||||||
|
"""
|
||||||
|
v = np.clip(v, 0, 1)
|
||||||
|
h, w = v.shape
|
||||||
|
if mode == 'fs':
|
||||||
|
a = v.copy() * (levels - 1)
|
||||||
|
for yy in range(h):
|
||||||
|
for xx in range(w):
|
||||||
|
old = a[yy, xx]
|
||||||
|
new = round(min(max(old, 0), levels - 1))
|
||||||
|
err = old - new
|
||||||
|
a[yy, xx] = new
|
||||||
|
if xx + 1 < w: a[yy, xx + 1] += err * 7 / 16
|
||||||
|
if yy + 1 < h:
|
||||||
|
if xx: a[yy + 1, xx - 1] += err * 3 / 16
|
||||||
|
a[yy + 1, xx] += err * 5 / 16
|
||||||
|
if xx + 1 < w: a[yy + 1, xx + 1] += err * 1 / 16
|
||||||
|
out = np.clip(a, 0, levels - 1) / (levels - 1)
|
||||||
|
else:
|
||||||
|
t = bayer(mn); th = np.tile(t, (h // mn + 1, w // mn + 1))[:h, :w]
|
||||||
|
s = v * (levels - 1); low = np.floor(s)
|
||||||
|
out = np.clip(low + (s - low > th), 0, levels - 1) / (levels - 1)
|
||||||
|
g = (lo + out * (hi - lo)).astype(np.uint8)
|
||||||
|
return Image.fromarray(g, 'L').convert('P', palette=Image.ADAPTIVE, colors=levels)
|
||||||
|
|
||||||
|
|
||||||
|
# -- styles -----------------------------------------------------------
|
||||||
|
def flow(seed=29):
|
||||||
|
"""Particles advected through a noise vector field; the bright filaments
|
||||||
|
are where the field converges and thousands of paths pile up."""
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
ang = fbm(4, 3, seed) * np.pi * 3.0
|
||||||
|
c = np.zeros((H, W)); n = 22000
|
||||||
|
px = rng.random(n) * W; py = rng.random(n) * H
|
||||||
|
for _ in range(240):
|
||||||
|
a = ang[np.clip(py.astype(int), 0, H - 1), np.clip(px.astype(int), 0, W - 1)]
|
||||||
|
px += np.cos(a) * 1.3; py += np.sin(a) * 1.3
|
||||||
|
ok = (px >= 0) & (px < W) & (py >= 0) & (py < H)
|
||||||
|
np.add.at(c, (py[ok].astype(int), px[ok].astype(int)), 1.0)
|
||||||
|
return np.clip(c / np.percentile(c, 99.5), 0, 1) ** 0.9 * calm() * 0.92, {}
|
||||||
|
|
||||||
|
|
||||||
|
def attractor(a=-1.7, b=1.8, cc=-1.9, d=-0.4, seed=3):
|
||||||
|
"""Clifford attractor: x' = sin(a*y) + c*cos(a*x). The bright edges are
|
||||||
|
density caustics where orbits crowd together."""
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
n = 260000; x = rng.uniform(-2, 2, n); y = rng.uniform(-2, 2, n)
|
||||||
|
c = np.zeros((H, W))
|
||||||
|
for i in range(70):
|
||||||
|
x, y = np.sin(a * y) + cc * np.cos(a * x), np.sin(b * x) + d * np.cos(b * y)
|
||||||
|
if i > 8:
|
||||||
|
px = ((x + 2.6) / 5.2 * W).astype(int)
|
||||||
|
py = ((y + 2.6) / 5.2 * H).astype(int)
|
||||||
|
ok = (px >= 0) & (px < W) & (py >= 0) & (py < H)
|
||||||
|
np.add.at(c, (py[ok], px[ok]), 1.0)
|
||||||
|
return np.clip(c / np.percentile(c, 99.6), 0, 1) ** 0.75 * 0.95, {}
|
||||||
|
|
||||||
|
|
||||||
|
def truchet(cell=46, seed=5):
|
||||||
|
"""Quarter-arc maze tiles. Regular and quiet; genuinely tileable."""
|
||||||
|
c = np.zeros((H, W)); rng = np.random.default_rng(seed)
|
||||||
|
yy, xx = np.mgrid[0:H, 0:W]
|
||||||
|
for gy in range(0, H, cell):
|
||||||
|
for gx in range(0, W, cell):
|
||||||
|
sl = (slice(gy, min(gy + cell, H)), slice(gx, min(gx + cell, W)))
|
||||||
|
ly = yy[sl] - gy; lx = xx[sl] - gx
|
||||||
|
if rng.random() < 0.5:
|
||||||
|
d1 = np.hypot(lx, ly); d2 = np.hypot(lx - cell, ly - cell)
|
||||||
|
else:
|
||||||
|
d1 = np.hypot(lx - cell, ly); d2 = np.hypot(lx, ly - cell)
|
||||||
|
r = cell / 2.0
|
||||||
|
c[sl] = np.maximum(np.clip(1 - np.abs(d1 - r) / 2.0, 0, 1),
|
||||||
|
np.clip(1 - np.abs(d2 - r) / 2.0, 0, 1))
|
||||||
|
return c * 0.78, {'levels': 3}
|
||||||
|
|
||||||
|
|
||||||
|
def circuit(seed=13, g=16, traces=150):
|
||||||
|
"""PCB-style routed traces at 45/90 degrees, with vias."""
|
||||||
|
c = np.zeros((H, W)); rng = np.random.default_rng(seed)
|
||||||
|
dirs = [(g, 0), (-g, 0), (0, g), (0, -g), (g, g), (-g, g), (g, -g), (-g, -g)]
|
||||||
|
yy, xx = np.mgrid[0:H, 0:W]
|
||||||
|
for _ in range(traces):
|
||||||
|
x, y = int(rng.integers(0, W // g)) * g, int(rng.integers(0, H // g)) * g
|
||||||
|
dx, dy = dirs[int(rng.integers(len(dirs)))]
|
||||||
|
for _ in range(int(rng.integers(8, 30))):
|
||||||
|
nx, ny = x + dx, y + dy
|
||||||
|
if not (0 <= nx < W and 0 <= ny < H):
|
||||||
|
break
|
||||||
|
seg(c, x, y, nx, ny, 1.0); x, y = nx, ny
|
||||||
|
if rng.random() < 0.28:
|
||||||
|
dx, dy = dirs[int(rng.integers(len(dirs)))]
|
||||||
|
if rng.random() < 0.45:
|
||||||
|
c += np.clip(1 - np.abs(np.hypot(xx - x, yy - y) - 3.5) / 1.6, 0, 1) * 0.9
|
||||||
|
return np.clip(c, 0, 1) * calm(0.5) * 0.72, {'levels': 3}
|
||||||
|
|
||||||
|
|
||||||
|
def reaction(seed=9, steps=3800):
|
||||||
|
"""Gray-Scott reaction-diffusion. Striking but dense, so it is knocked
|
||||||
|
well back in tone to stay readable behind text."""
|
||||||
|
gw, gh = 450, 282
|
||||||
|
U = np.ones((gh, gw)); V = np.zeros((gh, gw))
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
for _ in range(26):
|
||||||
|
sx, sy = int(rng.integers(12, gw - 12)), int(rng.integers(12, gh - 12))
|
||||||
|
U[sy - 5:sy + 5, sx - 5:sx + 5] = 0.5
|
||||||
|
V[sy - 5:sy + 5, sx - 5:sx + 5] = 0.25
|
||||||
|
Du, Dv, F, k = 0.16, 0.08, 0.0545, 0.062
|
||||||
|
|
||||||
|
def lap(Z):
|
||||||
|
return (np.roll(Z, 1, 0) + np.roll(Z, -1, 0)
|
||||||
|
+ np.roll(Z, 1, 1) + np.roll(Z, -1, 1) - 4 * Z)
|
||||||
|
|
||||||
|
for _ in range(steps):
|
||||||
|
uvv = U * V * V
|
||||||
|
U += Du * lap(U) - uvv + F * (1 - U)
|
||||||
|
V += Dv * lap(V) + uvv - (F + k) * V
|
||||||
|
v = np.clip(V / max(V.max(), 1e-6), 0, 1)
|
||||||
|
v = np.asarray(Image.fromarray((v * 255).astype(np.uint8), 'L')
|
||||||
|
.resize((W, H), Image.BICUBIC)) / 255.0
|
||||||
|
return v ** 1.1 * calm(0.45) * 0.42, {}
|
||||||
|
|
||||||
|
|
||||||
|
def ridge(seed=900, lines=40):
|
||||||
|
"""Stacked ridgelines, Joy Division style."""
|
||||||
|
c = np.zeros((H, W))
|
||||||
|
for i in range(lines):
|
||||||
|
base = H * 0.10 + i * (H * 0.84 / lines)
|
||||||
|
prof = fbm(5, 4, seed + i * 7, w=W, h=8)[0]
|
||||||
|
amp = (H * 0.84 / lines) * 11.0 * (0.25 + 0.75 * np.exp(-((i / lines - 0.5) ** 2) / 0.055))
|
||||||
|
ys = base - (prof - prof.mean()) * amp
|
||||||
|
for x in range(W - 1):
|
||||||
|
seg(c, x, ys[x], x + 1, ys[x + 1], 1.0)
|
||||||
|
return np.clip(c, 0, 1) * 0.85, {'levels': 3}
|
||||||
|
|
||||||
|
|
||||||
|
def topo(seed=11, bands=16):
|
||||||
|
"""Iso-contour map of a fractal-noise height field."""
|
||||||
|
h = fbm(6, 3, seed)
|
||||||
|
d = np.abs((h * bands) % 1.0 - 0.5)
|
||||||
|
return (np.clip(1 - d * 7.0, 0, 1) ** 2.2 * 0.7 + h * 0.12) * calm(0.4), {}
|
||||||
|
|
||||||
|
|
||||||
|
def _pine(c, cx, base, h, w, val):
|
||||||
|
"""One tiered conifer, drawn as rows of spans so the edges stay blocky."""
|
||||||
|
fh, fw = c.shape
|
||||||
|
top = int(base - h)
|
||||||
|
rows = np.arange(max(top, 0), min(int(base), fh))
|
||||||
|
if rows.size == 0:
|
||||||
|
return
|
||||||
|
t = (rows - top) / max(h, 1.0)
|
||||||
|
tier = (t * 3.0) % 1.0 # three stacked skirts
|
||||||
|
hw = w * (0.10 + 0.90 * t) * (0.55 + 0.45 * tier)
|
||||||
|
for r, half in zip(rows, hw):
|
||||||
|
x0 = max(int(cx - half), 0); x1 = min(int(cx + half) + 1, fw)
|
||||||
|
if x1 > x0:
|
||||||
|
c[r, x0:x1] = val
|
||||||
|
|
||||||
|
|
||||||
|
def forest(seed=4, layers=4):
|
||||||
|
"""1-bit pixel-art conifer forest.
|
||||||
|
|
||||||
|
Composition follows the knitwear reference: dark starry sky across the
|
||||||
|
top (where the text column sits), a dithered mist band at the horizon,
|
||||||
|
then receding layers of pines. Trees are drawn bright against the dark
|
||||||
|
sky -- each gets a dark outline pass first, so overlapping trees stay
|
||||||
|
separate instead of merging into one white mass. Rendered small and
|
||||||
|
upscaled by CSS with image-rendering: pixelated, so it reads as sprite
|
||||||
|
art rather than a photo.
|
||||||
|
"""
|
||||||
|
fw, fh = 760, 475
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
v = np.zeros((fh, fw))
|
||||||
|
y = np.arange(fh)[:, None] / fh
|
||||||
|
ones = np.ones((1, fw))
|
||||||
|
horizon = 0.66
|
||||||
|
|
||||||
|
# stars, denser toward the top of the sky
|
||||||
|
n = 440
|
||||||
|
sx = rng.integers(0, fw, n)
|
||||||
|
sy = (rng.random(n) ** 2.3 * fh * horizon).astype(int)
|
||||||
|
v[sy, sx] = 1.0
|
||||||
|
|
||||||
|
# mist: a narrow glow hugging the horizon, not a wash over the whole
|
||||||
|
# middle. The ordered dither turns this band into the scattered
|
||||||
|
# transition that the knitwear reference has.
|
||||||
|
v = np.maximum(v, np.exp(-((y - horizon) ** 2) / 0.0040) * 0.26 * ones)
|
||||||
|
|
||||||
|
# receding tree layers, far to near. Each tree gets a slightly larger
|
||||||
|
# dark pass first so overlapping trees stay separate; the outline hugs
|
||||||
|
# the shape rather than overshooting it.
|
||||||
|
for d in range(layers):
|
||||||
|
f = d / max(layers - 1, 1)
|
||||||
|
base = fh * (horizon + 0.02 + 0.34 * f ** 1.2)
|
||||||
|
count = int(120 * (1 - 0.80 * f)) + 8
|
||||||
|
val = 0.38 + 0.36 * f
|
||||||
|
for _ in range(count):
|
||||||
|
cx = rng.random() * (fw + 140) - 70
|
||||||
|
hgt = (20 + 74 * f ** 1.3) * (0.72 + 0.56 * rng.random())
|
||||||
|
wid = hgt * (0.27 + 0.11 * rng.random())
|
||||||
|
jitter = (rng.random() - 0.5) * 12 * (0.3 + f)
|
||||||
|
_pine(v, cx, base + jitter, hgt * 1.04 + 2, wid * 1.16 + 1.2, 0.0)
|
||||||
|
_pine(v, cx, base + jitter, hgt, wid, val)
|
||||||
|
|
||||||
|
# a little ground haze at the very bottom for the nearest trees to sit on
|
||||||
|
v = np.maximum(v, np.clip((y - 0.96) / 0.04, 0, 1) * 0.30 * ones)
|
||||||
|
return v, {'levels': 2, 'mn': 8, 'hi': 205}
|
||||||
|
|
||||||
|
|
||||||
|
STYLES = {'flow': flow, 'attractor': attractor, 'truchet': truchet,
|
||||||
|
'circuit': circuit, 'reaction': reaction, 'ridge': ridge,
|
||||||
|
'topo': topo, 'forest': forest}
|
||||||
|
|
||||||
|
|
||||||
|
def render(name, dest=None, seed=None):
|
||||||
|
v, opts = STYLES[name](seed=seed) if seed is not None else STYLES[name]()
|
||||||
|
img = dither(v, **{**{'levels': 4, 'mn': 4}, **opts})
|
||||||
|
dest = dest or os.path.join(OUT, name + '.png')
|
||||||
|
os.makedirs(os.path.dirname(dest) or '.', exist_ok=True)
|
||||||
|
img.save(dest, optimize=True)
|
||||||
|
a = np.asarray(img.convert('L'))
|
||||||
|
print(" %-10s -> %-22s %3dKB mean=%.0f"
|
||||||
|
% (name, dest, os.path.getsize(dest) // 1024, a.mean()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
what = sys.argv[1] if len(sys.argv) > 1 else 'all'
|
||||||
|
if what == 'all':
|
||||||
|
for n in STYLES:
|
||||||
|
render(n)
|
||||||
|
else:
|
||||||
|
dest = sys.argv[2] if len(sys.argv) > 2 else None
|
||||||
|
seed = int(sys.argv[3]) if len(sys.argv) > 3 else None
|
||||||
|
render(what, dest, seed)
|
||||||
Reference in New Issue
Block a user