Catmull-Rom smoothing!
This commit is contained in:
parent
63f3ad1394
commit
e4b59b124e
1 changed files with 186 additions and 13 deletions
|
|
@ -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 : []);
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue