commit 3e5b177d11d8c08430c0397ce08bb351e79c0d41
parent b90502806558ed45b58abbd11b7e971c2efaca0a
Author: Hunter
Date: Wed, 15 Apr 2026 18:28:16 -0400
cmd + scroll to adjust brush roundness
Diffstat:
| M | index.html | | | 176 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- |
1 file changed, 160 insertions(+), 16 deletions(-)
diff --git a/index.html b/index.html
@@ -106,6 +106,107 @@
// color / brush
let r = 255, g = 255, b = 255;
let brush = 1;
+ // brush roundness: 0 = square (default), 1 = circle inscribed in the square.
+ // preserved as a fraction when the brush size changes.
+ let roundness = 0;
+
+ // cached brush shape for the current (brush, roundness, color):
+ // inside: Uint8Array of length n*n, 1 = pixel belongs to the brush
+ // fillRuns: [dx, dy, len, ...] row runs over `inside` (cursor fill)
+ // outlineRuns: [dx, dy, len, ...] row runs over the 1-pixel dilation
+ // ring around `inside`, in (-1..n) shape-local coords
+ // (cursor outline)
+ // sprite: an n×n offscreen canvas with the shape rasterized in the
+ // current paint color, transparent outside — stamped with
+ // a single drawImage per paintAt call.
+ let brushShape = null;
+ let brushShapeKey = '';
+ function getBrushShape() {
+ const key = brush + ',' + roundness + ',' + r + ',' + g + ',' + b;
+ if (brushShapeKey === key && brushShape) return brushShape;
+ const n = brush;
+ const inside = new Uint8Array(n * n);
+ // rounded-square: corner radius grows uniformly with roundness.
+ // roundness=0 -> radius 0, full square (corners preserved).
+ // roundness=1 -> radius n/2, inscribed circle.
+ const rad = roundness * (n / 2);
+ const rad2 = rad * rad;
+ const lo = rad - 0.5;
+ const hi = n - 0.5 - rad;
+ for (let dy = 0; dy < n; dy++) {
+ for (let dx = 0; dx < n; dx++) {
+ let qx = 0, qy = 0;
+ if (dx < lo) qx = lo - dx;
+ else if (dx > hi) qx = dx - hi;
+ if (dy < lo) qy = lo - dy;
+ else if (dy > hi) qy = dy - hi;
+ if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1;
+ }
+ }
+ const sprite = document.createElement('canvas');
+ sprite.width = n;
+ sprite.height = n;
+ const sctx = sprite.getContext('2d');
+ const img = sctx.createImageData(n, n);
+ const data = img.data;
+ for (let i = 0; i < n * n; i++) {
+ if (inside[i]) {
+ data[i * 4] = r;
+ data[i * 4 + 1] = g;
+ data[i * 4 + 2] = b;
+ data[i * 4 + 3] = 255;
+ }
+ }
+ sctx.putImageData(img, 0, 0);
+
+ // row-runs over `inside` for the cursor fill
+ const fillRuns = [];
+ for (let dy = 0; dy < n; dy++) {
+ let dx = 0;
+ while (dx < n) {
+ if (!inside[dy * n + dx]) { dx++; continue; }
+ let dx1 = dx + 1;
+ while (dx1 < n && inside[dy * n + dx1]) dx1++;
+ fillRuns.push(dx, dy, dx1 - dx);
+ dx = dx1;
+ }
+ }
+
+ // 1-pixel outline ring: cells outside the shape that are 8-way adjacent
+ // to any inside cell. stored as row-runs in an (n+2)×(n+2) grid with
+ // coordinates offset by -1 so they index directly in shape-local space.
+ const m = n + 2;
+ const ring = new Uint8Array(m * m);
+ const isIn = (x, y) => x >= 0 && y >= 0 && x < n && y < n && inside[y * n + x] === 1;
+ for (let y = -1; y <= n; y++) {
+ for (let x = -1; x <= n; x++) {
+ if (isIn(x, y)) continue;
+ let adj = false;
+ for (let oy = -1; oy <= 1 && !adj; oy++) {
+ for (let ox = -1; ox <= 1 && !adj; ox++) {
+ if (ox === 0 && oy === 0) continue;
+ if (isIn(x + ox, y + oy)) adj = true;
+ }
+ }
+ if (adj) ring[(y + 1) * m + (x + 1)] = 1;
+ }
+ }
+ const outlineRuns = [];
+ for (let y = 0; y < m; y++) {
+ let x = 0;
+ while (x < m) {
+ if (!ring[y * m + x]) { x++; continue; }
+ let x1 = x + 1;
+ while (x1 < m && ring[y * m + x1]) x1++;
+ outlineRuns.push(x - 1, y - 1, x1 - x);
+ x = x1;
+ }
+ }
+
+ brushShape = { inside, sprite, fillRuns, outlineRuns, n };
+ brushShapeKey = key;
+ return brushShape;
+ }
// outline light/dark blend: animates between 0 (dark color -> lighten)
// and 1 (light color -> darken). cross-fades over 0.25s.
@@ -165,8 +266,23 @@
function paintAt(cx, cy) {
const tl = brushTopLeft(cx, cy);
- const color = 'rgb(' + r + ',' + g + ',' + b + ')';
- paintRect(tl.x, tl.y, brush, brush, color);
+ if (roundness === 0) {
+ paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')');
+ return;
+ }
+ // stamp the cached brush sprite into every chunk the brush touches —
+ // one drawImage per chunk, regardless of brush size.
+ const sprite = getBrushShape().sprite;
+ const cx0 = Math.floor(tl.x / CHUNK);
+ const cy0 = Math.floor(tl.y / CHUNK);
+ const cx1 = Math.floor((tl.x + brush - 1) / CHUNK);
+ const cy1 = Math.floor((tl.y + brush - 1) / CHUNK);
+ for (let ccy = cy0; ccy <= cy1; ccy++) {
+ for (let ccx = cx0; ccx <= cx1; ccx++) {
+ const c = getOrCreateChunk(ccx, ccy);
+ c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK);
+ }
+ }
}
function paintLine(x0, y0, x1, y1) {
@@ -260,8 +376,12 @@
vctx.fillStyle = '#000';
vctx.fillRect(0, 0, W, H);
- // one logical pixel on screen = zoom CSS px = zoom*dpr device px
- const pxD = zoom * dpr;
+ // one logical pixel on screen = integer device px. we round here so
+ // every logical pixel occupies the exact same number of device pixels:
+ // otherwise fractional dpr (1.25/1.5/1.75) makes nearest-neighbor
+ // resampling drop or duplicate rows, producing transparent stripes
+ // through painted content at high zoom.
+ const pxD = Math.max(1, Math.round(zoom * dpr));
// visible logical-pixel range
const viewWLog = cssW / zoom;
@@ -297,22 +417,37 @@
if (mouseInside) {
const tl = brushTopLeft(curX, curY);
- const sx = (tl.x - camX) * pxD;
- const sy = (tl.y - camY) * pxD;
- const sSize = brush * pxD;
+ // round to integer device pixels — camX/camY are fractional (smooth
+ // pan), and fractional fillRect coordinates antialias their edges,
+ // which would leave transparent lines between adjacent row strips.
+ const sx = Math.round((tl.x - camX) * pxD);
+ const sy = Math.round((tl.y - camY) * pxD);
+ const n = brush;
- // fill first (uses eased display color so pick transitions smoothly)
vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')';
- vctx.fillRect(sx, sy, sSize, sSize);
+ if (roundness === 0) {
+ vctx.fillRect(sx, sy, n * pxD, n * pxD);
+ } else {
+ const runs = getBrushShape().fillRuns;
+ for (let i = 0; i < runs.length; i += 3) {
+ vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
+ }
+ }
- // 1-logical-pixel outline with cross-faded darken/lighten blend.
- // light weight -> multiply (darken); dark weight -> screen (lighten).
- const ow = pxD;
+ // 1-logical-pixel outline, cross-faded darken/lighten blend.
+ const shape = roundness === 0 ? null : getBrushShape();
const drawOutline = () => {
- vctx.fillRect(sx - ow, sy - ow, sSize + 2 * ow, ow); // top
- vctx.fillRect(sx - ow, sy + sSize, sSize + 2 * ow, ow); // bottom
- vctx.fillRect(sx - ow, sy, ow, sSize); // left
- vctx.fillRect(sx + sSize, sy, ow, sSize); // right
+ if (shape) {
+ const runs = shape.outlineRuns;
+ for (let i = 0; i < runs.length; i += 3) {
+ vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
+ }
+ } else {
+ vctx.fillRect(sx - pxD, sy - pxD, (n + 2) * pxD, pxD); // top
+ vctx.fillRect(sx - pxD, sy + n * pxD, (n + 2) * pxD, pxD); // bottom
+ vctx.fillRect(sx - pxD, sy, pxD, n * pxD); // left
+ vctx.fillRect(sx + n * pxD, sy, pxD, n * pxD); // right
+ }
};
const L = outlineLightness;
vctx.save();
@@ -437,6 +572,7 @@
// determine the mode this gesture belongs to; lock it for the duration
if (wheelMode === null) {
if (e.ctrlKey) wheelMode = 'zoom';
+ else if (e.metaKey) wheelMode = 'roundness';
else if (e.shiftKey) wheelMode = 'brush';
else if (keys['r'] || keys['g'] || keys['b']) wheelMode = 'color';
else wheelMode = 'pan';
@@ -461,6 +597,14 @@
return;
}
+ if (wheelMode === 'roundness') {
+ // scroll up = squarer, scroll down = rounder
+ const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;
+ roundness = clamp(roundness + d * 0.01, 0, 1);
+ requestDraw();
+ return;
+ }
+
if (wheelMode === 'brush') {
// inverted: scroll up = bigger, scroll down = smaller
const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;