Catmull-Rom smoothing!

This commit is contained in:
Yaro Kasear 2026-01-07 09:36:35 -06:00
parent 63f3ad1394
commit e4b59b124e

View file

@ -2,7 +2,7 @@
if (window.__gridKeydownBound) return;
window.__gridKeydownBound = true;
window.activeGridWidget = null; // define it once, globally
window.activeGridWidget = null;
document.addEventListener('keydown', (e) => {
const w = window.activeGridWidget;
@ -31,7 +31,9 @@ function initGridWidget(root, opts = {}) {
}
let doc = loadDoc();
let shapes = sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : []);
let shapes = rebuildPathCaches(
sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : [])
);
let cellSize = Number(doc.cellSize) || 25;
let ctx;
@ -73,6 +75,7 @@ function initGridWidget(root, opts = {}) {
if (s.type === 'path') {
if (!Array.isArray(s.points) || s.points.length < 2) return [];
const points = s.points.flatMap(p => {
if (!p || !isFiniteNum(p.x) || !isFiniteNum(p.y)) return [];
return [{ x: +p.x, y: +p.y }];
@ -101,14 +104,74 @@ function initGridWidget(root, opts = {}) {
});
}
function stripCaches(shapes) {
return shapes.map(s => {
if (s.type === 'path') {
return {
type: 'path',
points: s.points,
color: s.color,
strokeWidth: s.strokeWidth,
strokeOpacity: s.strokeOpacity
};
}
if (s.type === 'line') {
return {
type: 'line',
x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2,
color: s.color,
strokeWidth: s.strokeWidth,
strokeOpacity: s.strokeOpacity
};
}
if (s.type === 'rect' || s.type === 'ellipse') {
return {
type: s.type,
x: s.x, y: s.y, w: s.w, h: s.h,
color: s.color,
fill: !!s.fill,
fillOpacity: s.fillOpacity,
strokeOpacity: s.strokeOpacity,
strokeWidth: s.strokeWidth
};
}
return s; // shouldn't happen
});
}
function rebuildPathCaches(list) {
return list.map(s => {
if (s.type !== 'path') return s;
if (!Array.isArray(s.points) || s.points.length < 2) return s;
const renderPoints = catmullRomResample(s.points, {
alpha: 0.5,
samplesPerSeg: 10,
maxSamplesPerSeg: 40,
minSamplesPerSeg: 6,
closed: false,
maxOutputPoints: 4000
});
return {
...s,
...(renderPoints?.length >= 2 ? { renderPoints } : {})
};
});
}
function loadDoc() {
try { return JSON.parse(localStorage.getItem(storageKey)) || structuredClone(DEFAULT_DOC); }
catch { return structuredClone(DEFAULT_DOC); }
}
function saveDoc(nextDoc = doc) {
doc = nextDoc;
try { localStorage.setItem(storageKey, JSON.stringify(nextDoc)); } catch { }
const safeDoc = {
...nextDoc,
shapes: stripCaches(Array.isArray(nextDoc.shapes) ? nextDoc.shapes : [])
};
doc = safeDoc;
try { localStorage.setItem(storageKey, JSON.stringify(safeDoc)); } catch { }
}
function resizeAndSetupCanvas() {
@ -198,7 +261,9 @@ function initGridWidget(root, opts = {}) {
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
const pts = shape.points;
const pts = (shape.renderPoints && shape.renderPoints.length >= 2)
? shape.renderPoints
: shape.points;
ctx.beginPath();
ctx.moveTo(toPx(pts[0].x), toPx(pts[0].y));
for (let i = 1; i < pts.length; i++) {
@ -220,8 +285,14 @@ function initGridWidget(root, opts = {}) {
function setDoc(nextDoc) {
const d = nextDoc && typeof nextDoc === 'object' ? nextDoc : DEFAULT_DOC;
cellSize = Number(d.cellSize) || 25;
shapes = sanitizeShapes(Array.isArray(d.shapes) ? d.shapes : []);
saveDoc({ version: Number(d.version) || 1, cellSize, shapes });
shapes = rebuildPathCaches(
sanitizeShapes(Array.isArray(d.shapes) ? d.shapes : [])
);
if (mode === 'editor') {
saveDoc({ version: Number(d.version) || 1, cellSize, shapes });
}
resizeAndSetupCanvas();
}
@ -501,10 +572,97 @@ function initGridWidget(root, opts = {}) {
return deduped;
}
function catmullRomResample(points, {
alpha = 0.5,
samplesPerSeg = 8,
maxSamplesPerSeg = 32,
minSamplesPerSeg = 4,
closed = false,
maxOutputPoints = 5000
} = {}) {
if (!Array.isArray(points) || points.length < 2) return points || [];
const dist = (a, b) => {
const dx = b.x - a.x, dy = b.y - a.y;
return Math.hypot(dx, dy);
}
const tj = (ti, pi, pj) => ti + Math.pow(dist(pi, pj), alpha);
const lerp2 = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
function evalSegment(p0, p1, p2, p3, t) {
let t0 = 0;
let t1 = tj(t0, p0, p1);
let t2 = tj(t1, p1, p2);
let t3 = tj(t2, p2, p3);
const eps = 1e-6;
if (t1 - t0 < eps) t1 = t0 + eps;
if (t2 - t1 < eps) t2 = t1 + eps;
if (t3 - t2 < eps) t3 = t2 + eps;
const u = t1 + (t2 - t1) * t;
const A1 = lerp2(p0, p1, (u - t0) / (t1 - t0));
const A2 = lerp2(p1, p2, (u - t1) / (t2 - t1));
const A3 = lerp2(p2, p3, (u - t2) / (t3 - t2));
const B1 = lerp2(A1, A2, (u - t0) / (t2 - t0));
const B2 = lerp2(A2, A3, (u - t1) / (t3 - t1));
const C = lerp2(B1, B2, (u - t1) / (t2 - t1));
return C;
}
const src = points;
const n = src.length;
const get = (i) => {
if (closed) {
const k = (i % n + n) % n;
return src[k];
}
if (i < 0) return src[0];
if (i >= n) return src[n - 1];
return src[i];
};
const out = [];
const pushPoint = (p) => {
if (out.length >= maxOutputPoints) return false;
const prev = out[out.length - 1];
if (!prev || prev.x !== p.x || prev.y !== p.y) out.push(p);
return true;
};
pushPoint({ x: src[0].x, y: src[0].y });
const segCount = closed ? n : (n - 1);
for (let i = 0; i < segCount; i++) {
const p0 = get(i - 1);
const p1 = get(i);
const p2 = get(i + 1);
const p3 = get(i + 2);
const segLen = dist(p1, p2);
const adaptive = Math.round(samplesPerSeg * Math.max(1, segLen * 0.75));
const steps = Math.max(minSamplesPerSeg, Math.min(maxSamplesPerSeg, adaptive));
for (let s = 1; s <= steps; s++) {
const t = s / steps;
const p = evalSegment(p0, p1, p2, p3, t);
if (!pushPoint(p)) return out;
}
}
return out;
}
function undo() {
if (historyIndex <= 0) return;
historyIndex--;
shapes = structuredClone(history[historyIndex]);
shapes = rebuildPathCaches(structuredClone(history[historyIndex]));
saveDoc({ ...doc, shapes, cellSize });
redrawAll();
}
@ -512,7 +670,7 @@ function initGridWidget(root, opts = {}) {
function redo() {
if (historyIndex >= history.length - 1) return;
historyIndex++;
shapes = structuredClone(history[historyIndex]);
shapes = rebuildPathCaches(structuredClone(history[historyIndex]));
saveDoc({ ...doc, shapes, cellSize });
redrawAll();
}
@ -530,7 +688,7 @@ function initGridWidget(root, opts = {}) {
if (historyIndex < 0) historyIndex = 0;
}
shapes = nextShapes;
shapes = rebuildPathCaches(nextShapes);
saveDoc({ ...doc, shapes, cellSize });
redrawAll();
@ -729,7 +887,22 @@ function initGridWidget(root, opts = {}) {
const simplified = simplifyRDP(coarse, epsilon);
if (simplified.length >= 2) {
finalShape = { ...currentShape, points: simplified };
const renderPoints = catmullRomResample(simplified, {
alpha: 0.5,
samplesPerSeg: 10,
maxSamplesPerSeg: 40,
minSamplesPerSeg: 6,
closed: false,
maxOutputPoints: 4000 // also fix your option name, see below
});
finalShape = {
type: 'path',
points: simplified,
color: currentShape.color || '#000000',
strokeWidth: isFiniteNum(currentShape.strokeWidth) ? Math.max(0, +currentShape.strokeWidth) : SHAPE_DEFAULTS.strokeWidth,
strokeOpacity: clamp01(currentShape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity)
};
}
}
}
@ -797,7 +970,7 @@ function initGridWidget(root, opts = {}) {
const loadedShapes = Array.isArray(data) ? data : data.shapes;
if (!Array.isArray(loadedShapes)) return;
shapes = sanitizeShapes(loadedShapes);
shapes = rebuildPathCaches(sanitizeShapes(loadedShapes));
doc = {
version: Number(data?.version) || 1,
@ -823,7 +996,7 @@ function initGridWidget(root, opts = {}) {
const payload = {
version: 1,
cellSize: cellSize,
shapes
shapes: stripCaches(shapes)
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);