Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: rewrite line brush — thick lines, per-gesture alpha, scroll sizing, color cycling

- Scanline capsule fill for thick lines in graph.mjs (O(height) lineh calls, no Set/plot overhead)
- Line brush: freehand default, scroll wheel adjusts thickness with beep + URL/HUD update
- Middle-click cycles through palette colors (red→orange→yellow→green→cyan→blue→purple→white→gray→black)
- [ / ] keys adjust opacity per-gesture (draw opaque, scale alpha at bake time)
- Brush preview circle on hover shows size + color at low alpha
- Per-stroke undo: addUndoPainting after every bake in nopaint act handler
- Fix shift-pan sticking: guard panning start against key repeat
- HUD label: colon params colored (gray colons, yellow values), no leading space
- HUD scrub: share text/color → cyan, edit block columns fly in from right
- VSCode extension title updates on net.rewrite via url:updated postMessage
- nopaint.cancelStroke() API to prevent painting from non-drawing mouse buttons
- Local dev upload fallback: presigned-url returns /local-upload/ when S3 creds missing
- local-upload.mjs Netlify Function + netlify.toml redirect for dev uploads
- bios: new URL() with location.origin base for relative presigned URLs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+518 -184
+3
.gitignore
··· 112 112 113 113 # Dev logs from remote debugging 114 114 dev-logs.txt 115 + 116 + # Local dev uploads (fallback when S3 credentials are missing) 117 + local-uploads/ 115 118 .#* 116 119 117 120 # NPM
+17 -1
lith/server.mjs
··· 47 47 } 48 48 49 49 import express from "express"; 50 - import { readdirSync, readFileSync, existsSync } from "fs"; 50 + import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "fs"; 51 51 import { join, dirname } from "path"; 52 52 import { fileURLToPath } from "url"; 53 53 import { createServer as createHttpsServer } from "https"; ··· 896 896 app.all("/handles", directFn("handles")); 897 897 app.all("/redirect-proxy", directFn("redirect-proxy")); 898 898 app.all("/redirect-proxy-sotce", directFn("redirect-proxy")); 899 + // Local dev upload fallback (used when S3 credentials are missing). 900 + app.all("/local-upload/:filename", (req, res) => { 901 + if (req.method === "OPTIONS") return res.sendStatus(204); 902 + const body = req.rawBody || req.body; 903 + if (!body || body.length === 0) { 904 + console.error("❌ Local upload: empty body for", req.params.filename); 905 + return res.status(400).send("Empty body"); 906 + } 907 + const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "local-uploads"); 908 + mkdirSync(dir, { recursive: true }); 909 + const filepath = join(dir, req.params.filename); 910 + writeFileSync(filepath, body); 911 + console.log("📁 Local upload saved:", filepath, `(${body.length} bytes)`); 912 + res.status(200).send("OK"); 913 + }); 914 + app.use("/local-uploads", express.static(join(dirname(fileURLToPath(import.meta.url)), "..", "local-uploads"))); 899 915 app.all("/presigned-upload-url/*rest", directFn("presigned-url")); 900 916 app.all("/presigned-download-url/*rest", directFn("presigned-url")); 901 917 app.all("/docs", directFn("docs"));
+10 -5
system/netlify.toml
··· 923 923 to = "https://aesthetic.computer/:splat" 924 924 status = 301 925 925 force = true 926 - [[redirects]] 927 - from = "https://wand.ac" 928 - to = "https://aesthetic.computer/wand" 929 - status = 301 930 - force = true 926 + [[redirects]] 927 + from = "https://wand.ac" 928 + to = "https://aesthetic.computer/wand" 929 + status = 301 930 + force = true 931 931 [[redirects]] 932 932 from = "https://edit.ac" 933 933 to = "https://vscode.dev/github/digitpain/aesthetic.computer-code/blob/main/blank.mjs" ··· 2028 2028 from = "/redirect-proxy" 2029 2029 to = "/.netlify/functions/redirect-proxy" 2030 2030 status = 200 2031 + [[redirects]] 2032 + from = "/local-upload/*" 2033 + to = "/.netlify/functions/local-upload" 2034 + status = 200 2035 + force = true 2031 2036 [[redirects]] 2032 2037 from = "/presigned-upload-url/*" 2033 2038 to = "/.netlify/functions/presigned-url"
+35
system/netlify/functions/local-upload.mjs
··· 1 + // Local dev upload fallback — saves files to disk when S3 credentials are missing. 2 + import { mkdirSync, writeFileSync } from "fs"; 3 + import { join, dirname } from "path"; 4 + import { fileURLToPath } from "url"; 5 + 6 + const __dirname = dirname(fileURLToPath(import.meta.url)); 7 + 8 + export async function handler(event) { 9 + // Extract filename from path: /local-upload/CODE.ext 10 + const filename = event.path.split("/").pop(); 11 + if (!filename) { 12 + return { statusCode: 400, body: "Missing filename" }; 13 + } 14 + 15 + const dir = join(__dirname, "..", "..", "..", "local-uploads"); 16 + mkdirSync(dir, { recursive: true }); 17 + const filepath = join(dir, filename); 18 + 19 + // event.body is base64-encoded for binary content in Netlify Functions 20 + const body = event.isBase64Encoded 21 + ? Buffer.from(event.body, "base64") 22 + : Buffer.from(event.body || ""); 23 + 24 + if (body.length === 0) { 25 + return { statusCode: 400, body: "Empty body" }; 26 + } 27 + 28 + writeFileSync(filepath, body); 29 + console.log(`📁 Local upload saved: ${filepath} (${body.length} bytes)`); 30 + 31 + return { 32 + statusCode: 200, 33 + body: "OK", 34 + }; 35 + }
+22 -11
system/netlify/functions/presigned-url.js
··· 45 45 46 46 // Validate credentials are available 47 47 if (!accessKeyId || !secretAccessKey) { 48 - console.error("❌ S3 Credentials missing:", { 49 - hasArtKey: !!process.env.ART_KEY, 50 - hasDoSpacesKey: !!process.env.DO_SPACES_KEY, 51 - hasArtSecret: !!process.env.ART_SECRET, 52 - hasDoSpacesSecret: !!process.env.DO_SPACES_SECRET, 53 - endpoint, 54 - }); 55 - throw new Error(`Missing S3 credentials: ART_KEY=${!!process.env.ART_KEY}, DO_SPACES_KEY=${!!process.env.DO_SPACES_KEY}, ART_SECRET=${!!process.env.ART_SECRET}, DO_SPACES_SECRET=${!!process.env.DO_SPACES_SECRET}`); 48 + // In local dev mode, skip S3 client init — handled by local fallback. 49 + return null; 56 50 } 57 51 58 52 console.log("🔑 Using credentials:", { ··· 99 93 artSecretLength: process.env.ART_SECRET?.length, 100 94 }); 101 95 102 - // Initialize S3 clients (lazy load to allow env vars to be set) 103 - const { s3Guest, s3Wand, s3User } = getS3Clients(); 104 - 105 96 // Only allow GET requests. 106 97 if (event.httpMethod !== "GET") 107 98 return respond(405, { error: "Wrong request type!" }); 99 + 100 + // Initialize S3 clients (lazy load to allow env vars to be set) 101 + const s3Clients = getS3Clients(); 102 + 103 + // Local dev fallback when S3 credentials are missing. 104 + if (!s3Clients) { 105 + const { customAlphabet } = await import("nanoid"); 106 + const nanoid = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 8); 107 + const ext = event.path.slice(1).split("/")[1]; 108 + const code = nanoid(); 109 + const slug = code + "." + ext; 110 + console.log("📁 Local dev upload fallback:", slug); 111 + return respond(200, { 112 + uploadURL: `/local-upload/${slug}`, 113 + slug, 114 + code, 115 + }); 116 + } 117 + 118 + const { s3Guest, s3Wand, s3User } = s3Clients; 108 119 109 120 if (event.path === "/presigned-download-url") { 110 121 const queryParams = new URLSearchParams(event.queryStringParameters);
+6 -2
system/public/aesthetic.computer/bios.mjs
··· 11909 11909 performHistoryRewrite(newPath, historical); 11910 11910 } 11911 11911 11912 + // Notify the VSCode extension (or any parent frame) of the slug change. 11913 + const slug = newPath.startsWith("/") ? newPath.slice(1) : newPath; 11914 + window.parent?.postMessage({ type: "url:updated", slug }, "*"); 11915 + 11912 11916 return; 11913 11917 } 11914 11918 ··· 18303 18307 18304 18308 // Probably the download code... maybe something else if a custom 18305 18309 // name is used. 18306 - const url = new URL(presignedUrl); 18310 + const url = new URL(presignedUrl, location.origin); 18307 18311 const filename = url.pathname.split("/").pop(); 18308 - const slug = filename.substring(0, filename.lastIndexOf(".")); 18312 + const slug = resData.slug ? resData.slug.replace(/\.[^.]+$/, "") : filename.substring(0, filename.lastIndexOf(".")); 18309 18313 const path = url.pathname.slice(1); // Remove prepending "/"; 18310 18314 18311 18315 // Log clean URL without query params
+207 -112
system/public/aesthetic.computer/disks/line.mjs
··· 1 - // Line, 25.09.30 2 - // Simple line brush 1 + // Line, 25.09.30 (Rewritten 25.04.07) 2 + // Freehand line brush with per-gesture alpha. 3 + // 4 + // Usage: 5 + // line purple → freehand 1px purple lines 6 + // line:3 purple → freehand 3px purple lines 7 + // line:3 blue 128 → freehand 3px blue at 50% alpha 3 8 4 - export const system = "nopaint"; 9 + import { nopaint_generateColoredLabel } from "../systems/nopaint.mjs"; 5 10 6 - function getLineEndpoints(mark) { 7 - if (!mark) return null; 11 + let colorParams, opaqueParams, strokeAlpha, thickness, randomColor, wasPainting; 12 + let savedParams, savedHud, savedApi; // Saved for dynamic HUD/URL updates. 13 + let colorIndex = -1; // -1 = user-specified or random, 0+ = palette index. 14 + const points = []; 15 + const segmentColors = []; 16 + let strokeToBake = null; 8 17 9 - if ( 10 - typeof mark.x === "number" && 11 - typeof mark.y === "number" && 12 - typeof mark.w === "number" && 13 - typeof mark.h === "number" 14 - ) { 15 - return { 16 - x1: mark.x, 17 - y1: mark.y, 18 - x2: mark.x + mark.w, 19 - y2: mark.y + mark.h 20 - }; 18 + const palette = [ 19 + { name: "red", rgb: [255, 0, 0] }, 20 + { name: "orange", rgb: [255, 165, 0] }, 21 + { name: "yellow", rgb: [255, 255, 0] }, 22 + { name: "green", rgb: [0, 128, 0] }, 23 + { name: "cyan", rgb: [0, 255, 255] }, 24 + { name: "blue", rgb: [0, 0, 255] }, 25 + { name: "purple", rgb: [128, 0, 128] }, 26 + { name: "white", rgb: [255, 255, 255] }, 27 + { name: "gray", rgb: [128, 128, 128] }, 28 + { name: "black", rgb: [0, 0, 0] }, 29 + ]; 30 + 31 + const system = "nopaint"; 32 + 33 + function boot({ params, num, colon, hud, ...api }) { 34 + colorParams = num.parseColor(params); 35 + randomColor = colorParams.length === 0; 36 + savedParams = params; 37 + savedHud = hud; 38 + savedApi = api; 39 + 40 + // Extract alpha and create an opaque version for buffer rendering. 41 + if (!randomColor && (colorParams.length === 2 || colorParams.length === 4)) { 42 + strokeAlpha = colorParams[colorParams.length - 1] / 255; 43 + opaqueParams = colorParams.slice(0, -1); 44 + if (opaqueParams.length === 1) opaqueParams.push(255); 45 + else opaqueParams.push(255); 46 + } else { 47 + strokeAlpha = 1; 48 + opaqueParams = colorParams; 21 49 } 22 50 23 - if (Array.isArray(mark) && mark.length >= 2) { 24 - const first = mark[0]; 25 - const last = mark[mark.length - 1]; 26 - const toPoint = (point) => 27 - Array.isArray(point) 28 - ? { x: point[0], y: point[1] } 29 - : { x: point?.x, y: point?.y }; 30 - const start = toPoint(first); 31 - const end = toPoint(last); 32 - if (Number.isFinite(start.x) && Number.isFinite(start.y) && Number.isFinite(end.x) && Number.isFinite(end.y)) { 33 - return { x1: start.x, y1: start.y, x2: end.x, y2: end.y }; 51 + thickness = Math.max(1, Math.min(parseInt(colon[0]) || 1, 50)); 52 + wasPainting = false; 53 + points.length = 0; 54 + segmentColors.length = 0; 55 + strokeToBake = null; 56 + 57 + updateLabel(); 58 + } 59 + 60 + function act({ event: e, net, needsPaint, sound, system: { nopaint } }) { 61 + if (e.is("scroll")) { 62 + const delta = e.y > 0 ? -1 : e.y < 0 ? 1 : 0; 63 + if (delta === 0) return; 64 + const prev = thickness; 65 + thickness = Math.max(1, Math.min(thickness + delta, 50)); 66 + if (thickness !== prev) { 67 + updateLabel(); 68 + rewriteURL(net); 69 + sound.synth({ 70 + tone: 200 + thickness * 40, 71 + beats: 0.04, 72 + attack: 0.01, 73 + decay: 0.1, 74 + volume: 0.1, 75 + }); 76 + needsPaint(); 34 77 } 35 78 } 36 79 37 - if (mark?.points && Array.isArray(mark.points) && mark.points.length >= 2) { 38 - const points = mark.points; 39 - return getLineEndpoints(points); 80 + // Middle-click (button 1) cycles through palette colors. 81 + if (e.is("touch") && e.button === 1) { 82 + nopaint.cancelStroke?.(); // Prevent painting from middle-click. 83 + colorIndex = (colorIndex + 1) % palette.length; 84 + applyPaletteColor(); 85 + rewriteURL(net); 86 + sound.synth({ 87 + tone: 300 + colorIndex * 80, 88 + beats: 0.05, 89 + attack: 0.01, 90 + decay: 0.15, 91 + volume: 0.12, 92 + }); 93 + needsPaint(); 40 94 } 41 95 42 - return null; 96 + // [ and ] keys adjust opacity. 97 + if (e.is("keyboard:down:[") || e.is("keyboard:down:]")) { 98 + const step = 0.1; 99 + if (e.is("keyboard:down:]")) strokeAlpha = Math.min(1, strokeAlpha + step); 100 + if (e.is("keyboard:down:[")) strokeAlpha = Math.max(step, strokeAlpha - step); 101 + strokeAlpha = Math.round(strokeAlpha * 10) / 10; 102 + opaqueParams = randomColor ? [] : colorParams.slice(0, 3); 103 + if (!randomColor && opaqueParams.length > 0) opaqueParams.push(255); 104 + if (!randomColor) { 105 + const colorName = savedParams[0] || ""; 106 + const alphaVal = Math.round(strokeAlpha * 255); 107 + savedParams = alphaVal < 255 ? [colorName, String(alphaVal)] : [colorName]; 108 + } 109 + updateLabel(); 110 + rewriteURL(net); 111 + sound.synth({ 112 + tone: 400 + strokeAlpha * 600, 113 + beats: 0.04, 114 + attack: 0.01, 115 + decay: 0.1, 116 + volume: 0.1, 117 + }); 118 + needsPaint(); 119 + } 43 120 } 44 121 45 - function formatAngle(angleDegrees) { 46 - if (!Number.isFinite(angleDegrees)) return null; 47 - const normalized = ((angleDegrees % 360) + 360) % 360; 48 - const fixed = normalized.toFixed(2); 49 - return fixed.replace(/\.00$/, ""); 122 + function applyPaletteColor() { 123 + const entry = palette[colorIndex]; 124 + colorParams = entry.rgb.slice(); 125 + opaqueParams = entry.rgb.slice(); 126 + savedParams = [entry.name]; 127 + randomColor = false; 128 + strokeAlpha = 1; 129 + updateLabel(); 50 130 } 51 131 52 - function ensureFadeDirectionString(fadeString, angleDegrees) { 53 - if (typeof fadeString !== "string" || !fadeString.startsWith("fade:")) { 54 - return fadeString; 55 - } 132 + function rewriteURL(net) { 133 + const colonStr = thickness > 1 ? `:${thickness}` : ""; 134 + const paramStr = savedParams.length > 0 ? " " + savedParams.join(" ") : ""; 135 + net.rewrite(`/line${colonStr}${paramStr}`); 136 + } 56 137 57 - const parts = fadeString.split(":"); 58 - if (parts.length < 2) return fadeString; 138 + function updateLabel() { 139 + const modifiers = thickness > 1 ? `:${thickness}` : ""; 140 + nopaint_generateColoredLabel("line", colorParams, savedParams, modifiers, { hud: savedHud, ...savedApi }); 141 + } 59 142 60 - let hasDirty = false; // Changed: check for dirty instead of neat (neat is now default) 61 - let type = null; 62 - let directionExists = false; 143 + function paint({ ink, page, paste, pen, screen, num, system: { nopaint } }) { 144 + const isPainting = nopaint.is("painting"); 145 + if (isPainting && !wasPainting) { 146 + points.length = 0; 147 + segmentColors.length = 0; 148 + } 149 + wasPainting = isPainting; 63 150 64 - for (let i = 1; i < parts.length; i += 1) { 65 - const segment = parts[i]; 66 - if (segment === "dirty") { // Changed: check for dirty 67 - hasDirty = true; 68 - continue; 69 - } 70 - if (segment === "neat") { // Still allow neat keyword (it's just explicit default now) 71 - continue; 72 - } 73 - if (!type) { 74 - type = segment; 75 - continue; 151 + if (isPainting) { 152 + const bx = nopaint.brush.x, by = nopaint.brush.y; 153 + const last = points[points.length - 1]; 154 + if (!last || last.x !== bx || last.y !== by) { 155 + points.push({ x: bx, y: by }); 156 + if (randomColor && points.length > 1) { 157 + segmentColors.push(num.randIntArr(255, 3)); 158 + } 76 159 } 77 - directionExists = true; 78 - break; 79 160 } 80 161 81 - if (!type || directionExists) { 82 - return fadeString; 83 - } 162 + if (points.length > 0) { 163 + const stroke = isPainting 164 + ? [...points, { x: nopaint.brush.x, y: nopaint.brush.y }] 165 + : points; 84 166 85 - const angle = formatAngle(angleDegrees); 86 - if (angle === null) return fadeString; 167 + page(nopaint.buffer).wipe(255, 0); 87 168 88 - return hasDirty ? `fade:dirty:${type}:${angle}` : `fade:${type}:${angle}`; // Changed: add dirty prefix if needed 89 - } 169 + if (randomColor) { 170 + if (thickness === 1) { 171 + for (let i = 0; i < stroke.length - 1; i++) { 172 + ink(segmentColors[i] || num.randIntArr(255, 3)) 173 + .line(stroke[i].x, stroke[i].y, stroke[i + 1].x, stroke[i + 1].y); 174 + } 175 + } else { 176 + const smoothed = stroke.length > 2 ? smoothStroke(stroke) : stroke; 177 + for (let i = 0; i < smoothed.length - 1; i++) { 178 + ink(segmentColors[i] || num.randIntArr(255, 3)) 179 + .line(smoothed[i].x, smoothed[i].y, smoothed[i + 1].x, smoothed[i + 1].y, thickness); 180 + } 181 + } 182 + } else { 183 + // Draw fully opaque — alpha is applied per-gesture below. 184 + if (thickness === 1) { 185 + ink(opaqueParams).pppline(stroke); 186 + } else { 187 + const smoothed = stroke.length > 2 ? smoothStroke(stroke) : stroke; 188 + for (let i = 0; i < smoothed.length - 1; i++) { 189 + const a = smoothed[i], b = smoothed[i + 1]; 190 + ink(opaqueParams).line(a.x, a.y, b.x, b.y, thickness); 191 + } 192 + } 193 + } 90 194 91 - function alignFadeColorToLine(color, endpoints) { 92 - if (!color || !endpoints) return color; 195 + page(screen); 93 196 94 - const { x1, y1, x2, y2 } = endpoints; 95 - const angle = Math.atan2(y2 - y1, x2 - x1) * (180 / Math.PI); 96 - 97 - if (typeof color === "string") { 98 - return ensureFadeDirectionString(color, angle); 99 - } 100 - 101 - if (Array.isArray(color)) { 102 - if (color.length > 0 && typeof color[0] === "string") { 103 - const updated = ensureFadeDirectionString(color[0], angle); 104 - if (updated !== color[0]) { 105 - return [updated, ...color.slice(1)]; 197 + strokeToBake = () => { 198 + // Scale buffer alpha once at bake time for per-gesture transparency. 199 + if (strokeAlpha < 1) { 200 + const px = nopaint.buffer.pixels; 201 + for (let i = 3; i < px.length; i += 4) { 202 + if (px[i] > 0) px[i] = (px[i] * strokeAlpha + 0.5) | 0; 203 + } 106 204 } 107 - } 108 - return color; 205 + paste(nopaint.buffer); 206 + page(nopaint.buffer).wipe(255, 0); 207 + points.length = 0; 208 + segmentColors.length = 0; 209 + strokeToBake = null; 210 + }; 109 211 } 110 212 111 - if (typeof color === "object" && color.type === "fade") { 112 - const updated = ensureFadeDirectionString(color.fadeString, angle); 113 - if (updated !== color.fadeString) { 114 - return { ...color, fadeString: updated }; 213 + // Brush preview circle on hover (when not painting and thickness > 2). 214 + if (!isPainting && thickness > 2 && pen) { 215 + const r = Math.floor((thickness - 1) / 2); 216 + const a = Math.round(strokeAlpha * 100); 217 + if (randomColor) { 218 + ink(255, 255, 255, a).circle(pen.x, pen.y, r, true); 219 + } else { 220 + ink(...opaqueParams.slice(0, 3), a).circle(pen.x, pen.y, r, true); 115 221 } 222 + return true; 116 223 } 117 - 118 - return color; 119 224 } 120 225 121 - function overlay({ ink, color, mark }) { 122 - const endpoints = getLineEndpoints(mark); 123 - if (!endpoints) return; 124 - 125 - const adjustedColor = alignFadeColorToLine(color, endpoints); 126 - 127 - try { 128 - ink(adjustedColor).line(endpoints.x1, endpoints.y1, endpoints.x2, endpoints.y2); 129 - } catch (error) { 130 - console.error("📏 LINE overlay error:", error, { color: adjustedColor, mark }); 131 - } 226 + function bake() { 227 + strokeToBake?.(); 132 228 } 133 229 134 - function lift({ ink, color, mark }) { 135 - const endpoints = getLineEndpoints(mark); 136 - if (!endpoints) return false; 230 + export { boot, act, paint, bake, system }; 137 231 138 - const adjustedColor = alignFadeColorToLine(color, endpoints); 232 + // ── Helpers ── 139 233 140 - try { 141 - ink(adjustedColor).line(endpoints.x1, endpoints.y1, endpoints.x2, endpoints.y2); 142 - return true; 143 - } catch (error) { 144 - console.error("📏 LINE lift error:", error, { color: adjustedColor, mark }); 145 - return false; 234 + function smoothStroke(raw) { 235 + if (raw.length < 3) return raw; 236 + const out = [raw[0]]; 237 + for (let i = 1; i < raw.length - 1; i++) { 238 + const p = raw[i - 1], c = raw[i], n = raw[i + 1]; 239 + const s = 0.25; 240 + out.push({ x: c.x * (1 - s) + (p.x + n.x) * s / 2, y: c.y * (1 - s) + (p.y + n.y) * s / 2 }); 146 241 } 242 + out.push(raw[raw.length - 1]); 243 + return out; 147 244 } 148 - 149 - export { overlay, lift };
+11 -1
system/public/aesthetic.computer/lib/color-highlighting.mjs
··· 614 614 let label = `\\${brushColorCode}\\${brushName}`; 615 615 616 616 if (modifiers) { 617 - label += ` \\gray\\${modifiers}`; 617 + // Color colons as gray and values as yellow, no leading space. 618 + const parts = modifiers.split(":"); 619 + let coloredMods = ""; 620 + for (const part of parts) { 621 + if (part === "") { 622 + coloredMods += "\\gray\\:"; 623 + } else { 624 + coloredMods += `\\yellow\\${part}`; 625 + } 626 + } 627 + label += coloredMods; 618 628 } 619 629 620 630 if (colorSection) {
+39 -29
system/public/aesthetic.computer/lib/disk.mjs
··· 53 53 const { pow, abs, round, sin, random, min, max, floor, cos } = Math; 54 54 const { keys } = Object; 55 55 56 - import { nopaint_boot, nopaint_act, nopaint_is, nopaint_renderPerfHUD, nopaint_triggerBakeFlash } from "../systems/nopaint.mjs"; 56 + import { nopaint_boot, nopaint_act, nopaint_is, nopaint_cancelStroke, nopaint_renderPerfHUD, nopaint_triggerBakeFlash } from "../systems/nopaint.mjs"; 57 57 import { getPreserveFadeAlpha, setPreserveFadeAlpha } from "./fade-state.mjs"; 58 58 import * as prompt from "../systems/prompt-system.mjs"; 59 59 import * as world from "../systems/world.mjs"; ··· 3536 3536 // console.log("🖌️🟠 Recorded a step:", record.label); 3537 3537 }, 3538 3538 is: nopaint_is, 3539 + cancelStroke: nopaint_cancelStroke, 3539 3540 undo: { paintings: undoPaintings }, 3540 3541 needsBake: false, 3541 3542 needsPresent: false, ··· 9272 9273 9273 9274 $.page($.system.painting); 9274 9275 bake($); 9275 - 9276 + 9276 9277 // 🧹 CLEAR BUFFER: Prevent flickering by clearing buffer BEFORE presentation 9277 9278 $.page($.system.nopaint.buffer).wipe(255, 255, 255, 0); 9278 - 9279 + 9279 9280 $.page($.screen); 9280 9281 np.present($); 9281 9282 np.needsBake = false; 9283 + 9284 + // 📸 Snapshot for undo after every stroke bake. 9285 + addUndoPainting($.system.painting, "stroke"); 9282 9286 9283 9287 // 🚀 Broadcast bake completion 9284 9288 $commonApi.broadcastPaintingUpdateImmediate("baked", { ··· 10096 10100 persistentDawState.snMin = content.min; 10097 10101 return; 10098 10102 } 10099 - if (type === "spreadnob:max") { 10100 - persistentDawState.snMax = content.max; 10101 - return; 10102 - } 10103 - if (type === "spreadnob:state") { 10104 - persistentDawState.snRawNote = content.raw; 10105 - persistentDawState.snNormalizedNote = content.note; 10106 - persistentDawState.snShift = content.shift; 10107 - persistentDawState.snLocked = content.locked; 10108 - persistentDawState.snAmbiguous = content.ambiguous; 10109 - return; 10110 - } 10111 - if (type === "spreadnob:qwerty-range") { 10112 - persistentDawState.snQwertyLow = content.low; 10113 - persistentDawState.snQwertyHigh = content.high; 10114 - return; 10115 - } 10116 - 10117 - // 🎸 Pedal messages (for audio effect visualization) 10118 - if (type === "pedal:peak") { 10103 + if (type === "spreadnob:max") { 10104 + persistentDawState.snMax = content.max; 10105 + return; 10106 + } 10107 + if (type === "spreadnob:state") { 10108 + persistentDawState.snRawNote = content.raw; 10109 + persistentDawState.snNormalizedNote = content.note; 10110 + persistentDawState.snShift = content.shift; 10111 + persistentDawState.snLocked = content.locked; 10112 + persistentDawState.snAmbiguous = content.ambiguous; 10113 + return; 10114 + } 10115 + if (type === "spreadnob:qwerty-range") { 10116 + persistentDawState.snQwertyLow = content.low; 10117 + persistentDawState.snQwertyHigh = content.high; 10118 + return; 10119 + } 10120 + 10121 + // 🎸 Pedal messages (for audio effect visualization) 10122 + if (type === "pedal:peak") { 10119 10123 // Forward to the piece's receive function if it exists 10120 10124 if (typeof receive === "function") { 10121 10125 receive({ type: "pedal:peak", peak: content.peak }); ··· 12826 12830 12827 12831 if (currentHUDScrub >= shareWidth) { 12828 12832 currentHUDScrub = shareWidth; 12829 - currentHUDTextColor = [255, 255, 0]; 12833 + currentHUDTextColor = [0, 255, 255]; 12830 12834 } else if (currentHUDScrub <= -editWidth) { 12831 12835 currentHUDScrub = -editWidth; 12832 12836 currentHUDTextColor = [255, 255, 0]; 12833 12837 } else if (currentHUDScrub > 0) { 12834 - currentHUDTextColor = [255, 0, 0]; 12838 + currentHUDTextColor = [0, 200, 200]; 12835 12839 } else if (currentHUDScrub < 0) { 12836 12840 currentHUDTextColor = [255, 120, 180]; 12837 12841 } else if (currentHUDScrub === 0) { ··· 14267 14271 x: shareTextX, 14268 14272 y: shareTextY, 14269 14273 typefaceName: undefined, // Use default font, not MatrixChunky8 14270 - textColor: [255, 255, 255], 14274 + textColor: [0, 255, 255], 14271 14275 shadowColor: "black", 14272 14276 preserveColors: false, 14273 14277 }); ··· 14292 14296 ); 14293 14297 const unclampedCaretY = Math.max(0, hudTextY + wrappedLastLineY); 14294 14298 const caretY = Math.min(unclampedCaretY, Math.max(bufferH - caretHeight - 1, 0)); 14295 - const fillWidth = Math.max(1, Math.round(caretWidth * editProgress)); 14299 + const filledCols = Math.max(1, Math.round(caretWidth * editProgress)); 14296 14300 const editColor = editProgress >= 1 ? [255, 170, 210] : [255, 107, 157]; 14297 14301 14298 - $.ink(0, 0, 0, 180).box(caretX + 1, caretY + 1, caretWidth, caretHeight); 14299 - $.ink(...editColor).box(caretX, caretY, fillWidth, caretHeight); 14302 + // Draw per-column with shadows and a fly-in offset from the right. 14303 + for (let col = 0; col < filledCols; col++) { 14304 + const t = filledCols > 1 ? col / (filledCols - 1) : 1; 14305 + const flyIn = Math.round((1 - t) * (caretWidth - filledCols)); 14306 + const cx = caretX + col + flyIn; 14307 + $.ink(0, 0, 0, 180).box(cx + 1, caretY + 1, 1, caretHeight); 14308 + $.ink(...editColor).box(cx, caretY, 1, caretHeight); 14309 + } 14300 14310 } 14301 14311 } else { 14302 14312 $.ink(0).line(1, 1, 1, h - 1);
+160 -20
system/public/aesthetic.computer/lib/graph.mjs
··· 2927 2927 // (2) p1, p2: pairs of {x, y} or [x, y] 2928 2928 // (4) x0, y0, x1, y1 2929 2929 // TODO: Automatically use lineh if possible. 22.10.05.18.27 2930 + // Fast thick line with rounded endcaps via scanline capsule fill. 2931 + // For each row, finds the x-range where distance to the line segment <= r, 2932 + // then fills with a single lineh call. No per-pixel overhead. 2933 + function lineThick(x0, y0, x1, y1, r) { 2934 + x0 += panTranslation.x; 2935 + y0 += panTranslation.y; 2936 + x1 += panTranslation.x; 2937 + y1 += panTranslation.y; 2938 + 2939 + const r2 = r * r + r; // +r for pixel-grid rounding to match circle stamp look. 2940 + const ldx = x1 - x0, ldy = y1 - y0; 2941 + const len2 = ldx * ldx + ldy * ldy; 2942 + 2943 + // Degenerate: single point → filled circle scanlines. 2944 + if (len2 === 0) { 2945 + for (let dy = -r; dy <= r; dy++) { 2946 + const h = floor(sqrt(max(0, r2 - dy * dy))); 2947 + lineh(x0 - h, x0 + h, y0 + dy); 2948 + } 2949 + return; 2950 + } 2951 + 2952 + const invLen2 = 1 / len2; 2953 + const minY = floor(min(y0, y1) - r); 2954 + const maxY = floor(max(y0, y1) + r); 2955 + 2956 + for (let y = minY; y <= maxY; y++) { 2957 + // Find x-range where capsule distance ≤ r at this row. 2958 + // Sample a few x candidates and bracket the span: 2959 + // 1. Endcap circles at A and B. 2960 + // 2. Body: closest point on segment projected to this row. 2961 + 2962 + let lo = Infinity, hi = -Infinity; 2963 + 2964 + // Endcap circles. 2965 + const da = y - y0, db = y - y1; 2966 + const da2 = da * da, db2 = db * db; 2967 + if (da2 <= r2) { 2968 + const h = sqrt(r2 - da2); 2969 + lo = x0 - h; hi = x0 + h; 2970 + } 2971 + if (db2 <= r2) { 2972 + const h = sqrt(r2 - db2); 2973 + if (x1 - h < lo) lo = x1 - h; 2974 + if (x1 + h > hi) hi = x1 + h; 2975 + } 2976 + 2977 + // Body: project (x, y) onto segment. The closest point on the segment 2978 + // for a given y lies at t = ((x-x0)*ldx + (y-y0)*ldy) / len2. 2979 + // For the body, the perpendicular distance from (x,y) to the line is 2980 + // |cross| / len where cross = (y-y0)*ldx - (x-x0)*ldy. 2981 + // Solving cross^2 / len2 <= r2 for x: 2982 + // (C - x*ldy)^2 <= r2*len2 where C = (y-y0)*ldx + x0*ldy 2983 + // x*ldy ∈ [C - D, C + D] where D = sqrt(r2*len2) 2984 + // Then clamp resulting x to the segment's t ∈ [0,1] range. 2985 + const C = (y - y0) * ldx + x0 * ldy; 2986 + const D = sqrt(r2 * len2); 2987 + 2988 + if (abs(ldy) > 0.0001) { 2989 + // Solve for x from the perpendicular constraint. 2990 + let bxLo = (C - D) / ldy, bxHi = (C + D) / ldy; 2991 + if (bxLo > bxHi) { const tmp = bxLo; bxLo = bxHi; bxHi = tmp; } 2992 + 2993 + // Clamp to segment t ∈ [0,1]: x = x0 + t*ldx, so t = (x-x0)/ldx if ldx≠0. 2994 + // Instead, clamp by checking t at each body-x bound. 2995 + const tLo = ((bxLo - x0) * ldx + (y - y0) * ldy) * invLen2; 2996 + const tHi = ((bxHi - x0) * ldx + (y - y0) * ldy) * invLen2; 2997 + const tMin = max(0, min(tLo, tHi)); 2998 + const tMax = min(1, max(tLo, tHi)); 2999 + 3000 + if (tMin <= tMax) { 3001 + // At the clamped t range, compute x and offset by the 3002 + // perpendicular half-width at this row. 3003 + const xA = x0 + tMin * ldx, yA = y0 + tMin * ldy; 3004 + const xB = x0 + tMax * ldx, yB = y0 + tMax * ldy; 3005 + // Half-width from circle at each point. 3006 + const ha2 = r2 - (y - yA) * (y - yA); 3007 + const hb2 = r2 - (y - yB) * (y - yB); 3008 + if (ha2 >= 0) { 3009 + const h = sqrt(ha2); 3010 + if (xA - h < lo) lo = xA - h; 3011 + if (xA + h > hi) hi = xA + h; 3012 + } 3013 + if (hb2 >= 0) { 3014 + const h = sqrt(hb2); 3015 + if (xB - h < lo) lo = xB - h; 3016 + if (xB + h > hi) hi = xB + h; 3017 + } 3018 + } 3019 + } 3020 + 3021 + if (lo <= hi) { 3022 + lineh(floor(lo), floor(hi), y); 3023 + } 3024 + } 3025 + } 3026 + 2930 3027 function line() { 2931 - let x0, y0, x1, y1; 3028 + let x0, y0, x1, y1, thickness; 2932 3029 if (arguments.length === 1) { 2933 3030 // Safely access properties on the first argument 2934 3031 const arg0 = arguments[0]; ··· 2937 3034 y0 = arg0.y0; 2938 3035 x1 = arg0.x1; 2939 3036 y1 = arg0.y1; 3037 + thickness = arg0.thickness; 2940 3038 } 2941 - } else if (arguments.length === 4) { 3039 + } else if (arguments.length >= 4) { 2942 3040 x0 = arguments[0]; // Set all `undefined` or `null` values to 0. 2943 3041 y0 = arguments[1]; 2944 3042 x1 = arguments[2]; 2945 3043 y1 = arguments[3]; 3044 + thickness = arguments[4]; 2946 3045 } else if (arguments.length === 2) { 2947 3046 const arg0 = arguments[0]; 2948 3047 const arg1 = arguments[1]; ··· 2984 3083 if (isNaN(y0)) y0 = randIntRange(0, height); 2985 3084 if (isNaN(x1)) x1 = randIntRange(0, width); 2986 3085 if (isNaN(y1)) y1 = randIntRange(0, height); 3086 + 3087 + // Thick line with rounded endcaps. 3088 + if (thickness > 1) { 3089 + const radius = floor((thickness - 1) / 2); 3090 + lineThick(floor(x0), floor(y0), floor(x1), floor(y1), radius); 3091 + const out = [floor(x0), floor(y0), floor(x1), floor(y1)]; 3092 + twoDCommands?.push(["line", ...out]); 3093 + return out; 3094 + } 2987 3095 2988 3096 // console.log("Line in:", x0, y0, x1, y1); 2989 3097 ··· 4072 4180 const u2w = uv2[0] * invW2, v2w = uv2[1] * invW2; 4073 4181 const u3w = uv3[0] * invW3, v3w = uv3[1] * invW3; 4074 4182 4075 - // Interpolate W values 4076 - const mw12 = (w1 + w2) / 2, mw23 = (w2 + w3) / 2, mw31 = (w3 + w1) / 2; 4077 - 4078 - // Interpolate u/w and v/w 4183 + // Interpolate 1/w linearly in screen space (1/w is linear after perspective divide) 4184 + const mInvW12 = (invW1 + invW2) / 2, mInvW23 = (invW2 + invW3) / 2, mInvW31 = (invW3 + invW1) / 2; 4185 + const mw12 = 1 / mInvW12, mw23 = 1 / mInvW23, mw31 = 1 / mInvW31; 4186 + 4187 + // Interpolate u/w and v/w linearly in screen space 4079 4188 const mu12w = (u1w + u2w) / 2, mv12w = (v1w + v2w) / 2; 4080 4189 const mu23w = (u2w + u3w) / 2, mv23w = (v2w + v3w) / 2; 4081 4190 const mu31w = (u3w + u1w) / 2, mv31w = (v3w + v1w) / 2; 4082 - 4083 - // Recover UV coordinates: u = (u/w) * w 4084 - const muv12 = [mu12w * mw12, mv12w * mw12]; 4085 - const muv23 = [mu23w * mw23, mv23w * mw23]; 4086 - const muv31 = [mu31w * mw31, mv31w * mw31]; 4191 + 4192 + // Recover UV coordinates: u = (u/w) / (1/w) 4193 + const muv12 = [mu12w / mInvW12, mv12w / mInvW12]; 4194 + const muv23 = [mu23w / mInvW23, mv23w / mInvW23]; 4195 + const muv31 = [mu31w / mInvW31, mv31w / mInvW31]; 4087 4196 4088 4197 // Recursively subdivide the 4 sub-triangles 4089 4198 const result = []; ··· 9158 9267 return polygon; 9159 9268 } 9160 9269 9161 - // Linear interpolation between two vertices 9270 + // Interpolation between two vertices in screen space 9271 + // After perspective divide, 1/w and attribute/w vary linearly — not w or attributes directly 9162 9272 function lerpVertex(v1, v2, t) { 9273 + const w1 = v1.pos[3]; 9274 + const w2 = v2.pos[3]; 9275 + 9276 + // Fall back to simple linear interpolation if W values are degenerate 9277 + const safeW = w1 > 0.001 && w2 > 0.001 && Number.isFinite(w1) && Number.isFinite(w2); 9278 + 9279 + let newW; 9280 + if (safeW) { 9281 + // Perspective-correct: interpolate 1/w linearly in screen space 9282 + const invW1 = 1 / w1; 9283 + const invW2 = 1 / w2; 9284 + const newInvW = invW1 + (invW2 - invW1) * t; 9285 + newW = newInvW > 0.0001 ? 1 / newInvW : w1 + (w2 - w1) * t; 9286 + } else { 9287 + newW = w1 + (w2 - w1) * t; 9288 + } 9289 + 9163 9290 const vert = new Vertex([ 9164 9291 v1.pos[0] + (v2.pos[0] - v1.pos[0]) * t, 9165 9292 v1.pos[1] + (v2.pos[1] - v1.pos[1]) * t, 9166 9293 v1.pos[2] + (v2.pos[2] - v1.pos[2]) * t, 9167 - v1.pos[3] + (v2.pos[3] - v1.pos[3]) * t 9294 + newW 9168 9295 ]); 9169 - 9296 + 9170 9297 vert.color = [ 9171 9298 v1.color[0] + (v2.color[0] - v1.color[0]) * t, 9172 9299 v1.color[1] + (v2.color[1] - v1.color[1]) * t, 9173 9300 v1.color[2] + (v2.color[2] - v1.color[2]) * t, 9174 9301 v1.color[3] + (v2.color[3] - v1.color[3]) * t 9175 9302 ]; 9176 - 9177 - vert.texCoords = [ 9178 - v1.texCoords[0] + (v2.texCoords[0] - v1.texCoords[0]) * t, 9179 - v1.texCoords[1] + (v2.texCoords[1] - v1.texCoords[1]) * t 9180 - ]; 9181 - 9303 + 9304 + // Perspective-correct texCoord interpolation 9305 + if (safeW) { 9306 + const invW1 = 1 / w1, invW2 = 1 / w2; 9307 + const newInvW = invW1 + (invW2 - invW1) * t; 9308 + const tc1OverW = [v1.texCoords[0] * invW1, v1.texCoords[1] * invW1]; 9309 + const tc2OverW = [v2.texCoords[0] * invW2, v2.texCoords[1] * invW2]; 9310 + const safeDiv = newInvW > 0.0001 ? newInvW : 1; 9311 + vert.texCoords = [ 9312 + (tc1OverW[0] + (tc2OverW[0] - tc1OverW[0]) * t) / safeDiv, 9313 + (tc1OverW[1] + (tc2OverW[1] - tc1OverW[1]) * t) / safeDiv 9314 + ]; 9315 + } else { 9316 + vert.texCoords = [ 9317 + v1.texCoords[0] + (v2.texCoords[0] - v1.texCoords[0]) * t, 9318 + v1.texCoords[1] + (v2.texCoords[1] - v1.texCoords[1]) * t 9319 + ]; 9320 + } 9321 + 9182 9322 return vert; 9183 9323 } 9184 9324
+8 -3
system/public/aesthetic.computer/systems/nopaint.mjs
··· 130 130 return state === stateQuery; 131 131 } 132 132 133 + function nopaint_cancelStroke() { 134 + state = "idle"; 135 + } 136 + 133 137 // 📊 Trigger bake flash effect 134 138 function nopaint_triggerBakeFlash() { 135 139 bakeFlashTime = performance.now(); ··· 397 401 398 402 // Start 399 403 if ( 400 - e.is("keyboard:down:shift") || 401 - ((e.is("touch:2") || e.is("touch:1")) && pens().length === 2) 404 + !nopaint_is("panning") && 405 + (e.is("keyboard:down:shift") || 406 + ((e.is("touch:2") || e.is("touch:1")) && pens().length === 2)) 402 407 ) { 403 408 // if (debug) console.log("🧭 Panning!"); 404 409 previousState = state; // Preserve current state ··· 818 823 }; 819 824 } 820 825 821 - export { nopaint_boot, nopaint_act, nopaint_paint, nopaint_is, nopaint_adjust, nopaint_generateColoredLabel, nopaint_handleColor, nopaint_cleanupColor, nopaint_parseBrushParams, nopaint_renderPerfHUD, nopaint_triggerBakeFlash }; 826 + export { nopaint_boot, nopaint_act, nopaint_paint, nopaint_is, nopaint_cancelStroke, nopaint_adjust, nopaint_generateColoredLabel, nopaint_handleColor, nopaint_cleanupColor, nopaint_parseBrushParams, nopaint_renderPerfHUD, nopaint_triggerBakeFlash };