Files
boundless/public/painterly/style-impressionism.js
T
吴璨 fad9760265 自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器
- 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作
- 空间(标签)有独立地址,增删改通过接口同步到所有设备
- 口令登录(签名 Cookie 会话、登录限流、同源校验)
- 图片上传到服务器,过大照片上传前自动压缩
- 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备
- 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush)
- 选中样式与拖拽性能优化:拖动结束后才同步位置
- Docker / docker-compose 部署,README 与 CLAUDE.md 更新
2026-10-21 18:09:03 +08:00

1145 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
* style-impressionism.js — Monet.
*
* Everything here is lifted verbatim from loader.html; only the wrapper
* changed. The vocabulary lives inside an IIFE so later movements can
* reuse names like drawFlower without colliding, and reads W / H / m from
* engine.js at call time (never captured at load time).
*
* Exports eleven sibling configs sharing one vocabulary, all of them
* cards in index.html. loader.html rotates the first four (garden, dawn,
* willow, wisteria).
* ===================================================================== */
window.STYLES = window.STYLES || {};
(function () {
// The dials settled on in the playground, baked in.
// `flower` scales blooms only — not pads, leaves or the compositions around them.
// At 1.8 a willow lily grows from ~20px to ~36px on its unchanged ~60px pad, so it
// sits proud of the pad rather than on it, the way Monet paints them.
const S = { water: 0.55, coverage: 0.84, density: 0.5, scale: 1.1, soft: 0.7, glaze: 0.3, touch: 0.2, detail: 0.45, brushSize: 1.49, vivid: 0.45, flower: 1.8 };
const SCENES = {
garden: {
seed: 1874, ground: '#f6f0ea',
washes: ['#eed7dc', '#dcc3cf', '#cdd8c0', '#f1e2d2'], foliage: ['#8fae72', '#5f8656', '#3f6146', '#b8cf95'],
accents: ['#f4b3cb', '#ea8fb4', '#d8679a', '#fbd7e3', '#f7e6ec'], highlights: ['#f7e0a4', '#fff4f7'],
phrases: ['creating…', 'growing…', 'blooming…', 'catching the light…', 'finished'],
},
dawn: {
seed: 1874, ground: '#f4efe9',
washes: ['#f2c9d3', '#e8b0c6', '#c9c0d9', '#b8cfd4'], foliage: ['#b9cfae', '#7fa38f', '#5a8272', '#d5e2c4'],
accents: ['#f3b8cf', '#e79ab9', '#d97fa3', '#fbe6ee'], highlights: ['#fde7b8', '#fff7e6'],
phrases: ['creating…', 'glowing…', 'growing the reeds…', 'opening the lilies…', 'finished'],
},
willow: {
seed: 1874, ground: '#f3efe6',
washes: ['#6f8fb3', '#4f6fa0', '#a9c8d6', '#3f5a86'], foliage: ['#5f8a5a', '#8fb15b', '#31504a', '#d2c96a'],
accents: ['#f2b6c9', '#e88fb0', '#d9749a', '#fbe3ea'], highlights: ['#f6e0a0', '#fff6d6'],
phrases: ['creating…', 'painting the water…', 'hanging the willow…', 'floating the lilies…', 'finished'],
},
wisteria: {
seed: 1874, ground: '#f5efe3',
washes: ['#e9dfa0', '#d9c9b8', '#c9b9d9', '#b7c68a'], foliage: ['#9bb26f', '#7c9a5e', '#5a7a4c', '#b9c98a'],
accents: ['#b99bd6', '#9c7fc4', '#e8b3c7', '#f3d6a2', '#7f63b0'], highlights: ['#fbf1c4', '#fff8e8'],
phrases: ['creating…', 'dappling the meadow…', 'hanging the wisteria…', 'scattering blossoms…', 'finished'],
},
poppies: {
seed: 1874, ground: '#f5f0e6',
washes: ['#c9d6e3', '#a9bfd4', '#b9c98a', '#8fa86a'], foliage: ['#7f9d5e', '#5b7f4d', '#3a5a40', '#c9d47a'],
accents: ['#d9463f', '#e4614f', '#b73838', '#f2a49e'], highlights: ['#f7e7b4', '#fff6e6'],
phrases: ['creating…', 'washing the sky…', 'growing the meadow…', 'scattering poppies…', 'finished'],
},
irises: {
seed: 1874, ground: '#f4eee2',
washes: ['#e6d6b8', '#d9c9a6', '#c8b8d6', '#b6c9a2'], foliage: ['#7fa565', '#5b8452', '#3c5f45', '#b9cf8a'],
accents: ['#8a6bb8', '#6c4fa0', '#4e3a85', '#c9b3e0', '#b28fd0'], highlights: ['#f4e3a6', '#fff6e6'],
phrases: ['creating…', 'laying the path…', 'raising the blades…', 'opening the irises…', 'finished'],
},
orchard: {
seed: 1874, ground: '#f5f1e8',
washes: ['#cfdcea', '#b9cbe0', '#c5d39a', '#9db877'], foliage: ['#7fa26a', '#5c8455', '#3e5f47', '#c0d28d'],
accents: ['#f6d4dc', '#eeb6c4', '#e39bb0', '#fff1f4', '#f9e4ea'], highlights: ['#f7e6b4', '#fff8ee'],
phrases: ['creating…', 'washing the sky…', 'planting the orchard…', 'blossoming…', 'finished'],
},
// The second set. The four colour lists keep their generic names; what each
// slot means in these scenes is noted per palette.
// koi: washes = teals / foliage = deep water and the shadow fish / accents = koi oranges / highlights = cream flecks
koi: {
seed: 1874, ground: '#3f7f8a',
washes: ['#4f9aa0', '#7fc0b8', '#2f6e8a', '#a8d4c6'], foliage: ['#2a5d70', '#1f4a60', '#173a4a', '#5f9a90'],
accents: ['#e8621e', '#f08a2a', '#c9471a', '#f6b06a'], highlights: ['#f3efc9', '#e9e6a8'],
phrases: ['creating…', 'stirring the water…', 'swirling…', 'the koi come up…', 'finished'],
},
// roses: washes = lilac-grey, gold, pale blue, cream / accents = rose pinks light to deep, coral heart / highlights = gold light, white
roses: {
seed: 1874, ground: '#eee6dc',
washes: ['#d9cdc9', '#e8d9a8', '#c9c2d4', '#f1e2c4'], foliage: ['#5f7a4a', '#3f5a3a', '#2f4432', '#9bb47a'],
accents: ['#f3b4a9', '#ea8f8a', '#d96468', '#f8cfc4', '#fbe6de'], highlights: ['#f4d98a', '#fff6e6'],
phrases: ['creating…', 'dappling the light…', 'shading the leaves…', 'opening the roses…', 'finished'],
},
// oranges: washes = sky blues and a pale ground / accents = the fruit, light to deep / highlights = blossom white, rind light
oranges: {
seed: 1874, ground: '#eef0ea',
washes: ['#b7cfe2', '#9fbfd9', '#d7e3ea', '#c2d4b0'], foliage: ['#6f9a3f', '#3f6b2f', '#24401f', '#a9c35a'],
accents: ['#f28c1e', '#e5731a', '#f7a23a', '#c95a12'], highlights: ['#fff6e0', '#fdf1c9'],
phrases: ['creating…', 'clearing the sky…', 'growing the leaves…', 'ripening…', 'finished'],
},
// sail: washes = sky blue, lilac sea, pink glow, grey-blue ripple / foliage = hull and rowboat darks / accents = cloud peach
sail: {
seed: 1874, ground: '#e9e2e0',
washes: ['#b9c3d9', '#c9b4c6', '#e6b9b0', '#8d95b3'], foliage: ['#4a4f6a', '#3a3f55', '#2c3044', '#7a7f9a'],
accents: ['#f0b39a', '#e8a48e', '#f6cdb5', '#f9dccb'], highlights: ['#f8e6d2', '#fdf5ee'],
phrases: ['creating…', 'washing the sky…', 'stilling the sea…', 'raising a sail…', 'finished'],
},
};
// =====================================================================
// Drawing vocabulary (the watercolor style from the playground)
// =====================================================================
function softBlobDraw(x, y, rx, ry, color, opacity, bleed, texture, rot = 0, jit = 0.12) {
const w = S.water - 0.5;
fillStyle(color, Math.round(opacity - w * 60), clamp01(bleed + w * 0.4), clamp01(texture + w * 0.2), 0.3);
brush.polygon(jitterEllipse(x, y, rx, ry, 14, jit, rot));
}
function bloomFill(color, opacity, bleed) { fillStyle(color, opacity, clamp01(bleed + S.water * 0.3), 0.5, 0.4); }
// One bloom layer: a flat body inset under a bleeding fill of the same scalloped
// shape. The body makes the colour solid (no ground showing through), the fill
// keeps the rim wet. No outline is ever stroked, so nothing reads as a line.
function bloomShape(x, y, r, lobes, inner, color, bleed = 0.3, solid = 0.72) {
const rot = rr(0, TWO_PI);
washStyle(color, 255); brush.polygon(rosette(x, y, r * solid, lobes, inner, rot, 0.08));
bloomFill(color, 250, bleed); brush.polygon(rosette(x, y, r, lobes, inner, rot, 0.05));
}
function centerDot(x, y, r, color, opacity = 250) { washStyle(color, opacity); brush.polygon(jitterEllipse(x, y, r, r * rr(0.85, 1), 12, 0.14)); }
function flowerCols(C, v = S.vivid) {
const a = C.accents, h = C.highlights;
const light = vivid(a[0], v), mid = vivid(a[1] || a[0], v), deep = vivid(a[2] || a[1] || a[0], v);
return { light, mid, deep, base: a[3] || mixHex(light, '#ffffff', 0.45), center: vivid(h[0] || '#e6b94f', v), pale: h[1] || mixHex(light, '#ffffff', 0.7), shade: mixHex(deep, '#3b3550', 0.45) };
}
function foliageCols(C) { const f = C.foliage; return { light: f[3] || mixHex(f[0], '#ffffff', 0.35), mid: f[0], deep: f[1] || f[0], shade: f[2] || mixHex(f[0], '#1f2f2a', 0.5) }; }
function drawPeony(x, y, s, F) {
const det = S.detail;
bloomShape(x, y, s * 1.08, 8, 0.74, mixHex(F.light, F.mid, S.vivid * 0.5), 0.32, 0.74);
bloomShape(x, y, s * 0.62, 6, 0.68, F.mid, 0.24, 0.68);
if (det > 0.35) bloomShape(x, y, s * 0.32, 5, 0.66, F.deep, 0.2, 0.62);
centerDot(x, y, 0.1 * s, F.center, 235);
}
function drawDaisy(x, y, s, F) {
const lobes = 7 + Math.round(4 * S.detail);
bloomShape(x, y, s * 1.05, lobes, 0.4, F.pale, 0.28, 0.8);
bloomFill(F.light, 170, 0.3); brush.polygon(rosette(x, y, s * 0.52, lobes, 0.55, rr(0, TWO_PI)));
centerDot(x, y, 0.24 * s, F.center, 245);
centerDot(x + 0.06 * s, y + 0.07 * s, 0.1 * s, mixHex(F.center, F.shade, 0.45), 190);
}
function drawBlossom(x, y, s, F) {
bloomShape(x, y, s * 0.9, 5, 0.5, random() < 0.5 ? F.light : F.mid, 0.26, 0.74);
centerDot(x, y, 0.14 * s, F.center, 240);
}
function drawLily(x, y, s, F) {
const pts = [], n = 36, lobes = 6;
for (let i = 0; i <= n; i++) {
const a = Math.PI + (i / n) * Math.PI, k = 0.62 + 0.38 * (0.5 + 0.5 * Math.cos(lobes * a));
pts.push([x + Math.cos(a) * 10 * s * k * (1 + rr(-0.05, 0.05)), y + 2 * s + Math.sin(a) * 9 * s * k]);
}
pts.push([x + 4 * s, y + 4.5 * s], [x, y + 5.5 * s], [x - 4 * s, y + 4.5 * s]);
washStyle(F.light, 255); brush.polygon(pts.map(([px, py]) => [x + (px - x) * 0.76, y + 2 * s + (py - y - 2 * s) * 0.76]));
bloomFill(F.light, 250, 0.3); brush.polygon(pts);
bloomFill(F.deep, 235, 0.28); brush.polygon(jitterEllipse(x, y + 2 * s, 5.5 * s, 3.2 * s, 10, 0.12));
centerDot(x, y + 1 * s, 2.2 * s, F.center, 242);
}
function drawFlower(type, x, y, s, F) {
if (type === 'peony') drawPeony(x, y, s, F); else if (type === 'daisy') drawDaisy(x, y, s, F); else if (type === 'lily') drawLily(x, y, s, F); else drawBlossom(x, y, s, F);
}
function drawPad(x, y, r, G) {
fillStyle(G.mid, 240, clamp01(0.2 + S.water * 0.25), 0.5, 0.35);
brush.polygon(jitterEllipse(x, y, r, r * 0.42, 12, 0.08));
strokeStyle('flick', G.light, 1.1 * S.brushSize);
brush.spline([[x - r * 0.6, y - r * 0.2], [x - r * 0.1, y - r * 0.4], [x + r * 0.5, y - r * 0.3]], 0.5);
strokeStyle('rim', G.shade, 1.4 * S.brushSize);
brush.spline([[x - r * 0.7, y + r * 0.2], [x, y + r * 0.45], [x + r * 0.7, y + r * 0.2]], 0.5);
}
function drawStrand(x0, y0, len, sway, color, weight, name = 'flick') {
brush.field('hand');
const watery = S.water > 0.6 && random() < (S.water - 0.6) * 1.5;
strokeStyle(watery ? 'marker' : name, color, weight * S.brushSize * (watery ? 2 : 1));
brush.spline([[x0, y0], [x0 + sway * 0.5 + rr(-2, 2), y0 + len * 0.5], [x0 + sway, y0 + len]], 0.6);
brush.noField();
}
function glazeDraw(x0, y0, x1, y1, color, weight, field = 'hand') { brush.field(field); strokeStyle('marker', color, weight * S.brushSize); brush.line(x0, y0, x1, y1); brush.noField(); }
function flickDraw(x, y, len, a, color, weight) { strokeStyle('flick', color, weight * S.brushSize); brush.line(x - Math.cos(a) * len / 2, y - Math.sin(a) * len / 2, x + Math.cos(a) * len / 2, y + Math.sin(a) * len / 2); }
// =====================================================================
// Descriptor factories
// =====================================================================
function dWash(color, opacity) {
const pts = [[-10, -10], [W + 10, -10], [W + 10, H + 10], [-10, H + 10]];
return D(() => { washStyle(color, opacity); brush.polygon(pts); }, [0, 0, W, H], 'x', rdir(), 700, 120);
}
function dBand(y0, y1, color, opacity, wobble) {
const pts = bandPolygon(y0, y1, wobble), water = S.water;
const bleed = 0.25 + water * 0.5, texture = 0.3 + water * 0.5;
return D(() => { fillStyle(color, opacity, bleed, texture, 0.3); brush.polygon(pts); }, bboxOf(pts, m * 0.25), 'x', rdir(), 600, 100);
}
// Reveal boxes are padded generously: bleed spreads well past the polygon, and since the
// buffer only holds the current stroke an oversized box copies nothing but transparency.
function dBlob(x, y, rx, ry, color, opacity, bleed, texture, rot = 0, jit = 0.12, dur = 420) {
const big = Math.max(rx, ry), pad = big * 0.9 + 14;
const bb = [Math.max(0, x - big - pad), Math.max(0, y - big - pad), Math.min(W, x + big + pad), Math.min(H, y + big + pad)];
return D(() => softBlobDraw(x, y, rx, ry, color, opacity, bleed, texture, rot, jit), bb.map(Math.round), raxis(), rdir(), dur, 80);
}
function dGlaze(x0, y0, x1, y1, color, weight, field) {
const bb = bboxOf([[x0, y0], [x1, y1]], 14 + weight * 4 * S.brushSize);
const axis = Math.abs(x1 - x0) >= Math.abs(y1 - y0) ? 'x' : 'y';
const dir = axis === 'x' ? Math.sign(x1 - x0) || 1 : Math.sign(y1 - y0) || 1;
const len = Math.hypot(x1 - x0, y1 - y0);
return D(() => glazeDraw(x0, y0, x1, y1, color, weight, field), bb, axis, dir, 160 + len * 1.4, 50);
}
function dStrand(x0, y0, len, sway, color, weight, name) {
const bb = bboxOf([[x0, y0], [x0 + sway, y0 + len]], 14 + weight * 5 * S.brushSize);
return D(() => drawStrand(x0, y0, len, sway, color, weight, name), bb, 'y', Math.sign(len) || 1, 120 + Math.abs(len) * 1.2, 30);
}
function dLeaves(cx, cy, rx, ry, n, cols, size, rot = 0) {
n = Math.max(2, Math.round(n * (0.3 + 0.7 * S.detail)));
size *= S.brushSize * (1.4 - 0.4 * S.detail);
const items = [];
let batch = [];
for (let i = 0; i < n; i++) {
const a = rr(0, TWO_PI), d = Math.sqrt(random());
const x = cx + Math.cos(a) * rx * d, y = cy + Math.sin(a) * ry * d, r = size * rr(0.6, 1.4);
batch.push({ x, y, r, color: pickOne(cols), rot: rot + rr(-0.7, 0.7), op: Math.round(rr(170, 235) - S.water * 40) });
if (batch.length === 5 || i === n - 1) {
const b = batch; batch = [];
const bb = bboxOf(b.map((lf) => [lf.x, lf.y]), size * 2.5 + 8);
items.push(D(() => { for (const lf of b) { washStyle(lf.color, lf.op); brush.polygon(jitterEllipse(lf.x, lf.y, lf.r * 1.7, lf.r * 0.7, 8, 0.12, lf.rot)); } }, bb, raxis(), rdir(), 200, 40));
}
}
return items;
}
function dFlower(type, x, y, s, F) {
const r = (type === 'lily' ? 11 * s : s * 1.3) * 1.6 + 10;
const bb = [Math.max(0, x - r), Math.max(0, y - r), Math.min(W, x + r), Math.min(H, y + r)].map(Math.round);
const dur = type === 'blossom' ? 260 : type === 'lily' ? 380 : 420;
return D(() => drawFlower(type, x, y, s, F), bb, raxis(), rdir(), dur, 90);
}
function dPad(x, y, r, G) {
const bb = [Math.max(0, x - r * 1.8 - 8), Math.max(0, y - r * 1.2 - 8), Math.min(W, x + r * 1.8 + 8), Math.min(H, y + r * 1.3 + 8)].map(Math.round);
return D(() => drawPad(x, y, r, G), bb, 'x', rdir(), 240, 50);
}
function dFlick(x, y, len, a, color, weight) {
const bb = bboxOf([[x - Math.cos(a) * len / 2, y - Math.sin(a) * len / 2], [x + Math.cos(a) * len / 2, y + Math.sin(a) * len / 2]], 10 + weight * 5);
const axis = Math.abs(Math.cos(a)) >= Math.abs(Math.sin(a)) ? 'x' : 'y';
return D(() => flickDraw(x, y, len, a, color, weight), bb, axis, rdir(), 90, 25);
}
function dTouches(points, C, count, angleFn, sizeMul = 1) {
const items = [];
count = Math.round(count * (0.4 + 0.6 * S.detail));
for (let i = 0; i < count; i++) {
const p = points.length ? pickOne(points) : [rr(0, W), rr(0, H), 10];
const x = p[0] + rr(-p[2], p[2]), y = p[1] + rr(-p[2] * 0.6, p[2] * 0.6);
const color = random() < 0.85 ? pickOne(C.highlights) : pickOne(C.accents);
items.push(dFlick(x, y, rr(6, 16) * S.scale * sizeMul * (m / 600) * 3, angleFn(), color, rr(0.6, 1.1) * S.scale));
}
return items;
}
// A batch of pre-computed flat wash marks — grass, petal dapple, iris heads.
// A meadow is hundreds of these; drawing them eight to a stroke keeps the
// reveal readable instead of turning one scene into 300 strokes.
function dMarks(marks, dur = 220, gap = 40) {
const items = [];
for (let i = 0; i < marks.length; i += 8) {
const b = marks.slice(i, i + 8);
let pts = [];
for (const d of b) pts = pts.concat(d.pts);
items.push(D(() => { for (const d of b) { washStyle(d.color, d.op); brush.polygon(d.pts); } }, bboxOf(pts, 10), raxis(), rdir(), dur, gap));
}
return items;
}
// =====================================================================
// Scenes (proportions from the playground, scaled by canvas size)
// =====================================================================
function buildGarden(C) {
const out = [], { water, density: dens, scale: sc, soft, detail: det, brushSize: bs } = S;
const F = flowerCols(C), G = foliageCols(C), washes = C.washes;
if (S.coverage > 0.02) out.push(dWash(washes[0], Math.round(S.coverage * 200)));
for (let i = 0; i < 3; i++) out.push(dBlob(rr(W * 0.2, W * 0.8), rr(H * 0.1, H * 0.5), W * rr(0.3, 0.5), H * rr(0.2, 0.35), washes[(i + 1) % washes.length], Math.round(190 - water * 40), 0.5 + water * 0.3, 0.5, rr(-0.5, 0.5), 0.2, 500));
const nMass = 5 + Math.round(dens * 6);
for (let i = 0; i < nMass; i++) {
const y = H * rr(0.35, 0.95), x = rr(-W * 0.05, W * 1.05);
const rx = W * rr(0.12, 0.26) * (0.6 + y / H), ry = rx * rr(0.45, 0.8);
const color = random() < 0.5 ? G.mid : (random() < 0.5 ? G.deep : G.light);
out.push(dBlob(x, y, rx, ry, color, Math.round(200 - water * 30), 0.4 + water * 0.3, 0.6, rr(-0.4, 0.4), 0.2 + soft * 0.2, 380));
}
out.push(...dLeaves(W * 0.5, H * 0.68, W * 0.55, H * 0.32, Math.round(24 + dens * 40), [G.deep, G.mid, G.light, F.light], m * 0.012 * sc, 0));
// flowers planned first so stalks lead to them
const nFlower = Math.round(6 + dens * 16), flowers = [];
for (let i = 0; i < nFlower; i++) {
const y = H * (0.3 + 0.68 * Math.pow(random(), 0.8)), x = rr(W * 0.04, W * 0.96), depth = 0.55 + (y / H) * 0.7, r = random();
const type = r < 0.5 ? 'peony' : r < 0.75 ? 'daisy' : 'blossom';
const s = m * (type === 'peony' ? rr(0.055, 0.085) : type === 'daisy' ? rr(0.045, 0.065) : rr(0.03, 0.045)) * sc * depth * S.flower;
flowers.push({ x, y, s, type });
}
flowers.sort((a, b) => a.y - b.y);
const paleF = { ...F, light: mixHex(F.light, '#ffffff', 0.35), mid: mixHex(F.mid, '#ffffff', 0.35) };
// Distant scatter high in the frame: deliberately NOT scaled by S.flower. These are
// background texture, and growing them with the near blooms would flatten the depth.
for (let i = 0; i < Math.round((8 + dens * 12) * (0.4 + 0.6 * det)); i++) {
const x = rr(W * 0.03, W * 0.97), y = H * rr(0.12, 0.45), s = m * rr(0.012, 0.022) * sc * bs;
out.push(dFlower('blossom', x, y, s, paleF));
}
for (const f of flowers) {
if (random() > 0.3 + 0.7 * det) continue;
const len = rr(H * 0.08, H * 0.22), sway = rr(-5, 5) * (m / 600) * 3;
const sc2 = random() < 0.5 ? G.mid : G.deep;
const stem = [[f.x, f.y + f.s * 0.3], [f.x + sway * 0.5, f.y + len * 0.5], [f.x + sway, f.y + len]];
out.push(D(() => { brush.field('hand'); strokeStyle('marker', sc2, rr(2.4, 4.2) * bs); brush.spline(stem, 0.6); brush.noField(); }, bboxOf(stem, 10 * bs), 'y', 1, 100 + len * 1.5, 30));
if (random() < 0.2 + 0.6 * det) {
const lx = f.x + rr(-f.s, f.s), ly = f.y + rr(f.s * 0.6, f.s * 2.2), lr = f.s * rr(0.6, 1.1) * bs, rot = rr(-1.2, 1.2), lc = random() < 0.5 ? G.mid : G.deep;
out.push(dBlob(lx, ly, lr * 1.6, lr * 0.55, lc, 230, 0.25, 0.5, rot, 0.1, 220));
}
}
for (const f of flowers) out.push(dFlower(f.type, f.x, f.y, f.s, F));
out.push(...dTouches(flowers.map((f) => [f.x, f.y - f.s * 0.4, f.s]), C, Math.round(S.touch * 18), () => rr(-0.3, 0.3), 0.7));
return out;
}
function buildDawn(C) {
const out = [], { water, density: dens, scale: sc, soft, detail: det, brushSize: bs } = S;
const F = flowerCols(C), G = foliageCols(C), washes = C.washes, highlights = C.highlights;
const sunX = W * 0.5, sunY = H * 0.34, horizon = H * 0.52;
if (S.coverage > 0.02) out.push(dWash(washes[0], Math.round(S.coverage * 200)));
out.push(dBand(-10, horizon + H * 0.05, washes[1], Math.round(200 - water * 40), m * 0.04));
out.push(dBlob(W * 0.15, H * 0.2, W * 0.3, H * 0.28, washes[2], 180, 0.6, 0.6, 0.3));
out.push(dBlob(W * 0.85, H * 0.22, W * 0.3, H * 0.28, washes[2], 180, 0.6, 0.6, -0.3));
out.push(dBlob(sunX, sunY, W * 0.26, H * 0.2, highlights[1] || '#fff7e6', 170, 0.75, 0.5, 0, 0.2, 520));
out.push(dBlob(sunX, sunY, W * 0.12, H * 0.09, highlights[0], 190, 0.7, 0.4, 0, 0.2, 360));
out.push(dBand(horizon - H * 0.03, H * 0.8, washes[2], Math.round(205 - water * 40), m * 0.023));
out.push(dBand(H * 0.72, H + 10, washes[3] || washes[2], Math.round(215 - water * 40), m * 0.023));
out.push(dBlob(sunX, H * 0.72, W * 0.14, H * 0.24, highlights[1] || '#fff7e6', 140, 0.7, 0.5, 0, 0.2, 420));
for (let i = 0; i < Math.round(4 + S.glaze * 12); i++) {
const y = rr(horizon, H), color = random() < 0.5 ? washes[0] : (random() < 0.5 ? washes[1] : washes[2]);
const x0 = rr(-5, W * 0.5), x1 = x0 + rr(W * 0.3, W * 0.9);
out.push(dGlaze(x0, y, x1, y + rr(-2, 2), color, rr(1.5, 3.5) * Math.sqrt(sc), random() < 0.3 ? 'waves' : 'hand'));
}
out.push(dBlob(W * 0.06, H * 0.62, W * 0.22, H * 0.36, G.mid, 190, 0.5, 0.6, 0.1));
out.push(dBlob(W * 0.94, H * 0.6, W * 0.22, H * 0.38, G.mid, 190, 0.5, 0.6, -0.1));
out.push(dBlob(W * 0.1, H * 0.85, W * 0.2, H * 0.2, G.deep, 180, 0.5, 0.6, 0));
out.push(dBlob(W * 0.9, H * 0.85, W * 0.2, H * 0.2, G.deep, 180, 0.5, 0.6, 0));
out.push(...dLeaves(W * 0.06, H * 0.62, W * 0.2, H * 0.3, Math.round(16 + dens * 24), [G.deep, G.mid, G.light], m * 0.011 * sc, 0.2));
out.push(...dLeaves(W * 0.94, H * 0.6, W * 0.2, H * 0.3, Math.round(16 + dens * 24), [G.deep, G.mid, G.light], m * 0.011 * sc, -0.2));
const nReed = Math.round((50 + dens * 110) * (0.35 + 0.65 * det));
for (let i = 0; i < nReed; i++) {
const left = i % 2 === 0;
const edge = left ? rr(-3, W * 0.24) : rr(W * 0.76, W + 3);
const c = left ? edge / (W * 0.24) : (W - edge) / (W * 0.24);
const len = -H * rr(0.18, 0.55) * (1 - c * 0.5) * (0.8 + sc * 0.2);
const y0 = H + 4 - rr(0, H * 0.12), sway = (left ? -1 : 1) * rr(1.5, 7) * (m / 600) * 3;
const color = random() < 0.25 ? G.shade : (random() < 0.5 ? G.deep : (random() < 0.7 ? G.mid : G.light));
out.push(dStrand(edge, y0, len, sway, color, rr(0.5, 1.1) * sc * (1.5 - 0.5 * det), random() < 0.6 ? 'rim' : 'flick'));
}
const rafts = [{ cx: W * 0.24, cy: H * 0.8, n: 4 }, { cx: W * 0.72, cy: H * 0.88, n: 5 }], lilies = [];
for (const rf of rafts) {
const pads = [];
for (let i = 0; i < rf.n + Math.round(dens * 2); i++) pads.push({ x: rf.cx + rr(-W * 0.1, W * 0.1), y: rf.cy + rr(-H * 0.035, H * 0.035), r: m * rr(0.03, 0.05) * sc });
pads.sort((a, b) => a.y - b.y);
pads.forEach((p) => out.push(dPad(p.x, p.y, p.r, G)));
const byR = pads.slice().sort((a, b) => b.r - a.r);
for (let i = 0; i < 2; i++) { const p = byR[i]; const s = (p.r / (m * 0.045)) * 1.2 * sc * S.flower; lilies.push(dFlower('lily', p.x, p.y - p.r * 0.1, s, F)); }
}
out.push(...lilies);
const glintPts = [];
for (let i = 0; i < 10; i++) glintPts.push([sunX + randomGaussian(0, W * 0.08), rr(horizon + 4, H * 0.95), 6]);
out.push(...dTouches(glintPts, C, Math.round(S.touch * 20), () => rr(-0.06, 0.06)));
return out;
}
function buildWillow(C) {
const out = [], { water, density: dens, scale: sc, soft, detail: det, brushSize: bs } = S;
const F = flowerCols(C), G = foliageCols(C), washes = C.washes, highlights = C.highlights, foliage = C.foliage;
const pathX = W * 0.5;
if (S.coverage > 0.02) out.push(dWash(washes[0], Math.round(S.coverage * 200)));
out.push(dBand(-10, H * 0.5, washes[2], Math.round(215 - water * 40), m * 0.027));
out.push(dBand(H * 0.35, H * 0.78, washes[0], Math.round(220 - water * 40), m * 0.027));
out.push(dBand(H * 0.68, H + 10, washes[3] || washes[1], Math.round(230 - water * 40), m * 0.027));
out.push(dBlob(pathX, H * 0.62, W * 0.16, H * 0.42, highlights[1] || '#fff6d6', 150, 0.7, 0.6, 0, 0.15, 520));
for (let i = 0; i < Math.round(4 + S.glaze * 10); i++) {
const y0 = rr(H * 0.3, H * 0.95), len = rr(H * 0.08, H * 0.22), x = pathX + randomGaussian(0, W * 0.05);
const color = i % 3 === 0 ? highlights[0] : mixHex(highlights[1] || '#fff6d6', washes[2], 0.4);
out.push(dGlaze(x, y0, x + rr(-2, 2), y0 + len, color, rr(2, 4), 'hand'));
}
const dark = G.shade, midG = G.mid, sunG = foliage[3] || mixHex(G.mid, highlights[0], 0.6);
out.push(dBlob(W * 0.12, H * 0.1, W * 0.36, H * 0.3, dark, 235, 0.35 + water * 0.3, 0.5, 0.3));
out.push(dBlob(W * 0.9, H * 0.12, W * 0.34, H * 0.32, dark, 235, 0.35 + water * 0.3, 0.5, -0.3));
out.push(dBand(-15, H * 0.09, dark, 225, m * 0.03));
out.push(dBlob(W * 0.3, H * 0.14, W * 0.22, H * 0.14, midG, 200, 0.5, 0.6, 0.2));
out.push(dBlob(W * 0.72, H * 0.16, W * 0.2, H * 0.13, midG, 200, 0.5, 0.6, -0.2));
const leafCols = [G.shade, G.deep, G.mid, sunG];
out.push(...dLeaves(W * 0.14, H * 0.16, W * 0.36, H * 0.2, Math.round(30 + dens * 40), leafCols, m * 0.012 * sc, 0.3));
out.push(...dLeaves(W * 0.86, H * 0.18, W * 0.34, H * 0.2, Math.round(30 + dens * 40), leafCols, m * 0.012 * sc, -0.3));
out.push(...dLeaves(W * 0.5, H * 0.05, W * 0.4, H * 0.08, Math.round(20 + dens * 30), [G.shade, G.deep, G.mid], m * 0.011 * sc, 0));
const nStrand = Math.round((40 + dens * 110) * (0.35 + 0.65 * det));
for (let i = 0; i < nStrand; i++) {
const x0 = rr(-3, W + 3), c = Math.abs(x0 - pathX) / (W * 0.5);
const y0 = H * 0.03 + Math.pow(c, 1.5) * H * 0.3 + rr(-5, 5);
const len = (H * rr(0.15, 0.5)) * (1 - 0.35 * (1 - c)) * (0.8 + sc * 0.2);
const backlit = c < 0.45 && random() < 0.7;
const color = backlit ? (random() < 0.5 ? sunG : highlights[0]) : (random() < 0.7 ? G.deep : G.mid);
out.push(dStrand(x0, y0, len, rr(-6, 6) * (m / 600) * 3, color, rr(0.7, 1.3) * sc * (1.5 - 0.5 * det), random() < 0.2 ? 'rim' : 'flick'));
}
const rafts = [
{ cx: W * 0.22, cy: H * 0.82, n: 5, r: [0.035, 0.055], flowers: 2 },
{ cx: W * 0.5, cy: H * 0.6, n: 4, r: [0.02, 0.032], flowers: 1 },
{ cx: W * 0.78, cy: H * 0.86, n: 5, r: [0.035, 0.05], flowers: 2 },
];
const lilies = [];
for (const rf of rafts) {
const pads = [];
for (let i = 0; i < rf.n + Math.round(dens * 3); i++) pads.push({ x: rf.cx + rr(-W * 0.11, W * 0.11), y: rf.cy + rr(-H * 0.04, H * 0.04), r: m * rr(rf.r[0], rf.r[1]) * sc });
pads.sort((a, b) => a.y - b.y);
pads.forEach((p) => out.push(dPad(p.x, p.y, p.r, G)));
const byR = pads.slice().sort((a, b) => b.r - a.r);
for (let i = 0; i < rf.flowers; i++) { const p = byR[i]; const s = (p.r / (m * 0.045)) * 0.9 * sc * S.flower; lilies.push(dFlower('lily', p.x + rr(-p.r * 0.2, p.r * 0.2), p.y - p.r * 0.12, s, F)); }
}
out.push(...lilies);
for (let i = 0; i < 3 + Math.round(dens * 4); i++) out.push(dFlower('blossom', rr(W * 0.02, W * 0.2), rr(H * 0.42, H * 0.72), m * rr(0.02, 0.035) * sc * S.flower, F));
const sparklePts = [];
for (let i = 0; i < 12; i++) sparklePts.push([pathX + randomGaussian(0, W * 0.06), rr(H * 0.5, H * 0.95), 4]);
out.push(...dTouches(sparklePts, C, Math.round(S.touch * 24), () => rr(-0.08, 0.08)));
return out;
}
function buildWisteria(C) {
const out = [], { water, density: dens, scale: sc, soft, detail: det, brushSize: bs } = S;
const F = flowerCols(C), G = foliageCols(C), washes = C.washes, accents = C.accents, highlights = C.highlights;
const lav = [accents[0], accents[1] || accents[0], accents[4] || accents[1] || accents[0]];
if (S.coverage > 0.02) out.push(dWash(washes[0], Math.round(S.coverage * 200)));
out.push(dBand(-10, H * 0.45, washes[2], Math.round(200 - water * 40), m * 0.037));
out.push(dBand(H * 0.3, H * 0.75, washes[0], Math.round(215 - water * 40), m * 0.037));
out.push(dBand(H * 0.62, H + 10, washes[3] || washes[1], Math.round(215 - water * 40), m * 0.037));
out.push(dBlob(W * 0.5, H * 0.5, W * 0.3, H * 0.14, highlights[0], 150, 0.7, 0.5, 0, 0.2, 420));
// dappled meadow in small batches
const nDots = Math.round((80 + dens * 160) * (0.3 + 0.7 * det));
const dotCols = [...washes, accents[2] || accents[0], accents[3] || highlights[0], highlights[0], G.light];
let batch = [];
for (let i = 0; i < nDots; i++) {
const y = H * (0.25 + 0.75 * Math.pow(random(), 0.7)), x = rr(-2, W + 2);
const r = m * rr(0.005, 0.013) * sc * (0.6 + y / H) * bs * (1.5 - 0.5 * det);
const pts = jitterEllipse(x, y, r * rr(1.6, 2.6), r, 8, 0.15 + soft * 0.2, rr(-0.25, 0.25));
batch.push({ color: pickOne(dotCols), pts, op: Math.round(rr(130, 210) - water * 30) });
if (batch.length === 10 || i === nDots - 1) {
const b = batch; batch = [];
out.push(D(() => { for (const d of b) { washStyle(d.color, d.op); brush.polygon(d.pts); } }, bboxOf(b.flatMap((d) => d.pts), 8), 'x', rdir(), 220, 40));
}
}
out.push(dBand(-15, H * 0.06, G.deep, 200, m * 0.023));
const nRac = 5 + Math.round(dens * 6);
for (let i = 0; i < nRac; i++) {
const x = ((i + rr(0.2, 0.8)) / nRac) * W;
const y0 = rr(-4, H * 0.04), L = H * rr(0.14, 0.34) * (0.8 + sc * 0.2), w = m * rr(0.04, 0.065) * sc;
const parts = [[], [], []], nd = Math.round(L / ((4 + (1 - det) * 6) * (m / 600)));
for (let k = 0; k < nd; k++) {
const t = k / nd, half = (w * (1 - t * 0.75)) / 2;
const dx = randomGaussian(0, half / 1.6), dy = y0 + t * L + rr(-1, 1);
const r = m * rr(0.007, 0.012) * sc * (1 - t * 0.3) * bs * (1 + (1 - det) * 0.8);
const color = random() < 0.15 ? lav[2] : (random() < 0.5 ? lav[0] : lav[1]);
const pale = Math.abs(dx) > half * 0.6 && random() < 0.5;
parts[Math.min(2, Math.floor(t * 3))].push({ x: x + dx, y: dy, r, color: pale ? mixHex(color, '#ffffff', 0.35) : color, rot: rr(-0.5, 0.5) });
}
const leaves = [];
for (let k = 0; k < 3; k++) leaves.push({ x: x + rr(-w, w), y: y0 + rr(0, L * 0.12), r: m * 0.014 * sc, rot: rr(-1, 1), color: random() < 0.5 ? G.mid : G.deep });
out.push(D(() => { for (const lf of leaves) { washStyle(lf.color, 230); brush.polygon(jitterEllipse(lf.x, lf.y, lf.r * 1.8, lf.r * 0.7, 8, 0.1, lf.rot)); } }, bboxOf(leaves.map((l) => [l.x, l.y]), m * 0.05), 'x', rdir(), 140, 30));
for (const part of parts) {
if (!part.length) continue;
out.push(D(() => { for (const d of part) { washStyle(d.color, 235); brush.polygon(jitterEllipse(d.x, d.y, d.r * 1.3, d.r, 8, 0.15, d.rot)); } }, bboxOf(part.map((d) => [d.x, d.y]), w + 8), 'y', 1, 220, 30));
}
}
const blossomPts = [], fl = [];
for (let i = 0; i < 6 + Math.round(dens * 10); i++) {
const y = H * rr(0.55, 0.97), x = rr(W * 0.03, W * 0.97), s = m * rr(0.018, 0.032) * sc * (0.6 + y / H) * S.flower;
blossomPts.push([x, y, s]);
fl.push({ y, d: dFlower(random() < 0.35 ? 'daisy' : 'blossom', x, y, s, F) });
}
fl.sort((a, b) => a.y - b.y);
out.push(...fl.map((f) => f.d));
out.push(...dTouches(blossomPts, C, Math.round(S.touch * 16), () => rr(-0.2, 0.2)));
return out;
}
// Poppy field. Deliberately the wettest and largest of the scenes: the
// playground preset already asked for water 0.7 against the 0.55 its siblings
// share, and the drift only reads as poppies when the blooms are big enough to
// carry a wet edge. Both are local to this scene rather than dials, so the
// other six paintings are untouched.
function buildPoppies(C) {
const out = [], { density: dens, scale: sc, soft, detail: det, brushSize: bs } = S;
const { washes, accents, highlights } = C;
const F = flowerCols(C), G = foliageCols(C);
const horizon = H * 0.38, wet = 0.72, pop = 1.35;
// sky with two pale clouds, then the meadow in two warm-green bands
if (S.coverage > 0.02) out.push(dWash(washes[0], Math.round(S.coverage * 200)));
out.push(dBand(-20, horizon + H * 0.04, washes[1], Math.round(180 - wet * 40), m * 0.033));
out.push(dBlob(W * 0.28, H * 0.14, W * 0.26, H * 0.09, highlights[1] || '#fff6e6', 160, 0.8, 0.5, 0.1, 0.2, 520));
out.push(dBlob(W * 0.74, H * 0.22, W * 0.2, H * 0.07, highlights[1] || '#fff6e6', 150, 0.8, 0.5, -0.1, 0.2, 460));
out.push(dBand(horizon - H * 0.02, H * 0.7, washes[2], Math.round(190 - wet * 40), m * 0.027));
out.push(dBand(H * 0.6, H + 20, washes[3] || washes[2], Math.round(200 - wet * 40), m * 0.027));
// a dark tree line on the horizon, then grass that grows toward the front
out.push(dBand(horizon - H * 0.03, horizon + H * 0.02, G.deep, Math.round(200 - wet * 30), m * 0.01));
const nTree = 4 + Math.round(dens * 2);
for (let i = 0; i < nTree; i++) {
const x = ((i + rr(0.2, 0.8)) / nTree) * W, rx = W * rr(0.05, 0.11), ry = H * rr(0.04, 0.09);
const color = random() < 0.6 ? G.shade : G.deep;
out.push(dBlob(x, horizon - ry * 0.6, rx, ry, color, 215, 0.35 + wet * 0.3, 0.5, rr(-0.2, 0.2), 0.18, 340));
}
const grass = [], grassCols = [G.light, G.mid, washes[2], highlights[0], G.mid];
for (let i = 0; i < Math.round((70 + dens * 140) * (0.3 + 0.7 * det)); i++) {
const y = horizon + (H - horizon) * Math.pow(random(), 0.75), x = rr(-5, W + 5);
const r = m * rr(0.003, 0.007) * sc * (0.5 + y / H) * bs * (1.5 - 0.5 * det);
grass.push({ color: pickOne(grassCols), pts: jitterEllipse(x, y, r * rr(2.5, 4.5), r * 0.55, 8, 0.15 + soft * 0.2, rr(-0.15, 0.15)), op: Math.round(rr(110, 190) - wet * 30) });
}
out.push(...dMarks(grass));
// the poppy drift: a diagonal band from upper left to lower right, bigger as
// it comes forward. Small ones are a wet halo under a flat dab; the big ones
// are bleeding fills with a shade dot at the centre.
const reds = [accents[0], accents[1] || accents[0], accents[2] || accents[0]];
const pops = [], blooms = [];
for (let i = 0; i < Math.round((60 + dens * 120) * (0.3 + 0.7 * det)); i++) {
const y = H * (0.4 + 0.6 * Math.pow(random(), 0.6));
const depth = (y - horizon) / (H - horizon); // 0 far, 1 near
const cx = W * (0.15 + depth * 0.6); // the band walks right as it comes forward
const x = random() < 0.3 ? rr(-5, W + 5) : cx + randomGaussian(0, W * 0.16);
const r = m * rr(0.006, 0.012) * sc * (0.5 + depth * 1.4) * bs * (1.3 - 0.3 * det) * pop;
const color = pickOne(reds), rot = rr(-0.5, 0.5);
if (r > m * 0.018 && random() < 0.6) blooms.push({ x, y, r, color, rot });
else {
pops.push({ color: mixHex(color, '#ffffff', 0.3), pts: jitterEllipse(x, y, r * 2.1, r * 1.7, 9, 0.22, rot), op: Math.round(120 - wet * 40) });
pops.push({ color, pts: jitterEllipse(x, y, r * rr(1.1, 1.5), r, 9, 0.18, rot), op: Math.round(rr(160, 210) - wet * 40) });
}
}
out.push(...dMarks(pops));
blooms.sort((a, b) => a.y - b.y);
for (const b of blooms) {
const pad = b.r * 2.4 + 16;
const bb = [Math.max(0, b.x - pad), Math.max(0, b.y - pad), Math.min(W, b.x + pad), Math.min(H, b.y + pad)].map(Math.round);
out.push(D(() => {
softBlobDraw(b.x, b.y, b.r * 1.3, b.r, b.color, 195, 0.55, 0.65, b.rot, 0.22);
washStyle(F.shade, 200);
brush.polygon(jitterEllipse(b.x, b.y + b.r * 0.1, b.r * 0.3, b.r * 0.25, 8, 0.15));
}, bb, raxis(), rdir(), 300, 60));
}
// a few readable poppies in front, grass stalks, light on the horizon
const redF = { ...F, light: vivid(accents[0], S.vivid), mid: vivid(accents[1] || accents[0], S.vivid), pale: mixHex(accents[0], '#ffffff', 0.5), center: F.shade };
for (let i = 0; i < 5 + Math.round(dens * 4); i++) {
const y = H * rr(0.72, 0.97), x = rr(W * 0.05, W * 0.95), s = m * rr(0.03, 0.05) * sc * pop;
out.push(dFlower('blossom', x, y, s, redF));
}
for (let i = 0; i < Math.round((10 + dens * 14) * (0.4 + 0.6 * det)); i++) {
const x = rr(0, W), y0 = H + 5 - rr(0, H * 0.1), len = -H * rr(0.06, 0.2) * sc;
out.push(dStrand(x, y0, len, rr(-8, 8) * (m / 600) * 3, random() < 0.5 ? G.deep : G.mid, rr(0.5, 0.9) * sc, 'rim'));
}
const glowPts = [];
for (let i = 0; i < 10; i++) glowPts.push([rr(0, W), horizon + rr(-H * 0.02, H * 0.05), 14]);
out.push(...dTouches(glowPts, C, Math.round(S.touch * 18), () => rr(-0.1, 0.1)));
return out;
}
// Iris path at Giverny. Everything is arranged around a path that narrows to a
// point near the top, so the blades have to keep off it or the perspective goes.
function buildIrises(C) {
const out = [], { water, density: dens, scale: sc, detail: det, brushSize: bs } = S;
const { washes, accents, highlights } = C;
const F = flowerCols(C), G = foliageCols(C);
const topY = H * 0.28;
const pathHalf = (y) => { const t = clamp01((H - y) / (H - topY)); return W * (0.2 - 0.15 * t); };
const pathX = (y) => W * 0.5 + (1 - clamp01((H - y) / (H - topY))) * W * 0.02;
const irisCols = [accents[0], accents[1] || accents[0], accents[2] || accents[0], accents[4] || accents[1] || accents[0]];
const paleIris = accents[3] || mixHex(accents[0], '#ffffff', 0.5);
// sky, the pale path, a warm core, meadow either side
if (S.coverage > 0.02) out.push(dWash(washes[0], Math.round(S.coverage * 200)));
out.push(dBand(-20, topY + H * 0.06, washes[2], Math.round(195 - water * 40), m * 0.033));
out.push(dBand(topY - H * 0.02, H + 20, washes[3] || washes[1], Math.round(200 - water * 40), m * 0.03));
const trap = [];
for (let i = 0; i <= 6; i++) { const y = H + 20 - (H + 20 - topY) * (i / 6); trap.push([pathX(y) - pathHalf(y) * rr(0.9, 1.1), y]); }
for (let i = 6; i >= 0; i--) { const y = H + 20 - (H + 20 - topY) * (i / 6); trap.push([pathX(y) + pathHalf(y) * rr(0.9, 1.1), y]); }
out.push(D(() => { fillStyle(washes[1], Math.round(220 - water * 40), 0.3 + water * 0.4, 0.4 + water * 0.4, 0.3); brush.polygon(trap); }, bboxOf(trap, m * 0.2), 'y', -1, 620, 100));
out.push(dBlob(W * 0.5, H * 0.8, W * 0.12, H * 0.3, washes[0], 170, 0.6, 0.5, 0, 0.15, 480));
out.push(dBlob(W * 0.5, topY + H * 0.04, W * 0.14, H * 0.06, highlights[1] || '#fff6e6', 160, 0.7, 0.5, 0, 0.2, 420));
// the beds: soft green masses, then rising blades that keep off the path
for (const side of [-1, 1]) {
out.push(dBlob(W * (0.5 + side * 0.33), H * 0.6, W * 0.2, H * 0.22, G.mid, 195, 0.5, 0.6, side * 0.15, 0.12, 400));
out.push(dBlob(W * (0.5 + side * 0.36), H * 0.86, W * 0.2, H * 0.16, G.deep, 190, 0.5, 0.6, 0, 0.12, 400));
out.push(dBlob(W * (0.5 + side * 0.24), H * 0.4, W * 0.16, H * 0.1, G.light, 170, 0.6, 0.6, side * 0.3, 0.12, 340));
}
const tops = [];
for (let i = 0; i < Math.round((50 + dens * 110) * (0.35 + 0.65 * det)); i++) {
const side = i % 2 === 0 ? -1 : 1;
const y0 = H * (0.34 + 0.7 * Math.pow(random(), 0.8));
const near = (y0 - topY) / (H - topY); // 0 far, 1 near
const x0 = pathX(y0) + side * (pathHalf(y0) + rr(4, W * 0.36 * (0.4 + 0.6 * near)));
const len = -H * rr(0.08, 0.26) * (0.3 + near) * (0.8 + sc * 0.2);
const sway = side * rr(-6, 14) * (m / 600) * 3;
const color = random() < 0.2 ? G.shade : (random() < 0.5 ? G.deep : (random() < 0.7 ? G.mid : G.light));
out.push(dStrand(x0, y0, len, sway, color, rr(0.6, 1.1) * sc * (1.5 - 0.5 * det), random() < 0.6 ? 'rim' : 'flick'));
tops.push({ x: x0 + sway, y: y0 + len, near });
}
// iris heads on a subset of the blades: a tall standard over a wider fall
const heads = [];
const chosen = tops.slice().sort(() => random() - 0.5).slice(0, Math.round(tops.length * (0.45 + 0.2 * dens)));
for (const h of chosen) {
const r = m * rr(0.009, 0.015) * sc * (0.5 + h.near) * bs * (1.3 - 0.3 * det);
const pale = random() < 0.2;
const cs = pale ? paleIris : pickOne(irisCols), cf = pale ? mixHex(paleIris, irisCols[1], 0.4) : irisCols[2];
heads.push({ color: vivid(cf, S.vivid), pts: jitterEllipse(h.x, h.y + r * 0.6, r * 1.5, r * 0.8, 8, 0.2, rr(-0.3, 0.3)), op: Math.round(225 - water * 30) });
heads.push({ color: vivid(cs, S.vivid), pts: jitterEllipse(h.x, h.y - r * 0.4, r * 0.8, r * 1.4, 8, 0.18, rr(-0.3, 0.3)), op: Math.round(235 - water * 30) });
if (det > 0.35 && r > m * 0.011) heads.push({ color: highlights[0], pts: jitterEllipse(h.x, h.y + r * 0.4, r * 0.25, r * 0.2, 6, 0.2), op: 235 });
}
heads.sort((a, b) => a.pts[0][1] - b.pts[0][1]);
out.push(...dMarks(heads));
// a few large irises in the foreground beds, then light falling down the path
const purpleF = { ...F, light: vivid(irisCols[3], S.vivid), mid: vivid(irisCols[0], S.vivid), deep: vivid(irisCols[2], S.vivid), pale: paleIris };
for (let i = 0; i < 6 + Math.round(dens * 4); i++) {
const side = i % 2 === 0 ? -1 : 1, y = H * rr(0.68, 0.96);
const x = pathX(y) + side * (pathHalf(y) + rr(W * 0.02, W * 0.26));
const s = m * rr(0.026, 0.04) * sc, pad = s * 2.6 + 14;
const bb = [Math.max(0, x - pad), Math.max(0, y - pad), Math.min(W, x + pad), Math.min(H, y + pad)].map(Math.round);
out.push(D(() => {
washStyle(purpleF.deep, 230);
brush.polygon(jitterEllipse(x, y + s * 0.5, s * 1.1, s * 0.55, 10, 0.18));
drawFlower('blossom', x, y - s * 0.2, s * 1.3, purpleF);
}, bb, raxis(), rdir(), 320, 80));
}
const pathPts = [];
for (let i = 0; i < 10; i++) { const y = rr(topY + 10, H * 0.95); pathPts.push([pathX(y) + rr(-pathHalf(y), pathHalf(y)) * 0.8, y, 10]); }
out.push(...dTouches(pathPts, C, Math.round(S.touch * 18), () => HALF_PI + rr(-0.2, 0.2)));
return out;
}
// Orchard in blossom. The trees are planned before anything is drawn, so the
// trunks, the canopies and the light on top all agree about where they stand.
function buildOrchard(C) {
const out = [], { water, density: dens, scale: sc, soft, detail: det, brushSize: bs } = S;
const { washes, accents, highlights } = C;
const F = flowerCols(C), G = foliageCols(C);
const horizon = H * 0.45;
// sky with a glow upper right, then the meadow
if (S.coverage > 0.02) out.push(dWash(washes[0], Math.round(S.coverage * 200)));
out.push(dBand(-20, horizon + H * 0.03, washes[1], Math.round(195 - water * 40), m * 0.037));
out.push(dBlob(W * 0.78, H * 0.16, W * 0.3, H * 0.18, highlights[1] || '#fff8ee', 150, 0.75, 0.5, 0, 0.2, 520));
out.push(dBand(horizon - H * 0.02, H * 0.75, washes[2], Math.round(210 - water * 40), m * 0.027));
out.push(dBand(H * 0.66, H + 20, washes[3] || washes[2], Math.round(220 - water * 40), m * 0.027));
const nTree = 3 + Math.round(dens * 1.5), trees = [];
for (let i = 0; i < nTree; i++) {
const x = W * (0.05 + 0.9 * (i + rr(0.25, 0.75)) / nTree), base = H * rr(0.72, 0.9);
const h = H * rr(0.3, 0.45) * sc, r = m * rr(0.12, 0.18) * sc * (0.7 + (base / H) * 0.4);
trees.push({ x, base, h, r, cy: base - h * 0.85 });
}
trees.sort((a, b) => a.base - b.base);
// trunks and branches, then grass
for (const t of trees) {
const lean = rr(-12, 12) * (m / 600) * 3;
const trunk = [[t.x, t.base], [t.x + lean * 0.5, t.base - t.h * 0.5], [t.x + lean, t.base - t.h]];
const tw = rr(2.2, 3.2) * bs * sc;
out.push(D(() => { strokeStyle('rim', G.shade, tw); brush.spline(trunk, 0.4); }, bboxOf(trunk, 12 * bs), 'y', -1, 260, 40));
for (let k = 0; k < 2 + Math.round(det * 2); k++) {
const y0 = t.base - t.h * rr(0.55, 0.85), x0 = t.x + lean * ((t.base - y0) / t.h);
const a = rr(-2.4, -0.7), len = t.r * rr(0.5, 0.9);
const br = [[x0, y0], [x0 + Math.cos(a) * len * 0.5, y0 + Math.sin(a) * len * 0.5 - 4], [x0 + Math.cos(a) * len, y0 + Math.sin(a) * len]];
const bw = rr(1, 1.6) * bs * sc;
out.push(D(() => { strokeStyle('rim', G.shade, bw); brush.spline(br, 0.4); }, bboxOf(br, 10 * bs), raxis(), rdir(), 160, 30));
}
}
const grass = [], grassCols = [G.light, G.mid, washes[2], highlights[0]];
for (let i = 0; i < Math.round((50 + dens * 100) * (0.3 + 0.7 * det)); i++) {
const y = horizon + (H - horizon) * Math.pow(random(), 0.75), x = rr(-5, W + 5);
const r = m * rr(0.003, 0.007) * sc * (0.5 + y / H) * bs * (1.5 - 0.5 * det);
grass.push({ color: pickOne(grassCols), pts: jitterEllipse(x, y, r * rr(2.5, 4.5), r * 0.55, 8, 0.15 + soft * 0.2, rr(-0.15, 0.15)), op: Math.round(rr(110, 190) - water * 30) });
}
out.push(...dMarks(grass));
// blossom clouds: a soft pink mass per tree, then a dapple of petals and leaves
const petalCols = [accents[0], accents[1] || accents[0], accents[2] || accents[0], accents[3] || '#fff1f4', accents[4] || accents[0]];
for (const t of trees) {
out.push(dBlob(t.x, t.cy, t.r * 1.1, t.r * 0.8, accents[0], 170, 0.65, 0.5, rr(-0.3, 0.3), 0.25, 460));
out.push(dBlob(t.x + rr(-t.r * 0.4, t.r * 0.4), t.cy - t.r * 0.3, t.r * 0.7, t.r * 0.5, accents[3] || '#fff1f4', 160, 0.7, 0.5, rr(-0.3, 0.3), 0.25, 400));
const dots = [];
for (let i = 0; i < Math.round((40 + dens * 50) * (0.3 + 0.7 * det)); i++) {
const a = rr(0, TWO_PI), d = Math.sqrt(random());
const x = t.x + Math.cos(a) * t.r * 1.05 * d, y = t.cy + Math.sin(a) * t.r * 0.8 * d;
const r0 = m * rr(0.005, 0.011) * sc * bs * (1.4 - 0.4 * det);
const low = y > t.cy + t.r * 0.3;
const color = random() < 0.25 ? G.light : (low && random() < 0.3 ? G.deep : pickOne(petalCols));
dots.push({ color, pts: jitterEllipse(x, y, r0, r0 * rr(0.8, 1), 8, 0.15), op: Math.round(rr(170, 235) - water * 30) });
}
out.push(...dMarks(dots));
}
// readable blossoms in the nearest canopy, fallen petals, light on the tops
const near = trees[trees.length - 1];
for (let i = 0; i < 6 + Math.round(dens * 4); i++) {
const a = rr(0, TWO_PI), d = Math.sqrt(random());
const x = near.x + Math.cos(a) * near.r * 0.9 * d, y = near.cy + Math.sin(a) * near.r * 0.7 * d;
out.push(dFlower('blossom', x, y, m * rr(0.02, 0.032) * sc, F));
}
const fallen = [];
for (let i = 0; i < Math.round(20 + dens * 20); i++) {
const x = rr(0, W), y = rr(H * 0.72, H), r = m * rr(0.003, 0.006) * sc * bs;
fallen.push({ color: pickOne(petalCols), pts: jitterEllipse(x, y, r * 1.6, r, 6, 0.2), op: 200 });
}
out.push(...dMarks(fallen));
out.push(...dTouches(trees.map((t) => [t.x, t.cy - t.r * 0.6, t.r * 0.6]), C, Math.round(S.touch * 16), () => rr(-0.3, 0.3)));
return out;
}
// =====================================================================
// Second set: koi, roses, oranges, sail. Same rules as the flowers above:
// a flat wash body under a bleeding fill for solid colour, never an
// outline, never brush.circle at a small radius.
//
// Where the playground tuned one of these scenes away from the shared
// dials (wetter, more glazes, a paler rose), the builder keeps that value
// as a local constant, as buildPoppies does, so no other painting moves.
// =====================================================================
function ringPts(cx, cy, r, a0, a1, n = 10) {
const pts = [];
for (let i = 0; i <= n; i++) { const a = a0 + (a1 - a0) * (i / n); pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); }
return pts;
}
// a pointed ellipse: round at the base (-rx along rot), tapering to a tip at +rx. Petals, leaves, buds.
function petalPts(cx, cy, rx, ry, rot, n = 14, jit = 0.05) {
const pts = [], cr = Math.cos(rot), sr = Math.sin(rot);
for (let i = 0; i < n; i++) {
const a = (i / n) * TWO_PI, c = Math.cos(a), k = 1 + rr(-jit, jit);
const px = rx * c * k, py = ry * Math.sin(a) * Math.sqrt(1 - 0.9 * c) / 1.1 * k;
pts.push([cx + px * cr - py * sr, cy + px * sr + py * cr]);
}
return pts;
}
// a fish silhouette: blunt head toward `angle`, fullest a third of the way back, a waist, a forked tail
function taperedBody(x, y, len, wid, angle, jit = 0.06) {
const ca = Math.cos(angle), sa = Math.sin(angle);
const P = (t, v) => { const u = len / 2 - t * len; return [x + u * ca - v * sa, y + u * sa + v * ca]; };
const T = [0, 0.1, 0.3, 0.5, 0.7, 0.82, 0.92, 1], HW = [0.3, 0.72, 1, 0.9, 0.62, 0.3, 0.55, 0.85];
const top = [], bottom = [];
for (let i = 0; i < T.length; i++) {
const h = HW[i] * (wid / 2) * (1 + rr(-jit, jit));
top.push(P(T[i], -h)); bottom.push(P(T[i], h));
}
return top.concat([P(0.88, 0)], bottom.reverse());
}
function drawKoi(x, y, len, angle, K) {
const ca = Math.cos(angle), sa = Math.sin(angle);
const off = (u, v) => [x + u * ca - v * sa, y + u * sa + v * ca];
washStyle(K.body, 250); brush.polygon(taperedBody(x, y, len, len * 0.3, angle, 0.04));
bloomFill(K.body, 235, 0.22); brush.polygon(taperedBody(x, y, len * 1.04, len * 0.33, angle, 0.08));
// a deeper saddle along the back, a paler belly, cream flecks, an eye
const [bx, by] = off(len * 0.08, -len * 0.06);
washStyle(K.deep, 200); brush.polygon(jitterEllipse(bx, by, len * 0.28, len * 0.07, 10, 0.15, angle));
const [px, py] = off(len * 0.05, len * 0.08);
washStyle(K.pale, 170); brush.polygon(jitterEllipse(px, py, len * 0.26, len * 0.05, 10, 0.15, angle));
for (let i = 0; i < 3; i++) { const [fx, fy] = off(rr(-len * 0.2, len * 0.35), rr(-len * 0.08, len * 0.08)); centerDot(fx, fy, len * rr(0.02, 0.04), K.fleck, 220); }
const [ex, ey] = off(len * 0.4, -len * 0.04); centerDot(ex, ey, len * 0.018, K.eye, 230);
}
// a rose: four nested scalloped rings, the heart drifting off centre so it hints at the spiral,
// and a few pale petal rims between the rings
function drawRose(x, y, s, F) {
const rings = [
{ r: 1.0, lobes: 9, inner: 0.8, col: F.light },
{ r: 0.74, lobes: 7, inner: 0.74, col: mixHex(F.light, F.mid, 0.5) },
{ r: 0.5, lobes: 6, inner: 0.7, col: F.mid },
{ r: 0.3, lobes: 5, inner: 0.66, col: F.deep },
];
const dx = rr(-0.08, 0.08) * s, dy = rr(-0.06, 0.06) * s;
// the outer ring gets a wider solid body so the bloom reads as paint, not a stain
rings.forEach((R, i) => bloomShape(x + dx * i, y + dy * i, s * R.r, R.lobes, R.inner, R.col, 0.26 - i * 0.03, 0.8 + i * 0.02));
if (S.detail > 0.3) {
for (let i = 0; i < 3; i++) {
const a = rr(0, TWO_PI), d = s * rr(0.5, 0.8);
washStyle(F.pale, 120); brush.polygon(jitterEllipse(x + Math.cos(a) * d, y + Math.sin(a) * d, s * 0.22, s * 0.045, 8, 0.15, a + HALF_PI));
}
}
centerDot(x + dx * 3, y + dy * 3, s * 0.1, mixHex(F.deep, F.shade, 0.4), 220);
}
// an orange: a solid disc, a shade crescent lower right, light upper left, one glint
function drawOrange(x, y, r, O) {
// mostly a solid wash disc: the bleed is kept low so the fruit has a rind, not a halo
const rot = rr(0, TWO_PI);
washStyle(O.mid, 252); brush.polygon(jitterEllipse(x, y, r * 0.94, r * 0.9, 14, 0.05, rot));
fillStyle(O.mid, 235, 0.12 + S.water * 0.1, 0.4, 0.3); brush.polygon(jitterEllipse(x, y, r, r * 0.96, 14, 0.05, rot));
washStyle(O.deep, 120); brush.polygon(jitterEllipse(x + r * 0.16, y + r * 0.2, r * 0.7, r * 0.6, 12, 0.08, 0.6));
washStyle(O.light, 140); brush.polygon(jitterEllipse(x - r * 0.22, y - r * 0.26, r * 0.4, r * 0.3, 10, 0.1, 0.7));
centerDot(x - r * 0.36, y - r * 0.4, r * 0.1, O.glint, 150);
}
// a reveal box of half-size r around a point, clipped to the canvas
function boxAround(x, y, r) { return [Math.max(0, x - r), Math.max(0, y - r), Math.min(W, x + r), Math.min(H, y + r)].map(Math.round); }
// pointed leaf dabs inside an ellipse, batched through dMarks. rotBias(x, y) gives each leaf a direction to fan along.
function dLeafDabs(cx, cy, rx, ry, n, cols, size, rotBias = null, op = [170, 235]) {
const marks = [];
n = Math.max(2, Math.round(n * (0.3 + 0.7 * S.detail)));
size *= S.brushSize * (1.4 - 0.4 * S.detail);
for (let i = 0; i < n; i++) {
const a = rr(0, TWO_PI), d = Math.sqrt(random());
const x = cx + Math.cos(a) * rx * d, y = cy + Math.sin(a) * ry * d, r = size * rr(0.6, 1.4);
const rot = rotBias ? rotBias(x, y) + rr(-0.5, 0.5) : rr(0, TWO_PI);
marks.push({ color: pickOne(cols), pts: petalPts(x, y, r * 2, r * 0.7, rot, 10, 0.1), op: Math.round(rr(op[0], op[1]) - S.water * 40) });
}
return dMarks(marks);
}
// Koi in swirling water. Darker, wetter and more heavily glazed than the
// shared dials; everything turns around one bright eye upper right.
function buildKoi(C) {
const out = [], { density: dens, scale: sc, detail: det, brushSize: bs } = S;
const water = 0.6, coverage = 0.9, glaze = 0.7, touch = 0.3;
const { washes, foliage, accents, highlights } = C;
const cx0 = W * 0.68, cy0 = H * 0.34;
const K = { body: vivid(accents[0], S.vivid), deep: accents[2] || accents[0], pale: accents[3] || mixHex(accents[1] || accents[0], '#ffffff', 0.4), fleck: highlights[0], eye: foliage[2] };
// the water: a lighter zone around the swirl, dark zones at the corners, a bright eye
out.push(dWash(washes[0], Math.round(coverage * 200)));
out.push(dBlob(cx0, cy0, W * 0.36, H * 0.3, washes[1] || washes[0], 200, 0.6, 0.5, 0.3, 0.25, 520));
out.push(dBlob(W * 0.15, H * 0.15, W * 0.3, H * 0.28, foliage[0], 210, 0.5, 0.5, 0.4, 0.25, 460));
out.push(dBlob(W * 0.2, H * 0.85, W * 0.34, H * 0.26, washes[2] || washes[0], 200, 0.5, 0.5, -0.3, 0.25, 460));
out.push(dBlob(W * 0.9, H * 0.9, W * 0.2, H * 0.2, foliage[1], 190, 0.5, 0.5, 0, 0.25, 380));
out.push(dBlob(W * 0.55, H * 0.55, W * 0.3, H * 0.14, washes[3] || washes[1] || washes[0], 150, 0.6, 0.5, -0.5, 0.25, 420));
out.push(dBlob(cx0, cy0, W * 0.1, H * 0.08, highlights[0], 90, 0.7, 0.5, 0, 0.25, 300));
// the swirl: marker glazes along arcs around the eye, opening out a little as
// they go, inside first and four to a stroke; then the ripple rings at the eye
const swirlCols = [washes[1] || washes[0], washes[3] || washes[1] || washes[0], washes[2] || washes[0], highlights[0], foliage[3] || washes[0], washes[0], highlights[1] || highlights[0]];
const nArc = Math.round((40 + glaze * 60) * (0.5 + 0.5 * det));
const arcs = [];
for (let i = 0; i < nArc; i++) {
const r = m * (0.05 + 0.62 * Math.pow(random(), 0.8)) * sc;
const a0 = rr(0, TWO_PI), span = rr(0.4, 1.6) * (r < m * 0.15 ? 1.6 : 1);
const pts = ringPts(cx0, cy0, r, a0, a0 + span, 9).map(([px, py], k) => { const g = 1 + k * 0.012; return [cx0 + (px - cx0) * g, cy0 + (py - cy0) * g]; });
arcs.push({ r, pts, color: pickOne(swirlCols), w: rr(1.4, 5) * bs * (0.6 + 0.4 * sc) });
}
arcs.sort((a, b) => a.r - b.r);
for (let i = 0; i < arcs.length; i += 4) {
const b = arcs.slice(i, i + 4);
const bb = bboxOf(b.flatMap((A) => A.pts), 14 + Math.max(...b.map((A) => A.w)) * 4);
out.push(D(() => { for (const A of b) { brush.field('waves'); strokeStyle('marker', A.color, A.w); brush.spline(A.pts, 0.5); brush.noField(); } }, bb, raxis(), rdir(), 260, 40));
}
const rings = [];
for (let k = 0; k < 4; k++) {
const r = m * (0.03 + k * 0.03) * sc, a0 = rr(0, TWO_PI);
const pts = ringPts(cx0 + rr(-2, 2), cy0 + rr(-2, 2), r, a0, a0 + TWO_PI * rr(0.7, 0.95), 14);
rings.push({ pts, color: k % 2 ? (highlights[1] || highlights[0]) : highlights[0], w: rr(1.5, 2.5) * bs });
}
out.push(D(() => { for (const R of rings) { strokeStyle('marker', R.color, R.w); brush.spline(R.pts, 0.5); } }, bboxOf(rings.flatMap((R) => R.pts), 24), 'y', -1, 320, 60));
// two shadow koi deep in the water, then the orange ones, each revealed tail to head
const shadows = [{ x: W * 0.28, y: H * 0.3, len: m * 0.3 * sc, a: 0.5 }, { x: W * 0.7, y: H * 0.78, len: m * 0.26 * sc, a: -2.6 }];
for (const s of shadows) {
const body = taperedBody(s.x, s.y, s.len, s.len * 0.3, s.a, 0.08);
out.push(D(() => { fillStyle(foliage[1], 130, 0.35 + water * 0.2, 0.5, 0.3); brush.polygon(body); }, bboxOf(body, s.len * 0.3 + 20), 'x', Math.sign(Math.cos(s.a)) || 1, 340, 60));
}
const koi = [
{ x: W * 0.6, y: H * 0.3, len: m * 0.3 * sc, a: -0.5 + rr(-0.15, 0.15) },
{ x: W * 0.3, y: H * 0.68, len: m * 0.36 * sc, a: 0.35 + rr(-0.15, 0.15) },
];
if (dens > 0.75) koi.push({ x: W * 0.85, y: H * 0.65, len: m * 0.2 * sc, a: 2.4 });
for (const k of koi) out.push(D(() => drawKoi(k.x, k.y, k.len, k.a, K), boxAround(k.x, k.y, k.len * 0.55 + 20), 'x', Math.sign(Math.cos(k.a)) || 1, 440, 100));
// cream flecks riding the swirl, a glaze back over each tail so the fish sit in the water, light at the eye
const flecks = [];
for (let i = 0; i < Math.round((30 + dens * 30) * (0.4 + 0.6 * det)); i++) {
const r = m * rr(0.06, 0.6) * sc, a = rr(0, TWO_PI), x = cx0 + Math.cos(a) * r, y = cy0 + Math.sin(a) * r;
if (x < -5 || x > W + 5 || y < -5 || y > H + 5) continue;
const len = m * rr(0.01, 0.03) * sc * bs, h = m * rr(0.002, 0.004) * bs;
flecks.push({ color: random() < 0.6 ? highlights[0] : (highlights[1] || highlights[0]), pts: jitterEllipse(x, y, len, h, 8, 0.2, a + HALF_PI + rr(-0.2, 0.2)), op: Math.round(rr(150, 230)) });
}
out.push(...dMarks(flecks));
for (const k of koi) {
const tx = k.x - Math.cos(k.a) * k.len * 0.35, ty = k.y - Math.sin(k.a) * k.len * 0.35;
const th = Math.atan2(ty - cy0, tx - cx0), r = Math.hypot(tx - cx0, ty - cy0);
const pts = ringPts(cx0, cy0, r, th - 0.3, th + 0.3, 6), w = rr(3, 5) * bs;
out.push(D(() => { brush.field('waves'); strokeStyle('marker', washes[1] || washes[0], w); brush.spline(pts, 0.5); brush.noField(); }, bboxOf(pts, 14 + w * 4), raxis(), rdir(), 200, 40));
}
const eyePts = [];
for (let i = 0; i < 8; i++) { const a = rr(0, TWO_PI), r = m * rr(0.05, 0.4); eyePts.push([cx0 + Math.cos(a) * r, cy0 + Math.sin(a) * r, 10]); }
out.push(...dTouches(eyePts, C, Math.round(touch * 16), () => rr(0, TWO_PI), 0.6));
return out;
}
// Roses in dappled light. Painted a little larger than the shared scale and
// less saturated than the shared vivid, so the pinks stay soft.
function buildRoses(C) {
const out = [], { water, density: dens, soft, detail: det, brushSize: bs } = S;
const sc = 1.2;
const { washes, accents, highlights } = C;
const F = flowerCols(C, 0.25), G = foliageCols(C);
const lilac = washes[0], gold = washes[1] || highlights[0], blue = washes[2] || lilac, cream = washes[3] || highlights[1] || highlights[0];
// a lilac-grey ground, a gold glow upper right, a cool patch low left, the dark leaf mass upper left
if (S.coverage > 0.02) out.push(dWash(lilac, Math.round(S.coverage * 200)));
out.push(dBlob(W * 0.5, H * 0.6, W * 0.55, H * 0.45, lilac, 190, 0.5, 0.5, 0, 0.2, 520));
out.push(dBlob(W * 0.78, H * 0.14, W * 0.34, H * 0.24, gold, 200, 0.6, 0.5, 0.2, 0.25, 480));
out.push(dBlob(W * 0.85, H * 0.06, W * 0.2, H * 0.12, highlights[0], 150, 0.7, 0.5, 0, 0.25, 360));
out.push(dBlob(W * 0.2, H * 0.85, W * 0.32, H * 0.22, blue, 160, 0.6, 0.5, 0, 0.25, 440));
out.push(dBlob(W * 0.18, H * 0.14, W * 0.26, H * 0.2, G.shade, 215, 0.4 + water * 0.2, 0.5, 0.3, 0.25, 440));
out.push(dBlob(W * 0.36, H * 0.08, W * 0.16, H * 0.1, G.deep, 200, 0.45, 0.5, -0.2, 0.25, 340));
out.push(dBlob(W * 0.08, H * 0.36, W * 0.14, H * 0.12, G.deep, 190, 0.45, 0.5, 0.4, 0.25, 340));
// the dapple, everywhere, then leaves in the dark mass and a few loose ones right of the roses
const dapple = [];
const dotCols = [lilac, gold, blue, cream, highlights[1] || cream, highlights[0], accents[3] || F.pale, mixHex(lilac, '#ffffff', 0.4)];
for (let i = 0; i < Math.round((120 + dens * 160) * (0.3 + 0.7 * det)); i++) {
const x = rr(-5, W + 5), y = rr(-5, H + 5), r = m * rr(0.008, 0.02) * sc * bs * (1.4 - 0.4 * det);
dapple.push({ color: pickOne(dotCols), pts: jitterEllipse(x, y, r * rr(1, 1.6), r, 12, 0.1 + soft * 0.08, rr(0, TWO_PI)), op: Math.round(rr(90, 170) - water * 30) });
}
out.push(...dMarks(dapple));
out.push(...dLeafDabs(W * 0.2, H * 0.16, W * 0.26, H * 0.18, Math.round(22 + dens * 24), [G.shade, G.deep, G.mid, G.light], m * 0.011 * sc, null, [130, 200]));
out.push(...dLeafDabs(W * 0.62, H * 0.28, W * 0.14, H * 0.12, Math.round(6 + dens * 8), [G.deep, G.mid, G.light], m * 0.011 * sc, null, [130, 200]));
// a pale bud-rose behind the group, two stems up into the leaves, then the roses back to front
const roses = [
{ x: W * 0.32, y: H * 0.36, s: m * 0.115 * sc },
{ x: W * 0.54, y: H * 0.6, s: m * 0.15 * sc },
{ x: W * 0.28, y: H * 0.7, s: m * 0.11 * sc },
];
if (dens > 0.7) roses.push({ x: W * 0.78, y: H * 0.8, s: m * 0.08 * sc });
const bud = { x: W * 0.74, y: H * 0.5, s: m * 0.07 * sc };
out.push(D(() => bloomShape(bud.x, bud.y, bud.s, 7, 0.76, mixHex(F.light, cream, 0.5), 0.3, 0.72), boxAround(bud.x, bud.y, bud.s * 2.2 + 16), raxis(), rdir(), 320, 80));
const sorted = roses.slice().sort((a, b) => a.y - b.y);
for (const r of sorted.slice(0, 2)) {
const stem = [[r.x - r.s * 0.2, r.y - r.s * 0.95], [r.x - W * 0.06 + rr(-8, 8), r.y - r.s - H * 0.1], [r.x - W * 0.12, H * 0.2]];
const w = rr(1.8, 2.6) * bs;
out.push(D(() => { brush.field('hand'); strokeStyle('marker', G.deep, w); brush.spline(stem, 0.6); brush.noField(); }, bboxOf(stem, 14 + w * 4), 'y', 1, 220, 40));
}
for (const r of sorted) out.push(D(() => drawRose(r.x, r.y, r.s, F), boxAround(r.x, r.y, r.s * 2.2 + 16), raxis(), rdir(), 460, 100));
// a lighter cream and gold dapple over the roses' rims, then light
const light = [];
for (let i = 0; i < Math.round(30 + dens * 30); i++) {
const r0 = pickOne(roses), a = rr(0, TWO_PI), d = r0.s * rr(1.1, 1.6);
const x = r0.x + Math.cos(a) * d, y = r0.y + Math.sin(a) * d, r = m * rr(0.006, 0.012) * sc * bs;
light.push({ color: random() < 0.5 ? cream : (random() < 0.5 ? gold : (highlights[1] || cream)), pts: jitterEllipse(x, y, r * 1.5, r, 8, 0.2, rr(0, TWO_PI)), op: 100 });
}
out.push(...dMarks(light));
out.push(...dTouches(roses.map((r) => [r.x, r.y - r.s * 0.6, r.s * 0.8]), C, Math.round(S.touch * 16), () => rr(-0.4, 0.4), 0.7));
return out;
}
// An orange tree against the sky. A little drier than the shared dials so
// the sky stays clear and the fruit keeps a rind instead of a halo.
function buildOranges(C) {
const out = [], { density: dens, scale: sc, detail: det, brushSize: bs } = S;
const water = 0.45;
const { washes, foliage, accents, highlights } = C;
const G = foliageCols(C);
const O = { light: vivid(accents[2] || accents[0], S.vivid), mid: vivid(accents[0], S.vivid), deep: accents[3] || accents[1] || accents[0], glint: highlights[1] || highlights[0] };
const canopyX = W * 0.62, canopyY = H * 0.58;
// a blue sky, brightest upper left, a pale ground behind the leaves lower right
if (S.coverage > 0.02) out.push(dWash(washes[2] || washes[0], Math.round(S.coverage * 200)));
out.push(dBand(-20, H * 0.45, washes[0], Math.round(200 - water * 40), m * 0.04));
out.push(dBlob(W * 0.15, H * 0.2, W * 0.3, H * 0.28, washes[1] || washes[0], 190, 0.6, 0.5, 0.2, 0.2, 480));
out.push(dBlob(W * 0.1, H * 0.5, W * 0.16, H * 0.2, mixHex(washes[0], highlights[0], 0.5), 150, 0.7, 0.5, 0, 0.25, 360));
out.push(dBand(H * 0.4, H + 20, washes[3] || washes[2] || washes[0], Math.round(190 - water * 40), m * 0.04));
// canopy masses lower right and top right, blossom specks where the sky meets the leaves
out.push(dBlob(canopyX, canopyY, W * 0.44, H * 0.4, mixHex(G.deep, G.shade, 0.5), 235, 0.35 + water * 0.2, 0.5, 0.2, 0.25, 540));
out.push(dBlob(W * 0.8, H * 0.14, W * 0.26, H * 0.14, G.deep, 205, 0.45, 0.5, -0.3, 0.25, 400));
out.push(dBlob(W * 0.5, H * 0.85, W * 0.36, H * 0.16, G.shade, 215, 0.4, 0.5, 0.1, 0.25, 420));
out.push(dBlob(W * 0.85, H * 0.62, W * 0.2, H * 0.24, G.shade, 200, 0.4, 0.5, 0, 0.25, 380));
out.push(dBlob(W * 0.35, H * 0.42, W * 0.18, H * 0.14, G.mid, 190, 0.5, 0.5, 0.4, 0.25, 340));
const specks = [];
for (let i = 0; i < Math.round((24 + dens * 30) * (0.4 + 0.6 * det)); i++) {
const x = rr(0, W * 0.55), y = rr(0, H * 0.5), r = m * rr(0.004, 0.008) * sc * bs;
specks.push({ color: random() < 0.7 ? highlights[0] : (highlights[1] || highlights[0]), pts: jitterEllipse(x, y, r, r * rr(0.8, 1), 8, 0.2), op: Math.round(rr(170, 240)) });
}
out.push(...dMarks(specks));
// the fruit is planned now so the leaf pass can fan around it
const fruit = [
{ x: W * 0.42, y: H * 0.36, r: m * 0.09 * sc },
{ x: W * 0.8, y: H * 0.28, r: m * 0.075 * sc },
{ x: W * 0.56, y: H * 0.66, r: m * 0.085 * sc },
];
if (dens > 0.4) fruit.push({ x: W * 0.1, y: H * 0.9, r: m * 0.06 * sc });
if (dens > 0.7) fruit.push({ x: W * 0.92, y: H * 0.86, r: m * 0.055 * sc });
// the leaf pass, fanning out from the canopy centre, then the oranges
const leafCols = [G.shade, G.deep, G.mid, G.light, mixHex(G.mid, highlights[1] || highlights[0], 0.35), foliage[0]];
const fan = (x, y) => Math.atan2(y - canopyY, x - canopyX);
out.push(...dLeafDabs(canopyX, canopyY, W * 0.48, H * 0.44, Math.round(150 + dens * 150), leafCols, m * 0.013 * sc, fan, [150, 235]));
out.push(...dLeafDabs(W * 0.8, H * 0.14, W * 0.26, H * 0.14, Math.round(26 + dens * 30), leafCols, m * 0.012 * sc, fan, [150, 235]));
out.push(...dLeafDabs(W * 0.3, H * 0.42, W * 0.2, H * 0.14, Math.round(18 + dens * 20), [G.mid, G.light, leafCols[4]], m * 0.011 * sc, fan, [150, 235]));
for (const f of fruit) out.push(D(() => drawOrange(f.x, f.y, f.r, O), boxAround(f.x, f.y, f.r * 1.6 + 16), 'y', 1, 380, 90));
// small half-hidden oranges, a few leaves crossing the fruit, light on the rinds
const small = [];
for (let i = 0; i < 3 + Math.round(dens * 4); i++) {
const x = rr(W * 0.2, W * 0.98), y = rr(H * 0.2, H * 0.98), r = m * rr(0.02, 0.035) * sc;
small.push({ color: random() < 0.5 ? O.mid : O.deep, pts: jitterEllipse(x, y, r, r * 0.95, 10, 0.1), op: 215 });
}
out.push(...dMarks(small, 260));
const over = [];
for (const f of fruit) for (let k = 0; k < 2; k++) {
const a = rr(0, TWO_PI), x = f.x + Math.cos(a) * f.r * 0.9, y = f.y + Math.sin(a) * f.r * 0.9, r = m * rr(0.012, 0.02) * sc * bs;
over.push({ color: random() < 0.5 ? G.deep : G.mid, pts: petalPts(x, y, r * 2, r * 0.7, a + rr(-0.6, 0.6), 10, 0.1), op: 200 });
}
out.push(...dMarks(over));
out.push(...dTouches(fruit.map((f) => [f.x - f.r * 0.3, f.y - f.r * 0.4, f.r * 0.5]), C, Math.round(S.touch * 14), () => rr(-0.5, 0.5), 0.6));
return out;
}
// Evening sea, one sail. Wetter than the shared dials, with more still-water
// glazes and more light on the water.
function buildSail(C) {
const out = [], { density: dens, scale: sc, soft, detail: det, brushSize: bs } = S;
const water = 0.6, glaze = 0.5, touch = 0.3;
const { washes, foliage, accents, highlights } = C;
const horizon = H * 0.52, sunX = W * 0.56;
const sky = washes[0], sea = washes[1], glow = washes[2] || highlights[0], ripple = washes[3] || mixHex(sea, foliage[0], 0.4);
// sky, a warm band at the horizon, the sea in two bands, the glow's reflection
if (S.coverage > 0.02) out.push(dWash(sky, Math.round(S.coverage * 200)));
out.push(dBand(-20, horizon * 0.7, sky, Math.round(200 - water * 40), m * 0.033));
out.push(dBand(horizon * 0.55, horizon + H * 0.02, glow, Math.round(190 - water * 40), m * 0.03));
out.push(dBand(horizon - H * 0.01, H * 0.8, sea, Math.round(210 - water * 40), m * 0.017));
out.push(dBand(H * 0.7, H + 20, mixHex(sea, ripple, 0.35), Math.round(215 - water * 40), m * 0.02));
out.push(dBlob(sunX, horizon + H * 0.12, W * 0.2, H * 0.14, mixHex(glow, highlights[0], 0.5), 150, 0.7, 0.5, 0, 0.15, 460));
// cumulus: a big lit cloud upper right, a low bank left, each a grey underside
// under a peach body and a lit crown; then wisps near the horizon
const under = mixHex(sea, foliage[2], 0.3);
const clouds = [
{ x: W * 0.62, y: H * 0.2, rx: W * 0.2, ry: H * 0.12 },
{ x: W * 0.85, y: H * 0.32, rx: W * 0.16, ry: H * 0.07 },
{ x: W * 0.12, y: H * 0.24, rx: W * 0.17, ry: H * 0.06 },
{ x: W * 0.3, y: H * 0.4, rx: W * 0.22, ry: H * 0.045 },
];
if (dens > 0.6) clouds.push({ x: W * 0.45, y: H * 0.1, rx: W * 0.1, ry: H * 0.05 });
for (const c of clouds) {
const peach = random() < 0.5 ? accents[0] : (accents[1] || accents[0]);
out.push(dBlob(c.x, c.y + c.ry * 0.3, c.rx, c.ry * 0.7, under, 170, 0.5 + water * 0.2, 0.5, 0, 0.2, 380));
out.push(dBlob(c.x, c.y, c.rx * 0.95, c.ry, peach, 215, 0.45 + water * 0.2, 0.5, rr(-0.1, 0.1), 0.25 + soft * 0.15, 420));
out.push(dBlob(c.x - c.rx * 0.2, c.y - c.ry * 0.45, c.rx * 0.6, c.ry * 0.6, accents[2] || highlights[0], 200, 0.5, 0.5, rr(-0.2, 0.2), 0.3, 340));
}
for (let i = 0; i < 3; i++) {
const x = rr(W * 0.1, W * 0.9), y = rr(horizon * 0.62, horizon * 0.92);
out.push(dBlob(x, y, W * rr(0.08, 0.16), H * rr(0.012, 0.025), under, 120, 0.6, 0.5, 0, 0.3, 300));
}
// the ripples: short horizontal dashes, grey-blue with a few warm ones, longer
// and denser toward the bottom; then still-water glazes
const dashes = [];
for (let i = 0; i < Math.round((90 + dens * 160) * (0.3 + 0.7 * det)); i++) {
const y = horizon + (H - horizon) * Math.pow(random(), 0.7), near = (y - horizon) / (H - horizon), x = rr(-5, W + 5);
const len = m * rr(0.012, 0.04) * sc * (0.5 + near) * bs, h = m * rr(0.002, 0.0035) * (0.6 + near) * bs;
const dark = random() < 0.65;
const color = dark ? (random() < 0.5 ? ripple : mixHex(ripple, sea, 0.4)) : (random() < 0.5 ? highlights[0] : mixHex(glow, highlights[1] || highlights[0], 0.5));
dashes.push({ color, pts: jitterEllipse(x, y, len, h, 8, 0.2, rr(-0.06, 0.06)), op: Math.round(rr(120, 200) - water * 30) });
}
out.push(...dMarks(dashes));
for (let i = 0; i < Math.round(3 + glaze * 8); i++) {
const y = rr(horizon + 5, H), x0 = rr(-10, W * 0.5), x1 = x0 + rr(W * 0.3, W * 0.8);
const color = random() < 0.5 ? sea : (random() < 0.5 ? ripple : glow);
out.push(dGlaze(x0, y, x1, y + rr(-3, 3), color, rr(1.5, 3) * Math.sqrt(sc), 'waves'));
}
// the sail and its hull over a faint reflection, a rowboat with two specks aboard, glints down the light
const sx = W * 0.55, sy = horizon + H * 0.1, sh = m * 0.165 * sc;
const sailPts = [[sx, sy - sh], [sx + sh * 0.45 + rr(-2, 2), sy], [sx - sh * 0.04, sy]];
const jib = [[sx - sh * 0.02, sy - sh * 0.7], [sx - sh * 0.3, sy - sh * 0.02], [sx - sh * 0.03, sy]];
const hull = jitterEllipse(sx + sh * 0.05, sy + sh * 0.03, sh * 0.32, sh * 0.035, 8, 0.15);
out.push(dBlob(sx, sy + sh * 0.5, sh * 0.35, sh * 0.5, highlights[1] || highlights[0], 110, 0.7, 0.5, 0, 0.2, 320));
out.push(D(() => {
washStyle(highlights[1] || highlights[0], 250); brush.polygon(sailPts); brush.polygon(jib);
bloomFill(highlights[0], 160, 0.15); brush.polygon(sailPts);
washStyle(foliage[1], 235); brush.polygon(hull);
}, bboxOf([...sailPts, ...jib, ...hull], 24), 'y', -1, 440, 90));
const bx = W * 0.38, by = horizon + H * 0.18;
const boat = jitterEllipse(bx, by, m * 0.02 * sc, m * 0.004 * sc, 8, 0.15);
out.push(D(() => {
washStyle(foliage[2], 230); brush.polygon(boat);
centerDot(bx - m * 0.005, by - m * 0.006, m * 0.004, foliage[2], 220); centerDot(bx + m * 0.006, by - m * 0.005, m * 0.0035, foliage[2], 220);
}, bboxOf(boat, 14), 'x', rdir(), 160, 40));
const glintPts = [];
for (let i = 0; i < 10; i++) glintPts.push([sunX + randomGaussian(0, W * 0.1), rr(horizon + 5, H * 0.95), 12]);
out.push(...dTouches(glintPts, C, Math.round(touch * 18), () => rr(-0.05, 0.05)));
return out;
}
// =====================================================================
// Configs. Everything the vocabulary closes over that isn't in
// engine.js goes in `dials` so Copy code can emit it.
// =====================================================================
const VOCABULARY = [
softBlobDraw, bloomFill, bloomShape, centerDot, flowerCols, foliageCols,
drawPeony, drawDaisy, drawBlossom, drawLily, drawFlower, drawPad, drawStrand,
glazeDraw, flickDraw,
dWash, dBand, dBlob, dGlaze, dStrand, dLeaves, dMarks, dFlower, dPad, dFlick, dTouches,
buildGarden, buildDawn, buildWillow, buildWisteria,
buildPoppies, buildIrises, buildOrchard,
ringPts, petalPts, taperedBody, drawKoi, drawRose, drawOrange, boxAround, dLeafDabs,
buildKoi, buildRoses, buildOranges, buildSail,
];
// `strokes` must name its builder literally: Copy code serialises this arrow
// with toString(), so a captured `builder` variable would be undefined in the
// emitted file. Each one has to resolve against VOCABULARY by name.
const scene = (key, label, note, strokes) => ({
key: 'impressionism-' + key,
movement: 'Impressionism',
label,
note,
canvasSize: 600,
loopMs: 9000,
holdMs: 1500,
dials: S,
palette: SCENES[key],
strokes,
vocabulary: VOCABULARY,
});
STYLES.impressionismGarden = scene('garden', 'Garden',
'Warm and a little too much, in the best way.',
(palette) => buildGarden(palette));
STYLES.impressionismDawn = scene('dawn', 'Dawn',
'Quiet, like nothing has happened yet.',
(palette) => buildDawn(palette));
STYLES.impressionismWillow = scene('willow', 'Willow',
'Cool and wistful, the kind of quiet you don’t want to leave.',
(palette) => buildWillow(palette));
STYLES.impressionismWisteria = scene('wisteria', 'Wisteria',
'Delicate in a way that already feels like a memory.',
(palette) => buildWisteria(palette));
STYLES.impressionismPoppies = scene('poppies', 'Poppies',
'Red that refuses to be background.',
(palette) => buildPoppies(palette));
STYLES.impressionismIrises = scene('irises', 'Irises',
'Purple standing at attention, politely.',
(palette) => buildIrises(palette));
STYLES.impressionismOrchard = scene('orchard', 'Orchard',
'Light and a little giddy, spring before it’s sure of itself.',
(palette) => buildOrchard(palette));
STYLES.impressionismKoi = scene('koi', 'Koi',
'Stillness is just movement too slow to notice.',
(palette) => buildKoi(palette));
STYLES.impressionismRoses = scene('roses', 'Roses',
'Everything opens at its own speed.',
(palette) => buildRoses(palette));
STYLES.impressionismOranges = scene('oranges', 'Oranges',
'Ripening is just time becoming sweet.',
(palette) => buildOranges(palette));
STYLES.impressionismSail = scene('sail', 'Sail',
'A boat is mostly waiting for wind.',
(palette) => buildSail(palette));
})();