From e4b59b124e36611eaeabba1d59a1562598dfab5d Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Wed, 7 Jan 2026 09:36:35 -0600 Subject: [PATCH] Catmull-Rom smoothing! --- inventory/static/js/components/draw.js | 199 +++++++++++++++++++++++-- 1 file changed, 186 insertions(+), 13 deletions(-) diff --git a/inventory/static/js/components/draw.js b/inventory/static/js/components/draw.js index 5e2a7dc..7342ce9 100644 --- a/inventory/static/js/components/draw.js +++ b/inventory/static/js/components/draw.js @@ -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);