cmd + scroll to adjust brush roundness
Hunter
Wednesday, April 15th, 2026 at 6:28 PM
106 // color / brush
107 let r = 255, g = 255, b = 255;
108 let brush = 1;
109 + // brush roundness: 0 = square (default), 1 = circle inscribed in the square.
110 + // preserved as a fraction when the brush size changes.
111 + let roundness = 0;
112 +
113 + // cached brush shape for the current (brush, roundness, color):
114 + // inside: Uint8Array of length n*n, 1 = pixel belongs to the brush
115 + // fillRuns: [dx, dy, len, ...] row runs over `inside` (cursor fill)
116 + // outlineRuns: [dx, dy, len, ...] row runs over the 1-pixel dilation
117 + // ring around `inside`, in (-1..n) shape-local coords
118 + // (cursor outline)
119 + // sprite: an n×n offscreen canvas with the shape rasterized in the
120 + // current paint color, transparent outside — stamped with
121 + // a single drawImage per paintAt call.
122 + let brushShape = null;
123 + let brushShapeKey = '';
124 + function getBrushShape() {
125 + const key = brush + ',' + roundness + ',' + r + ',' + g + ',' + b;
126 + if (brushShapeKey === key && brushShape) return brushShape;
127 + const n = brush;
128 + const inside = new Uint8Array(n * n);
129 + // rounded-square: corner radius grows uniformly with roundness.
130 + // roundness=0 -> radius 0, full square (corners preserved).
131 + // roundness=1 -> radius n/2, inscribed circle.
132 + const rad = roundness * (n / 2);
133 + const rad2 = rad * rad;
134 + const lo = rad - 0.5;
135 + const hi = n - 0.5 - rad;
136 + for (let dy = 0; dy < n; dy++) {
137 + for (let dx = 0; dx < n; dx++) {
138 + let qx = 0, qy = 0;
139 + if (dx < lo) qx = lo - dx;
140 + else if (dx > hi) qx = dx - hi;
141 + if (dy < lo) qy = lo - dy;
142 + else if (dy > hi) qy = dy - hi;
143 + if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1;
144 + }
145 + }
146 + const sprite = document.createElement('canvas');
147 + sprite.width = n;
148 + sprite.height = n;
149 + const sctx = sprite.getContext('2d');
150 + const img = sctx.createImageData(n, n);
151 + const data = img.data;
152 + for (let i = 0; i < n * n; i++) {
153 + if (inside[i]) {
154 + data[i * 4] = r;
155 + data[i * 4 + 1] = g;
156 + data[i * 4 + 2] = b;
157 + data[i * 4 + 3] = 255;
158 + }
159 + }
160 + sctx.putImageData(img, 0, 0);
161 +
162 + // row-runs over `inside` for the cursor fill
163 + const fillRuns = [];
164 + for (let dy = 0; dy < n; dy++) {
165 + let dx = 0;
166 + while (dx < n) {
167 + if (!inside[dy * n + dx]) { dx++; continue; }
168 + let dx1 = dx + 1;
169 + while (dx1 < n && inside[dy * n + dx1]) dx1++;
170 + fillRuns.push(dx, dy, dx1 - dx);
171 + dx = dx1;
172 + }
173 + }
174 +
175 + // 1-pixel outline ring: cells outside the shape that are 8-way adjacent
176 + // to any inside cell. stored as row-runs in an (n+2)×(n+2) grid with
177 + // coordinates offset by -1 so they index directly in shape-local space.
178 + const m = n + 2;
179 + const ring = new Uint8Array(m * m);
180 + const isIn = (x, y) => x >= 0 && y >= 0 && x < n && y < n && inside[y * n + x] === 1;
181 + for (let y = -1; y <= n; y++) {
182 + for (let x = -1; x <= n; x++) {
183 + if (isIn(x, y)) continue;
184 + let adj = false;
185 + for (let oy = -1; oy <= 1 && !adj; oy++) {
186 + for (let ox = -1; ox <= 1 && !adj; ox++) {
187 + if (ox === 0 && oy === 0) continue;
188 + if (isIn(x + ox, y + oy)) adj = true;
189 + }
190 + }
191 + if (adj) ring[(y + 1) * m + (x + 1)] = 1;
192 + }
193 + }
194 + const outlineRuns = [];
195 + for (let y = 0; y < m; y++) {
196 + let x = 0;
197 + while (x < m) {
198 + if (!ring[y * m + x]) { x++; continue; }
199 + let x1 = x + 1;
200 + while (x1 < m && ring[y * m + x1]) x1++;
201 + outlineRuns.push(x - 1, y - 1, x1 - x);
202 + x = x1;
203 + }
204 + }
205 +
206 + brushShape = { inside, sprite, fillRuns, outlineRuns, n };
207 + brushShapeKey = key;
208 + return brushShape;
209 + }
210
211 // outline light/dark blend: animates between 0 (dark color -> lighten)
212 // and 1 (light color -> darken). cross-fades over 0.25s.
266
267 function paintAt(cx, cy) {
268 const tl = brushTopLeft(cx, cy);
- const color = 'rgb(' + r + ',' + g + ',' + b + ')';
- paintRect(tl.x, tl.y, brush, brush, color);
269 + if (roundness === 0) {
270 + paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')');
271 + return;
272 + }
273 + // stamp the cached brush sprite into every chunk the brush touches —
274 + // one drawImage per chunk, regardless of brush size.
275 + const sprite = getBrushShape().sprite;
276 + const cx0 = Math.floor(tl.x / CHUNK);
277 + const cy0 = Math.floor(tl.y / CHUNK);
278 + const cx1 = Math.floor((tl.x + brush - 1) / CHUNK);
279 + const cy1 = Math.floor((tl.y + brush - 1) / CHUNK);
280 + for (let ccy = cy0; ccy <= cy1; ccy++) {
281 + for (let ccx = cx0; ccx <= cx1; ccx++) {
282 + const c = getOrCreateChunk(ccx, ccy);
283 + c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK);
284 + }
285 + }
286 }
287
288 function paintLine(x0, y0, x1, y1) {
376 vctx.fillStyle = '#000';
377 vctx.fillRect(0, 0, W, H);
378
- // one logical pixel on screen = zoom CSS px = zoom*dpr device px
- const pxD = zoom * dpr;
379 + // one logical pixel on screen = integer device px. we round here so
380 + // every logical pixel occupies the exact same number of device pixels:
381 + // otherwise fractional dpr (1.25/1.5/1.75) makes nearest-neighbor
382 + // resampling drop or duplicate rows, producing transparent stripes
383 + // through painted content at high zoom.
384 + const pxD = Math.max(1, Math.round(zoom * dpr));
385
386 // visible logical-pixel range
387 const viewWLog = cssW / zoom;
417
418 if (mouseInside) {
419 const tl = brushTopLeft(curX, curY);
- const sx = (tl.x - camX) * pxD;
- const sy = (tl.y - camY) * pxD;
- const sSize = brush * pxD;
420 + // round to integer device pixels — camX/camY are fractional (smooth
421 + // pan), and fractional fillRect coordinates antialias their edges,
422 + // which would leave transparent lines between adjacent row strips.
423 + const sx = Math.round((tl.x - camX) * pxD);
424 + const sy = Math.round((tl.y - camY) * pxD);
425 + const n = brush;
426
- // fill first (uses eased display color so pick transitions smoothly)
427 vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')';
- vctx.fillRect(sx, sy, sSize, sSize);
428 + if (roundness === 0) {
429 + vctx.fillRect(sx, sy, n * pxD, n * pxD);
430 + } else {
431 + const runs = getBrushShape().fillRuns;
432 + for (let i = 0; i < runs.length; i += 3) {
433 + vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
434 + }
435 + }
436
- // 1-logical-pixel outline with cross-faded darken/lighten blend.
- // light weight -> multiply (darken); dark weight -> screen (lighten).
- const ow = pxD;
437 + // 1-logical-pixel outline, cross-faded darken/lighten blend.
438 + const shape = roundness === 0 ? null : getBrushShape();
439 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
440 + if (shape) {
441 + const runs = shape.outlineRuns;
442 + for (let i = 0; i < runs.length; i += 3) {
443 + vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
444 + }
445 + } else {
446 + vctx.fillRect(sx - pxD, sy - pxD, (n + 2) * pxD, pxD); // top
447 + vctx.fillRect(sx - pxD, sy + n * pxD, (n + 2) * pxD, pxD); // bottom
448 + vctx.fillRect(sx - pxD, sy, pxD, n * pxD); // left
449 + vctx.fillRect(sx + n * pxD, sy, pxD, n * pxD); // right
450 + }
451 };
452 const L = outlineLightness;
453 vctx.save();
572 // determine the mode this gesture belongs to; lock it for the duration
573 if (wheelMode === null) {
574 if (e.ctrlKey) wheelMode = 'zoom';
575 + else if (e.metaKey) wheelMode = 'roundness';
576 else if (e.shiftKey) wheelMode = 'brush';
577 else if (keys['r'] || keys['g'] || keys['b']) wheelMode = 'color';
578 else wheelMode = 'pan';
597 return;
598 }
599
600 + if (wheelMode === 'roundness') {
601 + // scroll up = squarer, scroll down = rounder
602 + const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;
603 + roundness = clamp(roundness + d * 0.01, 0, 1);
604 + requestDraw();
605 + return;
606 + }
607 +
608 if (wheelMode === 'brush') {
609 // inverted: scroll up = bigger, scroll down = smaller
610 const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;