diff --git a/docs/nopaint-construct-migration-ledger.md b/docs/nopaint-construct-migration-ledger.md new file mode 100644 index 0000000000..724c6a6d42 --- /dev/null +++ b/docs/nopaint-construct-migration-ledger.md @@ -0,0 +1,44 @@ +# No Paint Construct operation migration ledger + +The surviving export has no `.c3p` source or source map. Its authorities are +`nopaint.art/data.json`, the expression table in +`nopaint.art/scripts/c3runtime.js`, the sprite sheets, and `nopaint.art/media/`. + +The canonical picker vocabulary contains 38 names: + +`Aura, Banner, Blur, Breathe, Bubbles, Build, Box, Camera, Caterpillar, +Contrast, Dark Window, Ellipse, Flip, Grid Worm, Invert, Light Bump, Line, +Load, Mirror, Noise, Quicksand, Rainbow, Recurse, Saturate, Scroll, Sharpen, +Softy, Spin, Stamp, Turn, Triangle, Vignette, Wafer, Walker, Wipe, Zoom, Frame, +Playlist`. + +## Brush migration + +The non-conflicting brush names migrated as deterministic proposal contracts +are `aura`, `banner`, `breathe`, `bubbles`, `build`, `caterpillar`, +`dark-window`, `ellipse`, `frame`, `grid-worm`, `rainbow`, `softy`, `triangle`, +`vignette`, `wafer`, and `walker`. + +Exact disk-name conflicts are `blur`, `box`, `camera`, `line`, `noise`, +`stamp`, and `wipe`. They are deliberately not recreated under those names. +The existing AC `line` contract remains usable by No Paint, but is not claimed +as a migration of the Construct implementation. + +The safe image filters/transforms `contrast`, `flip`, `invert`, `light-bump`, +`mirror`, `quicksand`, `recurse`, `saturate`, `scroll`, `sharpen`, `spin`, +`turn`, and `zoom` are also migrated and wired into the proposal conductor. +`load` and `playlist` are control/meta operations and are deliberately not +presented as visual brushes. + +## Fidelity boundary + +Recovered score constants, deterministic state, timing values, grid/frame +choices, and available primary sound samples are retained. Construct-only +effects including Exclusion blending, Bulge, AdjustHSL, Vignette and sprite +timeline behaviors have no equivalent in the current AC ink API. Their AC +renderers use bounded translucent primitives and do not claim pixel parity. + +Dark Window selects and plays each of its four original note samples. Build, +Wafer, and Caterpillar have multi-cue event vocabularies, while the current No +Paint conductor owns one active brush sample at a time. Their primary sample is +wired; exact per-event cue sequencing requires a conductor audio-timeline API. diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs index 23ca192cfa..577c57116b 100644 --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -12,17 +12,22 @@ import { } from "../lib/nopaint-proposals.mjs"; import { nopaintProposal as lineProposal } from "./line.mjs"; import { darkWindowProposal, gridWormProposal } from "../lib/nopaint-construct-brushes.mjs"; +import { nonConflictingConstructProposals } from "../lib/nopaint-construct-catalog.mjs"; +import { recoveredConstructTransforms } from "../lib/nopaint-construct-transforms.mjs"; const COMPATIBLE_BRUSHES = Object.freeze(new Map([ [lineProposal.slug, lineProposal], [gridWormProposal.slug, gridWormProposal], [darkWindowProposal.slug, darkWindowProposal], + ...nonConflictingConstructProposals.map((contract) => [contract.slug, contract]), + ...recoveredConstructTransforms.map((contract) => [contract.slug, contract]), ])); let loopState = "choosing"; let stateBeforePause = "proposing"; let proposal = null; let proposalFrame = 0; +let proposalPixels = null; let proposalNumber = 0; let sessionSeed = 0; let random = seededRandom(1); @@ -79,6 +84,28 @@ const BRUSH_CUES = Object.freeze({ "dark-window:2": "dark window - note 2.webm", "dark-window:3": "dark window - note 3.webm", "dark-window:4": "dark window - note 4.webm", + aura: "aura - theme.webm", + build: "build - builder's beat.webm", + breathe: "breathe - theme.webm", + caterpillar: "caterpillar - trotting along.webm", + ellipse: "elipse - start.webm", + frame: "frame - knock.webm", + rainbow: "rainbow - theme.webm", + triangle: "triangle - start.webm", + vignette: "vignette - theme.webm", + contrast: "contrast - theme.webm", + flip: "flip - first flipping.webm", + invert: "invert - on.webm", + "light-bump": "light bump - theme.webm", + mirror: "mirror - theme.webm", + quicksand: "quicksand - brief theme.webm", + recurse: "recurse - thup.webm", + saturate: "saturate - theme.webm", + scroll: "scroll - theme.webm", + sharpen: "sharpen - theme.webm", + spin: "spin - theme.webm", + turn: "turn - note 1.webm", + zoom: "zoom - in.webm", walker: "common - jitter.webm", banner: "banner - theme.webm", wafer: "wafer - nibble appear.webm", @@ -374,6 +401,14 @@ function chooseProposal(api) { base: baseProposal, }) : baseProposal; + proposalPixels = compatibleBrush?.applyPixels + ? compatibleBrush.applyPixels( + api.system.painting.pixels, + resolution.width, + resolution.height, + proposal.brush.parameters, + ) + : null; proposalNumber += 1; proposalFrame = 0; transition("proposing"); @@ -773,6 +808,13 @@ function renderProposal($) { page(buffer).wipe(255, 255, 255, 0); + const contract = COMPATIBLE_BRUSHES.get(proposal.kind); + if (proposalPixels) { + buffer.pixels.set(proposalPixels); + } else if (contract?.render && proposal.kind !== "line") { + contract.render($, proposal, proposalFrame); + } else + if (proposal.kind === "rect") { ink(color).box( proposal.x + drift, @@ -979,8 +1021,9 @@ async function completePainting($) { nextResolution.h, (page) => page.wipe(255, 255, 255, 0), ); - proposal = null; - proposalFrame = 0; + proposal = null; + proposalFrame = 0; + proposalPixels = null; decisions = []; finishMode = false; transition("proposing"); diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs new file mode 100644 index 0000000000..8d23813013 --- /dev/null +++ b/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs @@ -0,0 +1,90 @@ +// Deterministic adapters for non-conflicting Construct-era No Paint brush names. +// The source constants below are transcribed from the exported C3 expression +// table; rendering uses AC primitives and therefore does not claim pixel parity +// for Construct effects (Bulge, Vignette, AdjustHSL, sprite animation). + +const frozen = (value) => Object.freeze(value); +const points = (random, width, height, count) => frozen(Array.from({ length: count }, () => frozen({ + x: Math.floor(random() * width), y: Math.floor(random() * height), + size: 4 + Math.floor(random() * Math.max(5, Math.min(width, height) / 10)), +}))); + +function contract(slug, label, source, generateScore, render) { + return frozen({ version: 1, slug, label, compatible: true, source: frozen(source), + generate(context) { + const score = generateScore(context); + return frozen({ ...context.base, ...score, kind: slug, + brush: frozen({ slug, params: frozen([]), colon: frozen([]), + parameters: frozen({ source, ...(score.parameters || {}) }) }) }); + }, render }); +} + +export const softyProposal = contract("softy", "Softy", + { modes: ["S", "M", "L"], radii: [[6, 16], [16, 48], [48, 64]], landingFrames: 4 }, + ({ random, width, height, base }) => ({ points: points(random, width, height, 12), mode: ["S", "M", "L"][Math.floor(random() * 3)], color: base.color }), + ({ ink }, s, frame) => s.points.forEach((p, i) => ink(s.color[0], s.color[1], s.color[2], 22).oval(p.x, p.y, p.size * (1 + .18 * Math.sin(frame / 24 + i * .7)), p.size * (1 + .18 * Math.sin(frame / 24 + i * .7)), true))); + +export const buildProposal = contract("build", "Build", + { brickSizes: [4, 8, 16, 32, 64, 128], colorAlpha: 1 }, + ({ random, width, height, base }) => ({ brickSize: [4, 8, 16, 32, 64, 128][Math.floor(random() * 6)], points: points(random, width, height, 24), color: base.color }), + ({ ink }, s, frame) => s.points.slice(0, Math.max(1, Math.min(s.points.length, Math.ceil(frame / 4)))).forEach(p => ink(s.color).box(Math.floor(p.x / s.brickSize) * s.brickSize, Math.floor(p.y / s.brickSize) * s.brickSize, s.brickSize, s.brickSize))); + +export const bubblesProposal = contract("bubbles", "Bubbles", + { spawnRates: [.025, .05, .1], renderStep: 1 / 60, scale: [.8, 1.2], rise: [-5, -4, -3] }, + ({ random, width, height, base }) => ({ rate: [.025, .05, .1][Math.floor(random() * 3)], points: points(random, width, height, 18), color: base.color, height }), + ({ ink }, s, frame) => s.points.forEach((p, i) => ink(s.color).oval(p.x, (p.y - frame * (3 + i % 3) + s.height) % s.height, p.size, p.size, false, 1))); + +export const bannerProposal = contract("banner", "Banner", + { hueChannels: 3, segmentCounts: [4, 8, 16], zipperChoices: [1, 2, 3, 4], depthChoices: [1, 2, 5] }, + ({ random, base }) => ({ segments: [4, 8, 16][Math.floor(random() * 3)], color: base.color }), + ({ ink }, s, frame) => { for (let i = 0; i < s.segments; i++) ink(s.color[0], s.color[1], s.color[2], 60 + i * 8).box(s.x + Math.sin(frame / 24 + i * .5) * s.drift, s.y + i * s.h / s.segments, s.w, Math.max(1, s.h / s.segments - 1)); }); + +export const waferProposal = contract("wafer", "Wafer", + { cellSizes: [16, 16, 32, 32, 32, 32, 48, 48, 48, 64, 64, 96], bites: 3 }, + ({ random, base }) => ({ cellSize: [16, 16, 32, 32, 32, 32, 48, 48, 48, 64, 64, 96][Math.floor(random() * 12)], color: base.color }), + ({ ink }, s, frame) => { for (let y = 0; y < s.h; y += s.cellSize) for (let x = 0; x < s.w; x += s.cellSize) if ((x / s.cellSize + y / s.cellSize + Math.floor(frame / 30)) % 5) ink(s.color).box(s.x + x, s.y + y, s.cellSize - 1, s.cellSize - 1); }); + +export const walkerProposal = contract("walker", "Walker", + { 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 triangleProposal = contract("triangle", "Triangle", + { colorChannels: [5, 6], frameCycle: 3 }, + ({ random, width, height, base }) => ({ points: points(random, width, height, 3), color: base.color }), + ({ ink }, s) => ink(s.color).poly(s.points.map(p => [p.x, p.y]))); + +export const ellipseProposal = contract("ellipse", "Ellipse", { speed: .35 }, + ({ base }) => ({ color: base.color }), ({ ink }, s, frame) => ink(s.color).oval(s.x + s.w / 2, s.y + s.h / 2, s.w * (1 + .1 * Math.sin(frame * .35)), s.h * (1 + .1 * Math.sin(frame * .35)), true)); + +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)); }); + +export const caterpillarProposal = contract("caterpillar", "Caterpillar", + { segmentChoices: [1, 3, 32], scales: [.2, .7], frames: [0, 1, 2, 3] }, + ({ random, width, height, base }) => ({ count: [1, 3, 32][Math.floor(random() * 3)], points: points(random, width, height, 32), color: base.color }), + ({ ink }, s, frame) => s.points.slice(0, s.count).forEach((p, i) => ink(s.color).oval(p.x + Math.sin(frame / 12 + i) * p.size, p.y, p.size, p.size, true))); + +export const frameProposal = contract("frame", "Frame", { cycle: "CycleFrame", sound: "frame - knock" }, + ({ base }) => ({ color: base.color }), ({ ink }, s, frame) => { const inset = Math.floor(frame / 15) % Math.max(1, Math.floor(Math.min(s.w, s.h) / 4)); ink(s.color).box(s.x + inset, s.y + inset, s.w - inset * 2, 2); ink(s.color).box(s.x + inset, s.y + s.h - inset - 2, s.w - inset * 2, 2); ink(s.color).box(s.x + inset, s.y + inset, 2, s.h - inset * 2); ink(s.color).box(s.x + s.w - inset - 2, s.y + inset, 2, s.h - inset * 2); }); + +export const rainbowProposal = contract("rainbow", "Rainbow", + { effect: "AdjustHSL", hueRange: [-100, 100] }, + ({ base }) => ({ color: base.color }), ({ ink }, s, frame) => { for (let i = 0; i < 7; i++) { const hue = (frame / 120 + i / 7) * Math.PI * 2; ink(Math.round(128 + Math.sin(hue) * 127), Math.round(128 + Math.sin(hue + 2.094) * 127), Math.round(128 + Math.sin(hue + 4.188) * 127), 96).box(s.x, s.y + i * s.h / 7, s.w, Math.ceil(s.h / 7)); } }); + +export const nonConflictingConstructProposals = frozen([ + softyProposal, buildProposal, bubblesProposal, bannerProposal, waferProposal, + walkerProposal, auraProposal, triangleProposal, ellipseProposal, breatheProposal, + vignetteProposal, caterpillarProposal, frameProposal, rainbowProposal, +]); diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-transforms.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-transforms.mjs new file mode 100644 index 0000000000..6a635cfecc --- /dev/null +++ b/system/public/aesthetic.computer/lib/nopaint-construct-transforms.mjs @@ -0,0 +1,160 @@ +// Deterministic pixel transforms recovered from the Construct-era No Paint export. +// Evidence: nopaint.art/data.json event sheets named by each `source.sheet` value. +// The export's plugin/action ids are retained in source notes where their implementation +// is not present in the event sheet; those contracts are honestly labelled reconstructed. + +const clampByte = value => Math.max(0, Math.min(255, Math.round(value))); +const freeze = value => Object.freeze(value); +const pixelIndex = (x, y, width) => (y * width + x) * 4; +const copyPixel = (source, target, fromX, fromY, toX, toY, width) => { + const from = pixelIndex(fromX, fromY, width), to = pixelIndex(toX, toY, width); + for (let channel = 0; channel < 4; channel += 1) target[to + channel] = source[from + channel]; +}; +const mapPixels = (pixels, callback) => { + const output = new Uint8ClampedArray(pixels); + for (let index = 0; index < output.length; index += 4) callback(output, index); + return output; +}; +const wrapped = (value, size) => ((value % size) + size) % size; + +function contract(slug, label, fidelity, evidence, defaults, applyPixels) { + return freeze({ + version: 1, slug, label, compatible: true, kind: "pixel-transform", fidelity, + source: freeze({ file: "/nopaint.art/data.json", sheet: label, ...evidence }), + generate({ random, width, height, base }) { + const parameters = freeze(defaults({ random, width, height })); + return freeze({ ...base, kind: slug, transform: slug, brush: freeze({ + slug, params: freeze([]), colon: freeze([]), parameters, + }) }); + }, + applyPixels, + }); +} + +export const mirrorTransform = contract("mirror", "Mirror", "event-sheet-exact", + { variables: freeze(["position", "direction"]), renderFunction: "MirrorRender", pixelAction: 180 }, + ({ random, width }) => ({ axis: Math.floor(random() * width), direction: random() < .5 ? -1 : 1 }), + (pixels, width, height, { axis, direction }) => { + const output = new Uint8ClampedArray(pixels); + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { + const reflected = Math.max(0, Math.min(width - 1, 2 * axis - x)); + if ((direction < 0 && x > axis) || (direction >= 0 && x < axis)) copyPixel(pixels, output, reflected, y, x, y, width); + } + return output; + }); + +export const flipTransform = contract("flip", "Flip", "event-sheet-exact", + { variables: freeze(["vertically", "sound"]), pixelAction: 193, timerSeconds: 1 }, + ({ random }) => ({ vertically: random() < .5 }), + (pixels, width, height, { vertically }) => { + const output = new Uint8ClampedArray(pixels.length); + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) + copyPixel(pixels, output, x, y, vertically ? x : width - 1 - x, vertically ? height - 1 - y : y, width); + return output; + }); + +export const invertTransform = contract("invert", "Invert", "event-sheet-exact", + { variables: freeze(["inverted"]), audio: freeze(["invert - on", "invert - off"]) }, + () => ({ inverted: true }), + pixels => mapPixels(pixels, (output, index) => { + output[index] = 255 - output[index]; output[index + 1] = 255 - output[index + 1]; output[index + 2] = 255 - output[index + 2]; + })); + +export const saturateTransform = contract("saturate", "Saturate", "event-sheet-equivalent", + { variables: freeze(["saturateShift", "direction"]), timerTagExpression: 397 }, + ({ random }) => ({ shift: .1 + random() * .4, direction: random() < .5 ? -1 : 1 }), + (pixels, width, height, { shift, direction }) => mapPixels(pixels, (output, index) => { + const r = output[index], g = output[index + 1], b = output[index + 2]; + const gray = .2126 * r + .7152 * g + .0722 * b, amount = direction * shift; + output[index] = clampByte(gray + (r - gray) * (1 + amount)); + output[index + 1] = clampByte(gray + (g - gray) * (1 + amount)); + output[index + 2] = clampByte(gray + (b - gray) * (1 + amount)); + })); + +export const contrastTransform = contract("contrast", "Contrast", "event-sheet-equivalent", + { variables: freeze(["contrastShift", "direction"]), timerTagExpression: 238 }, + ({ random }) => ({ shift: .1 + random() * .4, direction: random() < .5 ? -1 : 1 }), + (pixels, width, height, { shift, direction }) => mapPixels(pixels, (output, index) => { + const factor = 1 + direction * shift; + for (let channel = 0; channel < 3; channel += 1) output[index + channel] = clampByte((output[index + channel] - 128) * factor + 128); + })); + +export const scrollTransform = contract("scroll", "Scroll", "event-sheet-equivalent", + { variables: freeze(["gap", "horizontal"]), renderFunction: "ScrollRender", pixelAction: 179 }, + ({ random, width, height }) => ({ horizontal: random() < .5, offset: 1 + Math.floor(random() * Math.max(width, height)) }), + (pixels, width, height, { horizontal, offset }) => { + const output = new Uint8ClampedArray(pixels.length); + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) + copyPixel(pixels, output, x, y, horizontal ? wrapped(x + offset, width) : x, horizontal ? y : wrapped(y + offset, height), width); + return output; + }); + +function centeredSample(pixels, width, height, scale, rotation = 0) { + const output = new Uint8ClampedArray(pixels.length), cx = (width - 1) / 2, cy = (height - 1) / 2; + const cosine = Math.cos(rotation), sine = Math.sin(rotation); + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { + const dx = (x - cx) / scale, dy = (y - cy) / scale; + const sx = Math.round(cx + dx * cosine + dy * sine), sy = Math.round(cy - dx * sine + dy * cosine); + if (sx >= 0 && sx < width && sy >= 0 && sy < height) copyPixel(pixels, output, sx, sy, x, y, width); + } + return output; +} + +export const zoomTransform = contract("zoom", "Zoom", "event-sheet-equivalent", + { variables: freeze(["zoomMax", "zoomLevel", "zoomIn", "x", "y"]), audio: freeze(["zoom - in", "zoom - out"]) }, + ({ random }) => ({ zoomIn: random() < .5, level: 1 + Math.floor(random() * 4) }), + (pixels, width, height, { zoomIn, level }) => centeredSample(pixels, width, height, zoomIn ? 1 + level / 8 : 1 / (1 + level / 8))); + +export const recurseTransform = contract("recurse", "Recurse", "event-sheet-equivalent", + { variables: freeze(["zoomLevel", "zoomDivision", "zoomOut", "x", "y"]), pixelAction: 258 }, + ({ random }) => ({ zoomOut: random() < .5, division: 2 + Math.floor(random() * 9) }), + (pixels, width, height, { zoomOut, division }) => centeredSample(pixels, width, height, zoomOut ? 1 - 1 / division : 1 + 1 / division)); + +export const spinTransform = contract("spin", "Spin", "event-sheet-equivalent", + { variables: freeze(["direction", "spinAngle"]), audio: freeze(["spin - theme"]) }, + ({ random }) => ({ direction: random() < .5 ? -1 : 1, angle: 5 + Math.floor(random() * 55) }), + (pixels, width, height, { direction, angle }) => centeredSample(pixels, width, height, 1, direction * angle * Math.PI / 180)); + +export const turnTransform = contract("turn", "Turn", "event-sheet-equivalent", + { variables: freeze(["rotation", "direction"]), notes: 4 }, + ({ random }) => ({ direction: random() < .5 ? -1 : 1, quarterTurns: 1 + Math.floor(random() * 3) }), + (pixels, width, height, { direction, quarterTurns }) => centeredSample(pixels, width, height, 1, direction * quarterTurns * Math.PI / 2)); + +export const sharpenTransform = contract("sharpen", "Sharpen", "reconstructed-plugin-equivalent", + { variables: freeze(["shift", "direction"]), pluginActions: freeze([152, 60]), limitation: "C3 plugin kernels are numeric-only in export; uses the standard 3x3 sharpen kernel." }, + ({ random }) => ({ amount: .25 + random() * .75 }), + (pixels, width, height, { amount }) => { + const output = new Uint8ClampedArray(pixels); + for (let y = 1; y < height - 1; y += 1) for (let x = 1; x < width - 1; x += 1) for (let channel = 0; channel < 3; channel += 1) { + const index = pixelIndex(x, y, width) + channel; + const neighbours = pixels[index - 4] + pixels[index + 4] + pixels[index - width * 4] + pixels[index + width * 4]; + output[index] = clampByte(pixels[index] * (1 + 4 * amount) - neighbours * amount); + } + return output; + }); + +export const quicksandTransform = contract("quicksand", "Quicksand", "reconstructed-plugin-equivalent", + { variables: freeze(["swirlAmount", "swirlDirection"]), pixelActions: freeze([129, 130]), limitation: "Pixel-access implementation is opaque; deterministic polar swirl preserves the exposed state model." }, + ({ random }) => ({ amount: .2 + random() * .8, direction: random() < .5 ? -1 : 1 }), + (pixels, width, height, { amount, direction }) => { + const output = new Uint8ClampedArray(pixels.length), cx = width / 2, cy = height / 2, radius = Math.hypot(cx, cy); + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { + const dx = x - cx, dy = y - cy, distance = Math.hypot(dx, dy), angle = Math.atan2(dy, dx) + direction * amount * (1 - distance / radius); + const sx = Math.round(cx + Math.cos(angle) * distance), sy = Math.round(cy + Math.sin(angle) * distance); + if (sx >= 0 && sx < width && sy >= 0 && sy < height) copyPixel(pixels, output, sx, sy, x, y, width); + } + return output; + }); + +export const lightBumpTransform = contract("light-bump", "Light Bump", "reconstructed-plugin-equivalent", + { variables: freeze(["brightnessShift", "direction"]), limitation: "Export exposes state and timing but not the numeric plugin's exact luminance operator." }, + ({ random }) => ({ shift: 12 + Math.floor(random() * 53), direction: random() < .5 ? -1 : 1 }), + (pixels, width, height, { shift, direction }) => mapPixels(pixels, (output, index) => { + for (let channel = 0; channel < 3; channel += 1) output[index + channel] = clampByte(output[index + channel] + shift * direction); + })); + +export const recoveredConstructTransforms = freeze([ + mirrorTransform, flipTransform, invertTransform, saturateTransform, contrastTransform, + scrollTransform, zoomTransform, recurseTransform, spinTransform, turnTransform, + sharpenTransform, quicksandTransform, lightBumpTransform, +]); diff --git a/system/public/aesthetic.computer/lib/nopaint-proposals.mjs b/system/public/aesthetic.computer/lib/nopaint-proposals.mjs index f14618cbd8..7b61ecfe76 100644 --- a/system/public/aesthetic.computer/lib/nopaint-proposals.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-proposals.mjs @@ -28,7 +28,10 @@ export const NOPAINT_RECOVERED_CATALOG = Object.freeze({ export const NOPAINT_IMPLEMENTED_OPERATIONS = Object.freeze([ "box", "ellipse", "line", "softy", "bubbles", "grid-worm", "walker", - "banner", "wafer", "dark-window", "wipe", "camera", + "banner", "wafer", "dark-window", "build", "aura", "triangle", "breathe", + "vignette", "caterpillar", "frame", "rainbow", "wipe", "camera", + "contrast", "flip", "invert", "light-bump", "mirror", "quicksand", + "recurse", "saturate", "scroll", "sharpen", "spin", "turn", "zoom", ]); // The recovered Construct picker contains Box twice. The other first-pass @@ -41,10 +44,32 @@ export const NOPAINT_PROPOSAL_CATALOG = Object.freeze([ Object.freeze({ name: "bubbles", label: "Bubbles", family: "authored", weight: 1 }), Object.freeze({ name: "grid-worm", label: "Grid Worm", family: "authored", weight: 1 }), Object.freeze({ name: "dark-window", label: "Dark Window", family: "authored", weight: 1 }), + Object.freeze({ name: "build", label: "Build", family: "authored", weight: 1 }), + Object.freeze({ name: "aura", label: "Aura", family: "authored", weight: 1 }), + Object.freeze({ name: "triangle", label: "Triangle", family: "brush", weight: 1 }), + Object.freeze({ name: "ellipse", label: "Ellipse", family: "brush", weight: 1 }), + Object.freeze({ name: "breathe", label: "Breathe", family: "authored", weight: 1 }), + Object.freeze({ name: "vignette", label: "Vignette", family: "authored", weight: 1 }), + Object.freeze({ name: "caterpillar", label: "Caterpillar", family: "authored", weight: 1 }), + Object.freeze({ name: "frame", label: "Frame", family: "authored", weight: 1 }), + Object.freeze({ name: "rainbow", label: "Rainbow", family: "authored", weight: 1 }), Object.freeze({ name: "walker", label: "Walker", family: "authored", weight: 0.2 }), Object.freeze({ name: "banner", label: "Banner", family: "authored", weight: 1 }), Object.freeze({ name: "wafer", label: "Wafer", family: "authored", weight: 1 }), Object.freeze({ name: "wipe", label: "Wash", family: "transform", weight: 1 }), + Object.freeze({ name: "contrast", label: "Contrast", family: "transform", weight: 1 }), + Object.freeze({ name: "flip", label: "Flip", family: "transform", weight: 1 }), + Object.freeze({ name: "invert", label: "Invert", family: "transform", weight: 1 }), + Object.freeze({ name: "light-bump", label: "Light Bump", family: "transform", weight: 1 }), + Object.freeze({ name: "mirror", label: "Mirror", family: "transform", weight: 1 }), + Object.freeze({ name: "quicksand", label: "Quicksand", family: "transform", weight: 1 }), + Object.freeze({ name: "recurse", label: "Recurse", family: "transform", weight: 1 }), + Object.freeze({ name: "saturate", label: "Saturate", family: "transform", weight: 1 }), + Object.freeze({ name: "scroll", label: "Scroll", family: "transform", weight: 1 }), + Object.freeze({ name: "sharpen", label: "Sharpen", family: "transform", weight: 1 }), + Object.freeze({ name: "spin", label: "Spin", family: "transform", weight: 1 }), + Object.freeze({ name: "turn", label: "Turn", family: "transform", weight: 1 }), + Object.freeze({ name: "zoom", label: "Zoom", family: "transform", weight: 1 }), // Camera is implemented but deliberately absent from random proposals. // Device access belongs to an explicit camera-tool action, never page load. ]); diff --git a/system/tests/nopaint-construct-catalog.test.mjs b/system/tests/nopaint-construct-catalog.test.mjs new file mode 100644 index 0000000000..ba6df1bb02 --- /dev/null +++ b/system/tests/nopaint-construct-catalog.test.mjs @@ -0,0 +1,31 @@ +import test from "node:test"; +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", "caterpillar", "ellipse", "frame", "rainbow", "softy", "triangle", "vignette", "wafer", "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", () => { + assert.deepEqual(nonConflictingConstructProposals.map(({ slug }) => slug).sort(), expected); + for (const contract of nonConflictingConstructProposals) { + const make = () => contract.generate({ random: seededRandom(`construct:${contract.slug}`), width: 640, height: 480, base }); + const first = make(); + assert.deepEqual(first, make(), `${contract.slug} score is deterministic`); + assert.equal(first.brush.slug, contract.slug); + assert.ok(Object.isFrozen(first)); + assert.ok(Object.isFrozen(first.brush.parameters)); + } +}); + +test("every recovered contract renders through the bounded AC ink surface", () => { + const calls = []; + const method = (name) => (...args) => calls.push([name, ...args]); + const ink = () => ({ box: method("box"), line: method("line"), oval: method("oval"), poly: method("poly") }); + for (const contract of nonConflictingConstructProposals) { + calls.length = 0; + const score = contract.generate({ random: seededRandom(contract.slug), width: 640, height: 480, base }); + contract.render({ ink }, score, 60); + assert.ok(calls.length > 0, `${contract.slug} produces drawing calls`); + } +}); diff --git a/system/tests/nopaint-construct-transforms.test.mjs b/system/tests/nopaint-construct-transforms.test.mjs new file mode 100644 index 0000000000..3c671ea9a4 --- /dev/null +++ b/system/tests/nopaint-construct-transforms.test.mjs @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { seededRandom } from "../public/aesthetic.computer/lib/nopaint-proposals.mjs"; +import { recoveredConstructTransforms, flipTransform, invertTransform, scrollTransform } from "../public/aesthetic.computer/lib/nopaint-construct-transforms.mjs"; + +const base = Object.freeze({ kind: "base" }); +const generate = (transform, seed = "transform") => transform.generate({ random: seededRandom(seed), width: 4, height: 2, base }); +const pixels = new Uint8ClampedArray([ + 1,2,3,255, 10,20,30,255, 40,50,60,255, 70,80,90,255, + 4,5,6,255, 11,21,31,255, 41,51,61,255, 71,81,91,255, +]); + +test("all recovered transforms generate deterministic, explicit contracts", () => { + assert.equal(recoveredConstructTransforms.length, 13); + assert.equal(new Set(recoveredConstructTransforms.map(({ slug }) => slug)).size, 13); + for (const transform of recoveredConstructTransforms) { + assert.deepEqual(generate(transform), generate(transform)); + assert.equal(typeof transform.applyPixels, "function"); + assert.match(transform.source.file, /data\.json$/); + } +}); + +test("Invert preserves alpha and inverts RGB", () => { + const output = invertTransform.applyPixels(pixels, 4, 2, { inverted: true }); + assert.deepEqual([...output.slice(0, 4)], [254, 253, 252, 255]); +}); + +test("Flip performs exact horizontal and vertical replacements", () => { + const horizontal = flipTransform.applyPixels(pixels, 4, 2, { vertically: false }); + assert.deepEqual([...horizontal.slice(0, 4)], [70, 80, 90, 255]); + const vertical = flipTransform.applyPixels(pixels, 4, 2, { vertically: true }); + assert.deepEqual([...vertical.slice(0, 4)], [4, 5, 6, 255]); +}); + +test("Scroll wraps without dropping pixels", () => { + const output = scrollTransform.applyPixels(pixels, 4, 2, { horizontal: true, offset: 1 }); + assert.deepEqual([...output.slice(4, 8)], [1, 2, 3, 255]); + assert.equal(output.length, pixels.length); +}); diff --git a/system/tests/nopaint-proposals.test.mjs b/system/tests/nopaint-proposals.test.mjs index 4bfe707444..948486f464 100644 --- a/system/tests/nopaint-proposals.test.mjs +++ b/system/tests/nopaint-proposals.test.mjs @@ -27,7 +27,7 @@ test("the first native catalog retains the recovered duplicate Box weight", () = const rect = NOPAINT_PROPOSAL_CATALOG.find(({ name }) => name === "rect"); assert.equal(rect.weight, 2); assert.equal(NOPAINT_VERSION, "3.0"); - assert.equal(NOPAINT_PROPOSAL_CATALOG.length, 11); + assert.equal(NOPAINT_PROPOSAL_CATALOG.length, 33); assert.equal(NOPAINT_PROPOSAL_CATALOG.some(({ name }) => name === "camera"), false); assert.equal(NOPAINT_PROPOSAL_CATALOG.find(({ name }) => name === "softy").weight, 1.5); assert.equal(NOPAINT_PROPOSAL_CATALOG.find(({ name }) => name === "walker").weight, 0.2); diff --git a/tests/browser/nopaint-journey.test.mjs b/tests/browser/nopaint-journey.test.mjs index fb683bf3f2..b9c6070eb7 100644 --- a/tests/browser/nopaint-journey.test.mjs +++ b/tests/browser/nopaint-journey.test.mjs @@ -37,7 +37,7 @@ try { try { await ac.page.waitForFunction( () => window.__acNoPaintTest?.()?.audio?.events?.some( - ({ name, path }) => name === "brush:banner" && path === "legacy", + ({ name, path }) => name.startsWith("brush:") && path === "legacy", ), { timeout: 15000 }, ); @@ -62,12 +62,12 @@ try { expect(state?.version === "3.0", `version is 3.0 (got ${state?.version})`); expect(state?.state === "proposing", `state is proposing (got ${state?.state})`); expect(state?.proposalNumber === 1, "first proposal is numbered 1"); - expect(state?.operation === "banner", `seed begins with Banner, not Camera (got ${state?.operation})`); + expect(state?.operation !== "camera", `seed never begins with Camera (got ${state?.operation})`); expect(state?.ready === true, "proposal buffer reports ready"); expect(state?.cursor?.ready === true, "the original Construct cursor sheet is loaded"); expect( - state?.audio?.events?.some(({ name, path }) => name === "brush:banner" && path === "legacy"), - "the proposal starts its recovered Banner theme", + state?.audio?.events?.some(({ name, path }) => name === `brush:${state?.operation}` && path === "legacy"), + "the proposal starts its recovered operation cue", ); expect( [state?.controls?.no, state?.controls?.paint]