From d4f5fade39b3dcd59fcef5afacaa138985c68c6a Mon Sep 17 00:00:00 2001 From: difficcd Date: Thu, 1 Oct 2026 15:03:05 +0900 Subject: [PATCH] fix: the pencil keeps answering pressure above the neutral point (#338) The pencil's width term was `0.65 + 0.35 * Math.min(1, pr * 2)`. The `* 2` is what puts the neutral 0.5 at 1x, so the clamp bit at exactly neutral: every press above it drew the same stroke to the pixel. Measured on a straight 24px stroke: pressure 0.1 0.25 0.5 0.6 0.75 0.9 1 before 18 20 24 24 24 24 24 after 18 20 24 26 28 32 34 brush 6 12 24 30 36 44 48 A stylus writes well above 0.5, so in practice the pencil behaved as though pressure were switched off - which is what came in from the tablet. The brush, on the same page, doubles. Dropping the clamp only extends the curve past neutral, and that matters more than it sounds: strokes are stored as points and re-rendered on every paint, so a change to this formula changes artwork already drawn. Checked byte-for-byte against main - mouse, and pen at 0.1, 0.25, 0.4 and 0.5, are all identical images; only a press above neutral differs. The alpha term keeps its clamp, and a comment now says why: it is already fully opaque at neutral, so there is no headroom to press into, and lowering the neutral to make room would re-render every pencil stroke ever drawn, lighter. Above neutral the pencil says pressure in its width alone. Co-Authored-By: Claude Opus 5 --- src/canvas/strokes.ts | 15 ++++++++++++++- test/canvas/pixels.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/canvas/strokes.ts b/src/canvas/strokes.ts index f6199a8..35991db 100644 --- a/src/canvas/strokes.ts +++ b/src/canvas/strokes.ts @@ -478,7 +478,15 @@ export function drawStrokesOnCtx(ctx: CanvasRenderingContext2D, strokes: readonl // reads as a textured graphite line rather than a flat vector stroke. Pressure = darkness. if (s.tool === 'pencil') { const pp = smooth(s.points); - const pw = pp.map((_, i) => baseSize * (0.65 + 0.35 * Math.min(1, prAt(pp, i) * 2))); + // 0.65x at no pressure, 1x at the neutral 0.5, 1.35x at a full press. The `* 2` is + // what puts neutral at 1, and this used to be wrapped in `Math.min(1, ...)` - which + // meant the width stopped growing at 0.5 and every press above it drew exactly the + // same stroke, to the pixel. A stylus writes well above 0.5, so in practice the + // pencil behaved as though pressure were switched off: #338's "pencil pressure is + // ignored". Removing the clamp only extends the curve past neutral; nothing at or + // below 0.5 changes, so no mouse-drawn stroke - which reports no pressure and reads + // as the neutral 0.5 - renders any differently than before. + const pw = pp.map((_, i) => baseSize * (0.65 + 0.7 * prAt(pp, i))); // As the marker: the scratch, the grain fill and the blit were all full-canvas for // a stroke of any size. This was the most expensive thing the profiler found. const box = inkBounds(pp, Math.max(...pw, baseSize) / 2 + SCRATCH_MARGIN, ctx.canvas.width, ctx.canvas.height); @@ -491,6 +499,11 @@ export function drawStrokesOnCtx(ctx: CanvasRenderingContext2D, strokes: readonl // have re-phased the grain per stroke. tctx.setTransform(1, 0, 0, 1, -box.x, -box.y); tctx.lineCap = 'round'; tctx.lineJoin = 'round'; tctx.strokeStyle = baseColor; tctx.fillStyle = baseColor; + // Darkness, 0.5x at no pressure up to fully opaque at the neutral 0.5. This clamp, + // unlike the width's, is real and stays: alpha is already 1 at neutral, so there is + // no headroom to press into. Lowering the neutral to make room would re-render every + // pencil stroke ever drawn - lighter - so above neutral the pencil says pressure in + // its width alone. smoothStroke(tctx, pp, pw, (i) => { tctx.globalAlpha = 0.5 + 0.5 * Math.min(1, prAt(pp, i) * 2); }); tctx.globalAlpha = 1; tctx.globalCompositeOperation = 'destination-in'; const pat = tctx.createPattern(grainTile(), 'repeat'); diff --git a/test/canvas/pixels.test.js b/test/canvas/pixels.test.js index 33c6b60..38b837c 100644 --- a/test/canvas/pixels.test.js +++ b/test/canvas/pixels.test.js @@ -66,6 +66,35 @@ test('an eraser stroke takes ink away where it crosses', () => { assert.ok(after > 0, 'the eraser took the whole stroke, not a band of it'); }); +/** A straight pencil stroke at one pressure, and how thick it came out. */ +function pencilThickness(pressure, pen = true, size = 24) { + const { c, ctx } = fresh(); + const points = []; + for (let x = 20; x <= 180; x += 10) points.push({ x, y: 60, pressure }); + drawStrokesOnCtx(ctx, [stroke({ tool: 'pencil', size, pen, points })], false, new Map()); + const { box } = ink(c); + return box ? box.y1 - box.y0 + 1 : 0; +} + +test('a pencil keeps widening above the neutral pressure', () => { + // The width term was wrapped in `Math.min(1, pr * 2)`, which stopped growing at 0.5 - so + // every press above neutral drew the same stroke to the pixel, and a stylus (which writes + // well above 0.5) behaved as though pressure were switched off. That is #338's "pencil + // pressure is ignored"; on main these three numbers are all equal. + const a = pencilThickness(0.5), b = pencilThickness(0.75), d = pencilThickness(1); + assert.ok(b > a, `0.75 is no thicker than neutral: ${a} -> ${b}`); + assert.ok(d > b, `a full press is no thicker than 0.75: ${b} -> ${d}`); +}); + +test('a pencil drawn with no pressure is drawn at the neutral weight', () => { + // The invariant that keeps the curve change off existing artwork: a mouse reports no + // pressure, which reads as 0.5, and the width at 0.5 is exactly 1x. So any future reshaping + // of the pencil's pressure response has to keep passing through 1 at neutral, or every + // pencil stroke ever drawn re-renders differently. + const mouse = pencilThickness(0.5, false), neutralPen = pencilThickness(0.5, true); + assert.equal(mouse, neutralPen); +}); + /** * The width an eraser actually clears, measured as the gap it cuts through a solid band. * `pen` is set, so the pressure is trusted as a tablet's would be.