From 9d1258d55f84318c419f129c54097d2ed14e8a49 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Mon, 10 Aug 2026 23:46:02 +0000 Subject: [PATCH] Give Breathe, Vignette, and Aura their own pieces Breathe is the second brush that turns out not to be one. Its sheet puts Construct's Bulge over the painting and drives the magnitude from a Sine — a .04s expand timer inside a 6s cycle — so like Rainbow it becomes a pixel transform and swells the painting instead of drawing a pulsing oval. The resample invents no pixels; a test holds it to that. Vignette has its own size list, 16|32|64|96|128, that nothing recorded before, plus hardness = round(random(radius/2, radius-2)) and two lightness ranges — .05–.2 for a dark vignette, .7–.9 for a light one, chosen by choose(0, 1). Aura's emitter numbers are exact (spray random(90,110), rate random(2,4), the three AdjustHSL ranges, the 1s Change timer); how its particles actually look is not in the sheet, and the contract says so. Co-Authored-By: Claude Opus 5 (1M context) --- system/public/aesthetic.computer/disks/aura.mjs | 14 ++++++++++++++ system/public/aesthetic.computer/disks/breathe.mjs | 46 ++++++++++++++++++++++++++++++++++++++++++++++ system/public/aesthetic.computer/disks/nopaint.mjs | 6 ++++++ system/public/aesthetic.computer/disks/vignette.mjs | 14 ++++++++++++++ system/public/aesthetic.computer/lib/nopaint-construct-breathe.mjs | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs | 17 +---------------- system/public/aesthetic.computer/lib/nopaint-construct-vignette.mjs | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ system/tests/nopaint-construct-catalog.test.mjs | 2 +- system/tests/nopaint-construct-fields.test.mjs | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 file(s) changed, 465 insertion(s)(+), 17 deletion(s)(-) diff --git a/system/public/aesthetic.computer/disks/aura.mjs b/system/public/aesthetic.computer/disks/aura.mjs new file mode 100644 --- /dev/null +++ b/system/public/aesthetic.computer/disks/aura.mjs @@ -0,0 +1,14 @@ +// Aura, 26.08.10 +// The original No Paint aura as a standalone AC piece. + +import { createNoPaintBrushPiece } from "../lib/nopaint-brush-piece.mjs"; +import { auraProposal as nopaintProposal } from "../lib/nopaint-construct-vignette.mjs"; + +const system = "nopaint"; +const piece = createNoPaintBrushPiece(nopaintProposal, { + title: "Aura", + desc: "Paint with the recovered emitter's coloured bloom.", +}); + +export const { boot, sim, paint, bake, meta } = piece; +export { nopaintProposal, system }; diff --git a/system/public/aesthetic.computer/disks/breathe.mjs b/system/public/aesthetic.computer/disks/breathe.mjs new file mode 100644 --- /dev/null +++ b/system/public/aesthetic.computer/disks/breathe.mjs @@ -0,0 +1,46 @@ +// Breathe, 26.08.10 +// The original No Paint bulge as a standalone AC piece. It is a pixel +// transform, not a brush: it swells what is already painted. + +import { breatheProposal as nopaintProposal } from "../lib/nopaint-construct-breathe.mjs"; +import { seededRandom } from "../lib/nopaint-proposals.mjs"; + +const system = "nopaint"; +let applied = false; + +function boot({ hud }) { + applied = false; + hud?.label?.("Breathe"); +} + +function paint({ system: sys, page, screen, needsPaint }) { + if (applied || !sys?.nopaint?.is?.("painting")) return false; + const painting = sys.painting; + if (!painting?.pixels) return false; + const score = nopaintProposal.generate({ + random: seededRandom(`breathe:${painting.width}:${painting.height}`), + width: painting.width, + height: painting.height, + base: { color: [0, 0, 0, 0] }, + }); + painting.pixels.set(nopaintProposal.applyPixels( + painting.pixels, painting.width, painting.height, score.brush.parameters)); + applied = true; + page(screen); + needsPaint(); + return true; +} + +function bake() { + applied = false; +} + +function meta() { + return { + title: "Breathe", + desc: "Swell the painting the way the original Bulge did.", + controls: "tap to take one breath", + }; +} + +export { boot, paint, bake, meta, nopaintProposal, system }; diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -24,6 +24,9 @@ import { nopaintProposal as gridWormPieceProposal } from "./grid-worm.mjs"; import { nopaintProposal as trianglePieceProposal } from "./triangle.mjs"; import { nopaintProposal as ellipsePieceProposal } from "./ellipse.mjs"; import { nopaintProposal as rainbowPieceProposal } from "./rainbow.mjs"; +import { nopaintProposal as breathePieceProposal } from "./breathe.mjs"; +import { nopaintProposal as vignettePieceProposal } from "./vignette.mjs"; +import { nopaintProposal as auraPieceProposal } from "./aura.mjs"; import { gridWormProposal } from "../lib/nopaint-construct-brushes.mjs"; import { nonConflictingConstructProposals } from "../lib/nopaint-construct-catalog.mjs"; import { recoveredConstructTransforms } from "../lib/nopaint-construct-transforms.mjs"; @@ -56,6 +59,9 @@ [gridWormPieceProposal.slug, gridWormPieceProposal], [trianglePieceProposal.slug, trianglePieceProposal], [ellipsePieceProposal.slug, ellipsePieceProposal], [rainbowPieceProposal.slug, rainbowPieceProposal], + [breathePieceProposal.slug, breathePieceProposal], + [vignettePieceProposal.slug, vignettePieceProposal], + [auraPieceProposal.slug, auraPieceProposal], ])); let loopState = "choosing"; diff --git a/system/public/aesthetic.computer/disks/vignette.mjs b/system/public/aesthetic.computer/disks/vignette.mjs new file mode 100644 --- /dev/null +++ b/system/public/aesthetic.computer/disks/vignette.mjs @@ -0,0 +1,14 @@ +// Vignette, 26.08.10 +// The original No Paint vignette as a standalone AC piece. + +import { createNoPaintBrushPiece } from "../lib/nopaint-brush-piece.mjs"; +import { vignetteProposal as nopaintProposal } from "../lib/nopaint-construct-vignette.mjs"; + +const system = "nopaint"; +const piece = createNoPaintBrushPiece(nopaintProposal, { + title: "Vignette", + desc: "Paint with the recovered soft field, dark or light.", +}); + +export const { boot, sim, paint, bake, meta } = piece; +export { nopaintProposal, system }; diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-breathe.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-breathe.mjs new file mode 100644 --- /dev/null +++ b/system/public/aesthetic.computer/lib/nopaint-construct-breathe.mjs @@ -0,0 +1,83 @@ +// Breathe, recovered from the Construct "Breathe" sheet. Like Rainbow it is not +// a brush: it never draws anything. It puts Construct's Bulge effect over the +// painting and drives the effect's magnitude from a Sine behaviour, so it +// belongs with the pixel transforms. +// +// The sheet: size is picked from the list "64|96|128|196|256" by +// ProcessNumericParameter(3, 1, 5); radius is size / 2; a repeating 0.04s +// "BulgeExpand" timer sets Bulge parameter 1 to abs(Sine.Value); a repeating +// 6s "BulgeCycle" timer restarts the sine and replays the theme, whose volume +// is -5 - (5 - size / 256 * 5), so a bigger breath is a louder one. +// +// A pixel transform applies once, so this takes one point off that sine. The +// 0.04s / 6s pair is recorded in `source` for when a transform can animate. + +const frozen = (value) => Object.freeze(value); + +export const BREATHE = frozen({ + canvas: 256, + sizes: frozen([64, 96, 128, 196, 256]), + expandSeconds: .04, // Timer "BulgeExpand" + cycleSeconds: 6, // Timer "BulgeCycle" + effect: "Bulge", + cue: "breathe - theme", + cueVolume: (size) => -5 - (5 - size / 256 * 5), +}); + +export const breatheProposal = frozen({ + version: 1, + slug: "breathe", + label: "Breathe", + compatible: true, + kind: "pixel-transform", + fidelity: "event-sheet-exact", + source: frozen({ sizes: BREATHE.sizes, expandSeconds: BREATHE.expandSeconds, + cycleSeconds: BREATHE.cycleSeconds, effect: BREATHE.effect, cue: BREATHE.cue, + file: "/nopaint.art/data.json", sheet: "Breathe", + magnitude: "abs(Sine.Value), refreshed every .04s over a 6s cycle", + animation: "not modeled — a pixel transform is applied once, not per frame", + // Construct's Bulge is a shader; this is a radial resample of the same + // shape rather than a port of it. + reconstructed: frozen(["the bulge falloff"]) }), + generate({ random, width, height, base }) { + const size = BREATHE.sizes[Math.floor(random() * BREATHE.sizes.length)]; + const scale = Math.min(width, height) / BREATHE.canvas; + // One point off the sine: anywhere in its swing is a legal magnitude. + const magnitude = random(); + return frozen({ ...base, kind: "breathe", transform: "breathe", + brush: frozen({ slug: "breathe", params: frozen([String(size)]), + colon: frozen([]), + parameters: frozen({ size, radius: size / 2 * scale, magnitude, + cue: BREATHE.cue, cueVolume: BREATHE.cueVolume(size), + x: Math.floor(random() * width), y: Math.floor(random() * height) }) }) }); + }, + applyPixels(pixels, width, height, { radius, magnitude, x, y }) { + const output = new Uint8ClampedArray(pixels); + const centerX = x ?? width / 2; + const centerY = y ?? height / 2; + const left = Math.max(0, Math.floor(centerX - radius)); + const right = Math.min(width - 1, Math.ceil(centerX + radius)); + const top = Math.max(0, Math.floor(centerY - radius)); + const bottom = Math.min(height - 1, Math.ceil(centerY + radius)); + for (let py = top; py <= bottom; py += 1) { + for (let px = left; px <= right; px += 1) { + const dx = px - centerX; + const dy = py - centerY; + const distance = Math.hypot(dx, dy); + if (distance >= radius || distance === 0) continue; + // Pull the sample toward the centre by more the nearer it is, which is + // what makes the middle swell. + const pull = 1 - magnitude * (1 - distance / radius) ** 2; + const sourceX = Math.round(centerX + dx * pull); + const sourceY = Math.round(centerY + dy * pull); + if (sourceX < 0 || sourceX >= width || sourceY < 0 || sourceY >= height) continue; + const to = (py * width + px) * 4; + const from = (sourceY * width + sourceX) * 4; + for (let channel = 0; channel < 4; channel += 1) { + output[to + channel] = pixels[from + channel]; + } + } + } + return output; + }, +}); diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs --- a/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs @@ -39,23 +39,8 @@ { blip: true, noiseShift: 1 / 30, alphaLevels: [60, 80] }, ({ random, width, height, base }) => ({ points: points(random, width, height, 24), color: base.color }), ({ ink }, s, frame) => { const visible = Math.max(2, Math.min(s.points.length, Math.ceil(frame / 3))); for (let i = 1; i < visible; i++) ink(s.color).line(s.points[i - 1].x, s.points[i - 1].y, s.points[i].x, s.points[i].y, s.thickness); }); -export const auraProposal = contract("aura", "Aura", - { angle: [90, 110], repetitions: [2, 4], amount: [0, 100], radius: [25, 120], spread: [60, 180] }, - ({ random, width, height, base }) => ({ center: frozen({ x: random() * width, y: random() * height }), radius: 25 + random() * 95, repetitions: random() < .5 ? 2 : 4, color: base.color }), - ({ ink }, s, frame) => { for (let i = s.repetitions; i > 0; i--) { const r = s.radius * i / s.repetitions * (1 + .08 * Math.sin(frame / 30)); ink(s.color[0], s.color[1], s.color[2], 35).oval(s.center.x, s.center.y, r * 2, r * 2, false, Math.max(1, r / 10)); } }); - -export const breatheProposal = contract("breathe", "Breathe", - { sizes: [64, 96, 128, 196, 256], bulge: "BulgeCycle" }, - ({ random, width, height, base }) => ({ size: [64, 96, 128, 196, 256][Math.floor(random() * 5)], center: frozen({ x: random() * width, y: random() * height }), color: base.color }), - ({ ink }, s, frame) => { const scale = .6 + .4 * (1 + Math.sin(frame / 30)); ink(s.color).oval(s.center.x, s.center.y, s.size * scale, s.size * scale, true); }); - -export const vignetteProposal = contract("vignette", "Vignette", { parameters: ["Radius", "Hardness"] }, - ({ random, width, height, base }) => ({ center: frozen({ x: width / 2, y: height / 2 }), radius: Math.min(width, height) * (.25 + random() * .25), color: base.color }), - ({ ink }, s) => { for (let i = 5; i > 0; i--) ink(s.color[0], s.color[1], s.color[2], 16).oval(s.center.x, s.center.y, s.radius * (1 + i * .18) * 2, s.radius * (1 + i * .18) * 2, false, Math.max(2, s.radius * .12)); }); - // A name only stays in this fallback catalog until a real piece owns it; what is // left here is the last of the procedural brushes. export const nonConflictingConstructProposals = frozen([ - buildProposal, bubblesProposal, bannerProposal, - walkerProposal, auraProposal, breatheProposal, vignetteProposal, + buildProposal, bubblesProposal, bannerProposal, walkerProposal, ]); diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-vignette.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-vignette.mjs new file mode 100644 --- /dev/null +++ b/system/public/aesthetic.computer/lib/nopaint-construct-vignette.mjs @@ -0,0 +1,172 @@ +// Vignette and Aura, recovered from the Construct sheets of the same names. +// Both lay a soft coloured field over the painting rather than drawing a shape, +// and both take their colours from hslaToRgba with recovered ranges, so they +// share a module. Constants read out of the compiled expression table. + +const frozen = (value) => Object.freeze(value); +const choose = (random, values) => values[Math.floor(random() * values.length)]; +const between = (random, [low, high]) => low + random() * (high - low); + +export const VIGNETTE = frozen({ + canvas: 256, + sizes: frozen([16, 32, 64, 96, 128]), + // hardness = round(random(radius / 2, radius - 2)) + hardness: frozen([.5, 1]), + hardnessInset: 2, + dark: frozen([0, 1]), // choose(0, 1): dark vignette or light + drift: frozen([-3, 3]), // choose(-3, 3) + angle: frozen([0, 360]), + // hslaToRgba(random(0,1), random(0,1), random(.05,.2) | random(.7,.9), 1) + darkLightness: frozen([.05, .2]), + lightLightness: frozen([.7, .9]), + cue: "vignette - theme", + tween: frozen(["Radius", "Hardness"]), +}); + +export const AURA = frozen({ + canvas: 256, + spray: frozen([90, 110]), // random(90, 110) + rate: frozen([2, 4]), // random(2, 4) + // The three AdjustHSL parameters the emitter is given at birth. + hue: frozen([0, 100]), + saturation: frozen([25, 120]), + lightness: frozen([60, 180]), + changeSeconds: 1, // Timer "Change" + changeHue: frozen([-100, 100]), // and lightness is re-rolled from its range + changeAngle: frozen([0, 360]), + cue: "aura - theme", + cueVolume: frozen([-100, -7.5]), // faded in over three seconds + cueFadeSeconds: 3, +}); + +export function hslaToRgba(hue, saturation, lightness) { + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const sector = ((hue % 1) + 1) % 1 * 6; + const second = chroma * (1 - Math.abs(sector % 2 - 1)); + const [r, g, b] = [[chroma, second, 0], [second, chroma, 0], [0, chroma, second], + [0, second, chroma], [second, 0, chroma], [chroma, 0, second]][Math.floor(sector) % 6]; + const base = lightness - chroma / 2; + return frozen([r, g, b].map((channel) => Math.round((channel + base) * 255))); +} + +// One soft radial field, opaque inside `hardness` and falling to nothing at the +// rim — the same ramp Softy stamps, drawn once and large. +function field(layer, centerX, centerY, radius, hardness, color, peak) { + const falloff = Math.max(1, radius - hardness); + const top = Math.max(0, Math.floor(centerY - radius)); + const bottom = Math.min(layer.height - 1, Math.ceil(centerY + radius)); + for (let y = top; y <= bottom; y += 1) { + const span = Math.sqrt(Math.max(0, radius * radius - (y - centerY) ** 2)); + const left = Math.max(0, Math.floor(centerX - span)); + const right = Math.min(layer.width - 1, Math.ceil(centerX + span)); + for (let x = left; x <= right; x += 1) { + const distance = Math.hypot(x - centerX, y - centerY); + const alpha = distance <= hardness + ? peak + : peak * (1 - (distance - hardness) / falloff); + if (alpha <= 0) continue; + const at = (y * layer.width + x) * 4; + if (layer.pixels[at + 3] >= alpha) continue; + layer.pixels[at] = color[0]; + layer.pixels[at + 1] = color[1]; + layer.pixels[at + 2] = color[2]; + layer.pixels[at + 3] = alpha; + } + } +} + +const layers = new WeakMap(); +function layerFor(score, draw) { + let layer = layers.get(score); + if (!layer) { + const width = Math.max(1, Math.round(score.width)); + const height = Math.max(1, Math.round(score.height)); + layer = { width, height, pixels: new Uint8ClampedArray(width * height * 4) }; + layers.set(score, layer); + draw(layer); + } + return layer; +} + +export const vignetteProposal = frozen({ + version: 1, + slug: "vignette", + label: "Vignette", + compatible: true, + source: frozen({ ...VIGNETTE, actionSheet: "Vignette", + // Construct tweened Radius and Hardness; the proposal holds one pose. + reconstructed: frozen(["the radius/hardness tween"]) }), + generate({ random, width, height, base }) { + const size = choose(random, VIGNETTE.sizes); + const scale = Math.min(width, height) / VIGNETTE.canvas; + const radius = Math.max(2, size * scale); + const hardness = Math.round(between(random, + [radius / 2, Math.max(radius / 2, radius - VIGNETTE.hardnessInset)])); + const dark = choose(random, VIGNETTE.dark) === 0; + const lightness = between(random, + dark ? VIGNETTE.darkLightness : VIGNETTE.lightLightness); + return frozen({ ...base, kind: "vignette", + size, radius, hardness, dark, + color: hslaToRgba(random(), random(), lightness), + angle: Math.floor(between(random, VIGNETTE.angle)), + drift: choose(random, VIGNETTE.drift), + x: Math.floor(random() * width), y: Math.floor(random() * height), + width, height, + brush: frozen({ slug: "vignette", params: frozen([String(size)]), + colon: frozen([]), + parameters: frozen({ size, hardness, dark, cue: VIGNETTE.cue }) }) }); + }, + render({ paste }, score, tick) { + const layer = layerFor(score, (target) => { + field(target, score.x, score.y, score.radius, score.hardness, score.color, 200); + }); + paste(layer, 0, 0); + }, +}); + +export const auraProposal = frozen({ + version: 1, + slug: "aura", + label: "Aura", + compatible: true, + source: frozen({ ...AURA, actionSheet: "Aura", object: "AuraParticles", + // The emitter's AdjustHSL numbers are exact; how its particles look is not + // in the sheet, so the bloom is an AC reading. + reconstructed: frozen(["the particle bloom"]) }), + generate({ random, width, height, base }) { + const scale = Math.min(width, height) / AURA.canvas; + const spray = between(random, AURA.spray); + const rate = between(random, AURA.rate); + const angle = Math.floor(between(random, AURA.changeAngle)); + const hue = between(random, AURA.hue) / 100; + const saturation = between(random, AURA.saturation) / 120; + const lightness = between(random, AURA.lightness) / 180; + const petals = Math.max(2, Math.round(rate * 4)); + return frozen({ ...base, kind: "aura", + spray, rate, angle, petals, + radius: Math.max(4, between(random, [25, 120]) * scale), + color: hslaToRgba(hue, saturation, Math.min(.9, lightness)), + x: Math.floor(random() * width), y: Math.floor(random() * height), + width, height, + brush: frozen({ slug: "aura", + params: frozen([String(Math.round(spray)), String(angle)]), + colon: frozen([]), + parameters: frozen({ spray, rate, angle, cue: AURA.cue }) }) }); + }, + render({ paste }, score, tick) { + const layer = layerFor(score, (target) => { + // The emitter sprays through `spray` degrees around `angle` at `rate`. + for (let petal = 0; petal < score.petals; petal += 1) { + const offset = (petal / Math.max(1, score.petals - 1) - .5) * score.spray; + const radians = (score.angle + offset) * Math.PI / 180; + const reach = score.radius * (.4 + .6 * (petal % 3) / 2); + field(target, + score.x + Math.cos(radians) * reach, + score.y + Math.sin(radians) * reach, + score.radius / 2, score.radius / 8, score.color, 90); + } + field(target, score.x, score.y, score.radius / 2, 0, score.color, 140); + }); + paste(layer, 0, 0); + }, +}); diff --git a/system/tests/nopaint-construct-catalog.test.mjs b/system/tests/nopaint-construct-catalog.test.mjs --- a/system/tests/nopaint-construct-catalog.test.mjs +++ b/system/tests/nopaint-construct-catalog.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { seededRandom } from "../public/aesthetic.computer/lib/nopaint-proposals.mjs"; import { nonConflictingConstructProposals } from "../public/aesthetic.computer/lib/nopaint-construct-catalog.mjs"; -const expected = ["aura", "banner", "breathe", "bubbles", "build", "vignette", "walker"]; +const expected = ["banner", "bubbles", "build", "walker"]; const base = Object.freeze({ color: Object.freeze([20, 40, 60, 128]), x: 20, y: 30, w: 180, h: 140, drift: 8, thickness: 3, points: Object.freeze([]), phase: 0 }); test("all non-conflicting recovered brush names have deterministic contracts", () => { diff --git a/system/tests/nopaint-construct-fields.test.mjs b/system/tests/nopaint-construct-fields.test.mjs new file mode 100644 --- /dev/null +++ b/system/tests/nopaint-construct-fields.test.mjs @@ -0,0 +1,128 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { seededRandom } from "../public/aesthetic.computer/lib/nopaint-proposals.mjs"; +import { + AURA, + VIGNETTE, + auraProposal, + vignetteProposal, +} from "../public/aesthetic.computer/lib/nopaint-construct-vignette.mjs"; +import { + BREATHE, + breatheProposal, +} from "../public/aesthetic.computer/lib/nopaint-construct-breathe.mjs"; + +const base = Object.freeze({ + color: Object.freeze([20, 40, 60, 128]), x: 10, y: 20, w: 120, h: 80, + drift: 4, thickness: 2, points: Object.freeze([]), phase: 0, +}); +const make = (contract, seed, width = 256, height = 256) => + contract.generate({ random: seededRandom(seed), width, height, base }); + +test("Breathe keeps the recovered size list and timers", () => { + assert.deepEqual(BREATHE.sizes, [64, 96, 128, 196, 256]); + assert.equal(BREATHE.expandSeconds, .04); + assert.equal(BREATHE.cycleSeconds, 6); + assert.equal(BREATHE.effect, "Bulge"); + assert.equal(BREATHE.cue, "breathe - theme"); + // Volume is -5 - (5 - size / 256 * 5): a bigger breath is a louder one. + assert.ok(BREATHE.cueVolume(256) > BREATHE.cueVolume(64)); + assert.equal(BREATHE.cueVolume(256), -5); +}); + +test("Breathe is a pixel transform that swells without inventing pixels", () => { + assert.equal(breatheProposal.kind, "pixel-transform"); + assert.equal(breatheProposal.render, undefined); + const score = make(breatheProposal, "breathe"); + assert.deepEqual(score, make(breatheProposal, "breathe")); + assert.ok(BREATHE.sizes.includes(score.brush.parameters.size)); + + // A 9×9 field with one marked pixel at the centre; the bulge must magnify it. + const width = 9, height = 9; + const pixels = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < width * height; i += 1) { + pixels[i * 4 + 3] = 255; + pixels[i * 4] = 10; + } + const middle = (4 * width + 4) * 4; + pixels[middle] = 250; + const swelled = breatheProposal.applyPixels(pixels, width, height, + { radius: 4, magnitude: .9, x: 4, y: 4 }); + const bright = [...swelled].filter((_, i) => i % 4 === 0).filter((v) => v === 250).length; + assert.ok(bright > 1, `the centre pixel is magnified (${bright} pixels carry it)`); + // Every value must have come from the source; a resample invents nothing. + const sourceValues = new Set([...pixels].filter((_, i) => i % 4 === 0)); + [...swelled].filter((_, i) => i % 4 === 0).forEach((value) => + assert.ok(sourceValues.has(value), `${value} was resampled, not invented`)); +}); + +test("Vignette keeps its own size list and both lightness ranges", () => { + assert.deepEqual(VIGNETTE.sizes, [16, 32, 64, 96, 128]); + assert.deepEqual(VIGNETTE.darkLightness, [.05, .2]); + assert.deepEqual(VIGNETTE.lightLightness, [.7, .9]); + assert.deepEqual(VIGNETTE.dark, [0, 1]); + assert.deepEqual(VIGNETTE.drift, [-3, 3]); + assert.equal(VIGNETTE.cue, "vignette - theme"); + + const seen = { dark: 0, light: 0 }; + for (let seed = 0; seed < 200; seed += 1) { + const score = make(vignetteProposal, `vignette:${seed}`); + assert.ok(VIGNETTE.sizes.includes(score.size)); + assert.ok(VIGNETTE.drift.includes(score.drift)); + assert.ok(score.angle >= 0 && score.angle < 360); + // hardness sits between half the radius and two short of it. + assert.ok(score.hardness >= score.radius / 2 - 1 && score.hardness <= score.radius); + seen[score.dark ? "dark" : "light"] += 1; + } + assert.ok(seen.dark > 0 && seen.light > 0, "choose(0, 1) reaches both"); +}); + +test("Vignette and Aura lay a soft field that fades outward", () => { + for (const contract of [vignetteProposal, auraProposal]) { + const score = make(contract, `${contract.slug}:field`, 128, 128); + assert.deepEqual(score, make(contract, `${contract.slug}:field`, 128, 128)); + const pasted = []; + contract.render({ paste: (...args) => pasted.push(args) }, score, 0); + assert.equal(pasted.length, 1, `${contract.slug} pastes one field`); + const [layer, x, y] = pasted[0]; + assert.deepEqual([x, y], [0, 0]); + assert.equal(layer.width, 128); + const painted = layer.pixels.reduce( + (total, value, index) => index % 4 === 3 && value > 0 ? total + 1 : total, 0); + assert.ok(painted > 0, `${contract.slug} paints something`); + const alphas = new Set(); + for (let index = 3; index < layer.pixels.length; index += 4) { + if (layer.pixels[index] > 0) alphas.add(layer.pixels[index]); + } + assert.ok(alphas.size > 1, `${contract.slug} has a falloff, not a flat disc`); + } +}); + +test("Aura keeps the emitter's recovered ranges", () => { + assert.deepEqual(AURA.spray, [90, 110]); + assert.deepEqual(AURA.rate, [2, 4]); + assert.deepEqual(AURA.hue, [0, 100]); + assert.deepEqual(AURA.saturation, [25, 120]); + assert.deepEqual(AURA.lightness, [60, 180]); + assert.equal(AURA.changeSeconds, 1); + assert.deepEqual(AURA.changeHue, [-100, 100]); + assert.equal(AURA.cue, "aura - theme"); + assert.deepEqual(AURA.cueVolume, [-100, -7.5]); + for (let seed = 0; seed < 100; seed += 1) { + const score = make(auraProposal, `aura:${seed}`); + assert.ok(score.spray >= AURA.spray[0] && score.spray <= AURA.spray[1]); + assert.ok(score.rate >= AURA.rate[0] && score.rate <= AURA.rate[1]); + assert.ok(score.angle >= 0 && score.angle < 360); + } +}); + +test("No Paint 3 resolves breathe, vignette, and aura to their own pieces", async () => { + const { COMPATIBLE_BRUSHES } = await import( + "../public/aesthetic.computer/disks/nopaint.mjs"); + for (const slug of ["breathe", "vignette", "aura"]) { + const piece = await import(`../public/aesthetic.computer/disks/${slug}.mjs`); + assert.equal(piece.system, "nopaint"); + assert.equal(piece.nopaintProposal.slug, slug); + assert.equal(COMPATIBLE_BRUSHES.get(slug), piece.nopaintProposal); + } +}); -- tangled.sh