cmd + scroll to adjust brush roundness

Hunter
Wednesday, April 15th, 2026 at 6:28 PM

index.html+160-16

index.html
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;