relabel(s.id, e.target.value)}
/>
- {(s.kind === 'path' || s.kind === 'rect' || s.kind === 'ellipse') && (
-
- )}
-
)
})}
diff --git a/frontend/src/utils/tidyShape.js b/frontend/src/utils/tidyShape.js
index b5010ec..2db7aa4 100644
--- a/frontend/src/utils/tidyShape.js
+++ b/frontend/src/utils/tidyShape.js
@@ -117,5 +117,31 @@ export default function tidyShape(shape, { tolerance = 0.015 } = {}) {
export function smoothOut(shape, epsilon = 0.004) {
const points = (shape?.points || []).filter(p => Array.isArray(p) && p.length === 2)
if (points.length < 3) return shape
- return { ...shape, points: simplify(points, epsilon).map(p => p.map(round)) }
+ // Two passes, because they do different jobs. Simplification drops points
+ // that carry no shape, which is sampling noise; it does not touch the ones
+ // it keeps, so a stroke that wandered by more than epsilon came back
+ // wandering by exactly as much and pressing Tidy looked like it had done
+ // nothing at all. Relaxing afterwards pulls each remaining point a little
+ // towards its neighbours — the shake goes, the route stays.
+ return { ...shape, points: relax(simplify(points, epsilon)).map(p => p.map(round)) }
+}
+
+/** A moving average over the interior, twice. The ends never move: they are
+ * where somebody put the pen down and lifted it, which is the one part of a
+ * freehand mark that is deliberate. */
+export function relax(points, passes = 2) {
+ let out = points
+ for (let pass = 0; pass < passes; pass += 1) {
+ if (out.length < 3) return out
+ const next = [out[0]]
+ for (let i = 1; i < out.length - 1; i += 1) {
+ next.push([
+ (out[i - 1][0] + out[i][0] * 2 + out[i + 1][0]) / 4,
+ (out[i - 1][1] + out[i][1] * 2 + out[i + 1][1]) / 4,
+ ])
+ }
+ next.push(out[out.length - 1])
+ out = next
+ }
+ return out
}
diff --git a/frontend/src/utils/tidyShape.test.js b/frontend/src/utils/tidyShape.test.js
index fc3a3fb..40fe8df 100644
--- a/frontend/src/utils/tidyShape.test.js
+++ b/frontend/src/utils/tidyShape.test.js
@@ -43,6 +43,21 @@ describe('tidying a hand-drawn mark', () => {
expect(tidied.points.at(-1)[0]).toBeCloseTo(points.at(-1)[0], 3)
})
+ it('takes the shake out of a stroke without moving where it went', () => {
+ // Simplification alone left a shaky trace shaking: it drops points, it
+ // does not move the ones it keeps, so Tidy on a wobble that is bigger than
+ // epsilon looked like a button that did nothing.
+ const shaky = Array.from({ length: 21 }, (_, i) => (
+ [0.2 + i * 0.03, 0.5 + (i % 2 ? 0.012 : -0.012)]))
+ const tidied = smoothOut(path(shaky))
+ const swing = ys => Math.max(...ys) - Math.min(...ys)
+ expect(swing(tidied.points.slice(1, -1).map(p => p[1])))
+ .toBeLessThan(swing(shaky.slice(1, -1).map(p => p[1])) / 2)
+ // Where the pen went down and came up is deliberate, and stays.
+ expect(tidied.points[0]).toEqual(shaky[0])
+ expect(tidied.points.at(-1)).toEqual(shaky.at(-1))
+ })
+
it('keeps the corners a simplification is for', () => {
const elbow = [[0, 0], [0.25, 0], [0.5, 0], [0.5, 0.25], [0.5, 0.5]]
expect(simplify(elbow, 0.004)).toEqual([[0, 0], [0.5, 0], [0.5, 0.5]])