mirror of
https://github.com/alesimula/Murine-launcher.git
synced 2026-10-28 09:43:42 +00:00
WEBSITE - WIP - icon shapes - shape stretching
This commit is contained in:
1 parent
d32841cbb0
commit
c6ab2ab2b4
3 files changed
+179
-52
No files matched your search
+1
-1
@@ -5,7 +5,7 @@ description: >-
|
||||
url: "https://alesimula.github.io"
|
||||
baseurl: ""
|
||||
|
||||
version: "1.0.20" #Website version - increase to invalidate cached resources
|
||||
version: "1.0.21" #Website version - increase to invalidate cached resources
|
||||
github_repo: "alesimula/Murine-launcher"
|
||||
icon_url: "https://raw.githubusercontent.com/alesimula/Murine-launcher/refs/heads/main/image/app_icon/icon_browser_fullsize.svg"
|
||||
icon_freeform_url: "https://raw.githubusercontent.com/alesimula/Murine-launcher/refs/heads/main/image/app_icon/icon_freeform.svg"
|
||||
|
||||
+168
-48
@@ -18,7 +18,9 @@
|
||||
// shows a margin around it and grows when something reaches further out.
|
||||
const view = { lo: -20, hi: 120 };
|
||||
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
||||
const fmt = (n) => String(+n.toFixed(2)); // +"…" drops trailing zeros and "-0"
|
||||
// Paths are written to 2 decimals; measuring a pivot raises that for a moment (see pivot()).
|
||||
let digits = 2;
|
||||
const fmt = (n) => String(+n.toFixed(digits)); // +"…" drops trailing zeros and "-0"
|
||||
const r2 = (n) => +n.toFixed(2);
|
||||
const dist = (a, b) => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
||||
@@ -129,27 +131,87 @@
|
||||
const MIRRORS = { none: [], h: [[-1, 1]], v: [[1, -1]], both: [[-1, 1], [1, -1], [-1, -1]] };
|
||||
// Rewrites every coordinate pair of one of our own paths (absolute M/L/C/Z, single spaces).
|
||||
const mapPath = (d, f) => d.replace(/(-?[\d.]+) (-?[\d.]+)/g, (_, x, y) => { const p = f(+x, +y); return fmt(p.x) + ' ' + fmt(p.y); });
|
||||
// Centroid of each FIXED path in its own 0-100 frame: the point its rot is drawn around.
|
||||
// Centroid of each FIXED path in its own 0-100 frame: the point its ang is drawn around.
|
||||
const FIXED_C = Object.fromEntries(Object.entries(FIXED).map(([k, d]) => [k, ringCentroid(flatten(d))]));
|
||||
function shapePath(sh) {
|
||||
// A shape is a normal shape (its points, or a FIXED path at scale a, angle ang and offset x, y), optionally
|
||||
// followed by a stretch st = [sxx, sxy, syy, cx, cy]: a symmetric matrix with determinant 1 applied around
|
||||
// (cx, cy). Only that relative stretch is kept; any uniform scale, rotation or move goes into the normal
|
||||
// shape (see applyWorld), so stretching x2 one way and then x2 the other leaves no stretch at all.
|
||||
const toWorld = (sh, p) => {
|
||||
if (!sh.st) return p;
|
||||
const [a, b, d, cx, cy] = sh.st, x = p.x - cx, y = p.y - cy;
|
||||
return { x: cx + a * x + b * y, y: cy + b * x + d * y };
|
||||
};
|
||||
const toBase = (sh, p) => {
|
||||
if (!sh.st) return p;
|
||||
const [a, b, d, cx, cy] = sh.st, det = a * d - b * b, x = p.x - cx, y = p.y - cy;
|
||||
return { x: cx + (d * x - b * y) / det, y: cy + (a * y - b * x) / det };
|
||||
};
|
||||
function basePath(sh) {
|
||||
if (!sh.fixed) return buildPath(sh.pts);
|
||||
if (!sh.rot) return mapPath(FIXED[sh.fixed], (x, y) => ({ x: x * sh.a + sh.x, y: y * sh.a + sh.y })); // exact, unturned
|
||||
const c = FIXED_C[sh.fixed], t = (sh.rot || 0) * Math.PI / 180, cos = Math.cos(t), sin = Math.sin(t);
|
||||
if (!sh.ang) return mapPath(FIXED[sh.fixed], (x, y) => ({ x: x * sh.a + sh.x, y: y * sh.a + sh.y })); // exact, unturned
|
||||
const c = FIXED_C[sh.fixed], t = sh.ang * Math.PI / 180, cos = Math.cos(t), sin = Math.sin(t);
|
||||
const ox = c.x * sh.a + sh.x, oy = c.y * sh.a + sh.y;
|
||||
return mapPath(FIXED[sh.fixed], (x, y) => {
|
||||
const dx = (x - c.x) * sh.a, dy = (y - c.y) * sh.a;
|
||||
return { x: ox + dx * cos - dy * sin, y: oy + dx * sin + dy * cos };
|
||||
});
|
||||
}
|
||||
// Curves are Béziers, which a stretch maps exactly: the same as stretching the drawn image.
|
||||
const shapePath = (sh) => (sh.st ? mapPath(basePath(sh), (x, y) => toWorld(sh, { x, y })) : basePath(sh));
|
||||
// A shape's own outline followed by its mirrored copies.
|
||||
const copies = (sh) => {
|
||||
const d = shapePath(sh);
|
||||
return [d, ...MIRRORS[sh.mirror].map(([sx, sy]) => mapPath(d, (x, y) => ({ x: sx < 0 ? 100 - x : x, y: sy < 0 ? 100 - y : y })))];
|
||||
};
|
||||
// Applies w -> A w + t to the shape as drawn, A = [a, b, c, d] (row by row, positive determinant). A times the
|
||||
// current stretch splits into rotation U, uniform scale k and a new stretch of determinant 1 (polar
|
||||
// decomposition); U and k go into the normal shape, where they're exact (corner sizes scale with k), and the
|
||||
// stretch is kept on its own. Nothing is rounded beyond 1e-6, so undoing a stretch by hand lands back exactly.
|
||||
const r6 = (n) => Math.round(n * 1e6) / 1e6;
|
||||
function applyWorld(sh, A, tx, ty) {
|
||||
const [a, b, d, cx, cy] = sh.st || [1, 0, 1, 0, 0];
|
||||
const m = [A[0] * a + A[1] * b, A[0] * b + A[1] * d, A[2] * a + A[3] * b, A[2] * b + A[3] * d];
|
||||
const th = Math.atan2(m[2] - m[1], m[0] + m[3]), cs = Math.cos(th), sn = Math.sin(th);
|
||||
const k = Math.sqrt(m[0] * m[3] - m[1] * m[2]);
|
||||
// P = U^T m / k is the new stretch in the shape's own turn; U P U^T puts it back in canvas directions.
|
||||
const p0 = (cs * m[0] + sn * m[2]) / k, p1 = (cs * m[1] + sn * m[3]) / k, p3 = (-sn * m[1] + cs * m[3]) / k;
|
||||
const sxx = cs * cs * p0 - 2 * cs * sn * p1 + sn * sn * p3, syy = sn * sn * p0 + 2 * cs * sn * p1 + cs * cs * p3;
|
||||
const sxy = cs * sn * (p0 - p3) + (cs * cs - sn * sn) * p1;
|
||||
const nx = A[0] * cx + A[1] * cy + tx, ny = A[2] * cx + A[3] * cy + ty;
|
||||
const T = (x, y) => ({ x: nx + k * (cs * (x - cx) - sn * (y - cy)), y: ny + k * (sn * (x - cx) + cs * (y - cy)) });
|
||||
// Back within 1e-4 of no stretch (say, an edge dragged back onto its grid line): no stretch.
|
||||
const none = Math.abs(sxx - 1) < 1e-4 && Math.abs(syy - 1) < 1e-4 && Math.abs(sxy) < 1e-4;
|
||||
const out = { ...sh };
|
||||
if (none) delete out.st; else out.st = [sxx, sxy, syy, nx, ny];
|
||||
if (sh.fixed) {
|
||||
const f = FIXED_C[sh.fixed], o = T(f.x * sh.a + sh.x, f.y * sh.a + sh.y), na = sh.a * k;
|
||||
return Object.assign(out, { a: r6(na), ang: r6(((((sh.ang || 0) + th * 180 / Math.PI) % 360) + 360) % 360), x: r6(o.x - f.x * na), y: r6(o.y - f.y * na) });
|
||||
}
|
||||
out.pts = sh.pts.map((p) => { const q = T(p.x, p.y); return { x: r6(q.x), y: r6(q.y), r: r6(p.r * k), s: p.s }; });
|
||||
return out;
|
||||
}
|
||||
// p -> (p - c) * k + t, for both kinds of shape (corner sizes scale too, so the look is kept).
|
||||
function transform(sh, k, cx, cy, tx, ty) {
|
||||
if (sh.fixed) return { ...sh, a: r2(sh.a * k), x: r2((sh.x - cx) * k + tx), y: r2((sh.y - cy) * k + ty) };
|
||||
return { ...sh, pts: sh.pts.map((p) => ({ x: r2((p.x - cx) * k + tx), y: r2((p.y - cy) * k + ty), r: r2(p.r * k), s: p.s })) };
|
||||
const transform = (sh, k, cx, cy, tx, ty) => applyWorld(sh, [k, 0, 0, k], tx - k * cx, ty - k * cy);
|
||||
// Stretches by k along one canvas axis ('x' or 'y'), keeping the line at `at` on that axis in place.
|
||||
const stretched = (sh, axis, k, at) => (axis === 'x' ? applyWorld(sh, [k, 0, 0, 1], at - k * at, 0) : applyWorld(sh, [1, 0, 0, k], 0, at - k * at));
|
||||
// The stretch as drawn: the unit direction of its long side and how many times longer that side is
|
||||
// than the short one. null without a stretch.
|
||||
// Two arrows pointing away from each other along the long side, starting r0 from the centre; the
|
||||
// stronger the stretch, the longer they get.
|
||||
function stretchArrows(info, r0) {
|
||||
if (!info) return '';
|
||||
const { ux, uy } = info, len = Math.min(16, 2 + 4 * Math.log2(info.ratio)), f = (n) => +n.toFixed(3);
|
||||
return [1, -1].map((s) => {
|
||||
const ax = s * ux * r0, ay = s * uy * r0, bx = s * ux * (r0 + len), by = s * uy * (r0 + len);
|
||||
const hx = -s * ux * 1.8, hy = -s * uy * 1.8, px = -uy * 1.3, py = ux * 1.3;
|
||||
return `M${f(ax)} ${f(ay)}L${f(bx)} ${f(by)}M${f(bx + hx + px)} ${f(by + hy + py)}L${f(bx)} ${f(by)}L${f(bx + hx - px)} ${f(by + hy - py)}`;
|
||||
}).join('');
|
||||
}
|
||||
function stretchInfo(sh) {
|
||||
if (!sh.st) return null;
|
||||
const [a, b, d] = sh.st, l1 = (a + d) / 2 + Math.hypot((a - d) / 2, b), th = Math.atan2(2 * b, a - d) / 2;
|
||||
return { ux: Math.cos(th), uy: Math.sin(th), ratio: (l1 * l1) / (a * d - b * b) };
|
||||
}
|
||||
// Scales the given shapes, mirrored copies included, until they touch the box. An axis with mirrored
|
||||
// copies is symmetric around the canvas centre, so it may only scale around 50, never shift.
|
||||
@@ -204,23 +266,22 @@
|
||||
// The point a shape rotates and centres around, as picked in the Move pill: the middle of its box, or its
|
||||
// centre of mass. Only the box one moves as the shape turns.
|
||||
let pivotMode = 'box';
|
||||
// Measured from the outline at 6 decimals: at 2, turning a shape and turning it back would pivot around
|
||||
// two slightly different points and not land exactly where it started.
|
||||
const pivot = (sh) => {
|
||||
if (pivotMode === 'mass') return centroid(sh);
|
||||
const b = measure(shapePath(sh));
|
||||
return { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
||||
digits = 6;
|
||||
try {
|
||||
if (pivotMode === 'mass') return centroid(sh);
|
||||
const b = measure(shapePath(sh));
|
||||
return { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
||||
} finally { digits = 2; }
|
||||
};
|
||||
// Turns a shape by deg (clockwise) around its pivot. rot keeps the running total for the pill;
|
||||
// FIXED shapes are drawn from it (around their own centroid, then shifted so the pivot stays put),
|
||||
// point shapes get their points turned.
|
||||
// Turns a shape by deg (clockwise) around its pivot; a stretch turns with it. rot only keeps the running
|
||||
// total for the rotation pill.
|
||||
function rotated(sh, deg) {
|
||||
const rot = r2((((sh.rot || 0) + deg) % 360 + 360) % 360) % 360;
|
||||
const c = pivot(sh), t = deg * Math.PI / 180, cos = Math.cos(t), sin = Math.sin(t);
|
||||
const turn = (x, y) => ({ x: c.x + (x - c.x) * cos - (y - c.y) * sin, y: c.y + (x - c.x) * sin + (y - c.y) * cos });
|
||||
if (sh.fixed) {
|
||||
const f = FIXED_C[sh.fixed], wx = f.x * sh.a + sh.x, wy = f.y * sh.a + sh.y, w = turn(wx, wy);
|
||||
return { ...sh, rot, x: r2(sh.x + w.x - wx), y: r2(sh.y + w.y - wy) };
|
||||
}
|
||||
return { ...sh, rot, pts: sh.pts.map((p) => { const q = turn(p.x, p.y); return { ...p, x: r2(q.x), y: r2(q.y) }; }) };
|
||||
return { ...applyWorld(sh, [cos, -sin, sin, cos], c.x - cos * c.x + sin * c.y, c.y - sin * c.x - cos * c.y), rot };
|
||||
}
|
||||
// Ramer–Douglas–Peucker: drops points closer than tol to the line between their neighbours.
|
||||
function simplify(pts, tol) {
|
||||
@@ -277,13 +338,24 @@
|
||||
function cleanShape(s) {
|
||||
const mirror = Object.hasOwn(MIRRORS, s?.mirror) ? s.mirror : 'none', rot = r2((((+(s?.rot ?? 0)) % 360) + 360) % 360) % 360;
|
||||
if (!Number.isFinite(rot)) return null;
|
||||
// The stretch: a symmetric matrix brought back to determinant 1, and its centre.
|
||||
let st;
|
||||
if (s?.st !== undefined) {
|
||||
const [a, b, d, cx, cy] = Array.isArray(s.st) ? s.st.map(Number) : [], det = a * d - b * b;
|
||||
if (!(a > 0 && det > 1e-6 && Math.abs(b) < 1e3 && a < 1e3 && Math.abs(cx) < 1e3 && Math.abs(cy) < 1e3)) return null;
|
||||
const k = Math.sqrt(det);
|
||||
st = [a / k, b / k, d / k, cx, cy];
|
||||
}
|
||||
const extra = st ? { st } : {};
|
||||
if (typeof s?.fixed === 'string' && Object.hasOwn(FIXED, s.fixed)) {
|
||||
const sh = { fixed: s.fixed, a: clamp(+(s.a ?? 1), 0.01, 20), x: clamp(+(s.x ?? 0), -1000, 1000), y: clamp(+(s.y ?? 0), -1000, 1000), mirror, rot };
|
||||
return [sh.a, sh.x, sh.y].every(Number.isFinite) ? sh : null;
|
||||
// Saves from before stretching drew the head at its pill angle (rot); now that's ang.
|
||||
const ang = ((+(s.ang ?? s.rot ?? 0) % 360) + 360) % 360;
|
||||
const sh = { fixed: s.fixed, a: clamp(+(s.a ?? 1), 0.01, 20), x: clamp(+(s.x ?? 0), -1000, 1000), y: clamp(+(s.y ?? 0), -1000, 1000), ang, mirror, rot, ...extra };
|
||||
return [sh.a, sh.x, sh.y, sh.ang].every(Number.isFinite) ? sh : null;
|
||||
}
|
||||
if (!Array.isArray(s?.pts) || s.pts.length < 3 || s.pts.length > 200) return null;
|
||||
const p = s.pts.map((q) => ({ x: clamp(+q?.x, -500, 600), y: clamp(+q?.y, -500, 600), r: clamp(+q?.r, 0, 500), s: clamp(+q?.s, 0, 1) }));
|
||||
return p.every((q) => [q.x, q.y, q.r, q.s].every(Number.isFinite)) ? { pts: p, mirror, rot } : null;
|
||||
return p.every((q) => [q.x, q.y, q.r, q.s].every(Number.isFinite)) ? { pts: p, mirror, rot, ...extra } : null;
|
||||
}
|
||||
function restore(o) {
|
||||
// Saves and links from before multiple shapes held one shape: { pts } or { pts, fixed }.
|
||||
@@ -372,12 +444,22 @@
|
||||
|
||||
let activeBox = null; // bounding box of the selected shape's own outline, for Move mode
|
||||
const boxCorners = (b) => [{ x: b.x, y: b.y }, { x: b.x + b.width, y: b.y }, { x: b.x + b.width, y: b.y + b.height }, { x: b.x, y: b.y + b.height }];
|
||||
// Move mode's 8 handles: the corners (top left, clockwise), then the middle of each side (top, right,
|
||||
// bottom, left). Corners resize keeping the proportions, sides stretch; each pulls against its opposite.
|
||||
const boxHandles = (b) => [...boxCorners(b), { x: b.x + b.width / 2, y: b.y }, { x: b.x + b.width, y: b.y + b.height / 2 },
|
||||
{ x: b.x + b.width / 2, y: b.y + b.height }, { x: b.x, y: b.y + b.height / 2 }];
|
||||
const opposite = (i) => (i < 4 ? (i + 2) % 4 : 4 + (i - 2) % 4);
|
||||
const sideAxis = (i) => (i === 5 || i === 7 ? 'x' : 'y'); // the axis a side handle stretches
|
||||
// Where the selected shape's points are on the canvas (they're stored before its stretch), and back.
|
||||
const wpt = (p) => toWorld(shapes[active], p);
|
||||
const bpt = (p) => toBase(shapes[active], p);
|
||||
const setPoint = (i, w) => { const q = bpt(w); pts[i].x = r6(q.x); pts[i].y = r6(q.y); };
|
||||
|
||||
function render() {
|
||||
const lines = shapes.map(copies); // per shape: own outline, then its mirrored copies
|
||||
if (!drag) {
|
||||
const bb = measure(lines.flat().join(' '));
|
||||
const xy = [bb.x, bb.y, bb.x + bb.width, bb.y + bb.height, ...(pts ? pts.flatMap((p) => [p.x, p.y]) : [])];
|
||||
const xy = [bb.x, bb.y, bb.x + bb.width, bb.y + bb.height, ...(pts ? pts.map(wpt).flatMap((p) => [p.x, p.y]) : [])];
|
||||
const lo = Math.floor(Math.min(-20, ...xy) - 6), hi = Math.ceil(Math.max(120, ...xy) + 6);
|
||||
if (lo !== view.lo || hi !== view.hi) {
|
||||
Object.assign(view, { lo, hi });
|
||||
@@ -396,7 +478,7 @@
|
||||
const layer = $('se-lines');
|
||||
resize(layer, strokes.length, () => el('path', {}));
|
||||
strokes.forEach((s, k) => { layer.children[k].setAttribute('d', s.d); layer.children[k].setAttribute('class', s.cls); });
|
||||
$('se-outline').setAttribute('points', pts && mode === 'edit' ? pts.map((p) => p.x + ',' + p.y).join(' ') : '');
|
||||
$('se-outline').setAttribute('points', pts && mode === 'edit' ? pts.map(wpt).map((p) => p.x + ',' + p.y).join(' ') : '');
|
||||
|
||||
// Neighbours whose rounding the selected corner is currently eating into.
|
||||
const squeezed = new Set();
|
||||
@@ -405,7 +487,7 @@
|
||||
for (const j of neighbours(sel)) if (now[j] < alone[j] - 0.01) squeezed.add(j);
|
||||
}
|
||||
|
||||
const handles = $('se-handles'), adders = $('se-adders'), shown = pts && mode === 'edit' ? pts : [];
|
||||
const handles = $('se-handles'), adders = $('se-adders'), shown = pts && mode === 'edit' ? pts.map(wpt) : [];
|
||||
const focused = document.activeElement?.dataset?.i !== undefined;
|
||||
resize(adders, shown.length, () => {
|
||||
const g = el('g', { class: 'se-add' }), t = el('title', {});
|
||||
@@ -419,7 +501,7 @@
|
||||
return g;
|
||||
});
|
||||
shown.forEach((p, i) => {
|
||||
const q = pts[(i + 1) % pts.length], m = lerp(p, q, 0.5), a = adders.children[i], h = handles.children[i];
|
||||
const q = shown[(i + 1) % shown.length], m = lerp(p, q, 0.5), a = adders.children[i], h = handles.children[i];
|
||||
a.dataset.add = i;
|
||||
a.style.display = !drag && dist(p, q) > 10 ? '' : 'none'; // grabAt() uses the same rule
|
||||
for (const c of a.querySelectorAll('circle')) { c.setAttribute('cx', m.x); c.setAttribute('cy', m.y); }
|
||||
@@ -438,16 +520,28 @@
|
||||
activeBox = measure(lines[active][0]);
|
||||
const r = $('se-move-box'), b = activeBox;
|
||||
r.setAttribute('x', b.x); r.setAttribute('y', b.y); r.setAttribute('width', b.width); r.setAttribute('height', b.height);
|
||||
boxCorners(b).forEach((c, i) => {
|
||||
boxHandles(b).forEach((c, i) => {
|
||||
const h = $('se-move').querySelectorAll('.se-resize')[i];
|
||||
h.setAttribute('x', c.x - 2); h.setAttribute('y', c.y - 2);
|
||||
h.setAttribute('x', c.x - h.getAttribute('width') / 2); h.setAttribute('y', c.y - h.getAttribute('height') / 2);
|
||||
h.classList.toggle('selected', i === pickedCorner);
|
||||
});
|
||||
}
|
||||
$('se-fit-tools').hidden = $('se-mirror').hidden = !moving;
|
||||
$('se-unstretch').hidden = !shapes[active].st;
|
||||
for (const b of $('se-mirror').querySelectorAll('[data-mirror]')) b.setAttribute('aria-pressed', b.dataset.mirror === shapes[active].mirror);
|
||||
for (const b of $('se-mirror').querySelectorAll('[data-pivot]')) b.setAttribute('aria-pressed', b.dataset.pivot === pivotMode);
|
||||
if (moving) { const c = pivot(shapes[active]); $('se-pivot-mark').setAttribute('transform', `translate(${c.x} ${c.y})`); }
|
||||
// The stretch marks: in Move mode on the pivot cross, past the end of its arms; in Edit mode from the
|
||||
// middle of the box, around a dot.
|
||||
const info = stretchInfo(shapes[active]);
|
||||
$('se-stretch-arrows').setAttribute('d', moving ? stretchArrows(info, 4) : '');
|
||||
const editMark = !moving && info && pts;
|
||||
$('se-stretch-mark').style.display = editMark ? '' : 'none';
|
||||
if (editMark) {
|
||||
const b = measure(lines[active][0]);
|
||||
$('se-stretch-mark').setAttribute('transform', `translate(${b.x + b.width / 2} ${b.y + b.height / 2})`);
|
||||
$('se-stretch-mark').querySelector('path').setAttribute('d', stretchArrows(info, 1.6));
|
||||
}
|
||||
|
||||
renderCards();
|
||||
|
||||
@@ -455,7 +549,7 @@
|
||||
const editable = !!pts;
|
||||
$('se-corner-set').disabled = !editable;
|
||||
// Top-left pill: the selected point (Edit mode) or the selected corner (Move mode).
|
||||
const at = moving ? (pickedCorner >= 0 && activeBox ? boxCorners(activeBox)[pickedCorner] : null) : (pts && sel >= 0 ? pts[sel] : null);
|
||||
const at = moving ? (pickedCorner >= 0 && activeBox ? boxHandles(activeBox)[pickedCorner] : null) : (pts && sel >= 0 ? wpt(pts[sel]) : null);
|
||||
$('se-point-pill').hidden = !at;
|
||||
// In its place, with no corner selected in Move mode: the shape's rotation.
|
||||
$('se-rot-pill').hidden = !moving || !!at;
|
||||
@@ -467,7 +561,10 @@
|
||||
}
|
||||
if (at) {
|
||||
$('se-x').value = fmt(at.x); $('se-y').value = fmt(at.y);
|
||||
$('se-point-pill').setAttribute('aria-label', moving ? 'Corner' : `Point ${sel + 1}`);
|
||||
$('se-point-pill').setAttribute('aria-label', moving ? (pickedCorner < 4 ? 'Corner' : 'Side') : `Point ${sel + 1}`);
|
||||
// A side handle only has the one coordinate it stretches.
|
||||
$('se-x').parentElement.hidden = moving && pickedCorner >= 4 && sideAxis(pickedCorner) !== 'x';
|
||||
$('se-y').parentElement.hidden = moving && pickedCorner >= 4 && sideAxis(pickedCorner) !== 'y';
|
||||
$('se-remove').hidden = moving;
|
||||
$('se-remove').disabled = moving || pts.length <= 3;
|
||||
}
|
||||
@@ -566,13 +663,14 @@
|
||||
let best = null, bestD = reach;
|
||||
if (mode === 'move') {
|
||||
if (!activeBox) return null;
|
||||
boxCorners(activeBox).forEach((c, i) => { const d = dist(at, c); if (d < bestD) { best = { resize: i }; bestD = d; } });
|
||||
boxHandles(activeBox).forEach((c, i) => { const d = dist(at, c); if (d < bestD) { best = { resize: i }; bestD = d; } });
|
||||
const b = activeBox;
|
||||
return best || (at.x >= b.x && at.x <= b.x + b.width && at.y >= b.y && at.y <= b.y + b.height ? { move: true } : null);
|
||||
}
|
||||
if (!pts) return null;
|
||||
pts.forEach((q, i) => {
|
||||
const next = pts[(i + 1) % pts.length], dq = dist(at, q);
|
||||
const wp = pts.map(wpt);
|
||||
wp.forEach((q, i) => {
|
||||
const next = wp[(i + 1) % wp.length], dq = dist(at, q);
|
||||
if (dq < bestD) { best = { point: i }; bestD = dq; }
|
||||
// A "+" only exists on edges longer than 10 units, and counts as a bit further than it is so a
|
||||
// point next to it still wins.
|
||||
@@ -619,17 +717,23 @@
|
||||
const at = toSvg(e);
|
||||
if (g.point !== undefined || g.add !== undefined) {
|
||||
if (g.add !== undefined) {
|
||||
const a = pts[g.add], m = lerp(a, pts[(g.add + 1) % pts.length], 0.5);
|
||||
pts.splice(g.add + 1, 0, { x: snap(m.x), y: snap(m.y), r: a.r, s: a.s });
|
||||
const a = pts[g.add], m = lerp(wpt(a), wpt(pts[(g.add + 1) % pts.length]), 0.5);
|
||||
pts.splice(g.add + 1, 0, { x: 0, y: 0, r: a.r, s: a.s });
|
||||
setPoint(g.add + 1, { x: snap(m.x), y: snap(m.y) });
|
||||
sel = g.add + 1;
|
||||
} else sel = g.point;
|
||||
drag = { kind: 'point', dx: pts[sel].x - at.x, dy: pts[sel].y - at.y }; // the point keeps its distance from the finger
|
||||
const w = wpt(pts[sel]);
|
||||
drag = { kind: 'point', dx: w.x - at.x, dy: w.y - at.y }; // the point keeps its distance from the finger
|
||||
} else if (g.move) {
|
||||
drag = { kind: 'move', from: at, orig: shapes[active] };
|
||||
} else {
|
||||
const c = boxCorners(activeBox), anchor = c[(g.resize + 2) % 4];
|
||||
drag = { kind: 'resize', anchor, d0: dist(anchor, c[g.resize]), orig: shapes[active] };
|
||||
pickedCorner = g.resize; // a tap selects the corner; a drag resizes too
|
||||
const c = boxHandles(activeBox), anchor = c[opposite(g.resize)];
|
||||
if (g.resize < 4) drag = { kind: 'resize', anchor, d0: dist(anchor, c[g.resize]), orig: shapes[active] };
|
||||
else {
|
||||
const axis = sideAxis(g.resize);
|
||||
drag = { kind: 'stretch', axis, at: anchor[axis], d0: c[g.resize][axis] - anchor[axis], from: at[axis], orig: shapes[active] };
|
||||
}
|
||||
pickedCorner = g.resize; // a tap selects the handle; a drag resizes too
|
||||
}
|
||||
svg.setPointerCapture(e.pointerId);
|
||||
e.preventDefault(); // this also stops the browser focusing the canvas, so do it here:
|
||||
@@ -640,8 +744,13 @@
|
||||
if (!drag) return;
|
||||
const p = toSvg(e);
|
||||
if (drag.kind === 'point') {
|
||||
pts[sel].x = snap(p.x + drag.dx);
|
||||
pts[sel].y = snap(p.y + drag.dy);
|
||||
setPoint(sel, { x: snap(p.x + drag.dx), y: snap(p.y + drag.dy) });
|
||||
} else if (drag.kind === 'stretch') {
|
||||
// The side moves by whole grid steps when snapping (like a moved shape), so dragging it back to where
|
||||
// it started gives exactly the shape it started as.
|
||||
const moved = p[drag.axis] - drag.from, k = Math.max(0.05, 1 + (grid.snap ? Math.round(moved / grid.step) * grid.step : moved) / drag.d0);
|
||||
shapes[active] = stretched(drag.orig, drag.axis, k, drag.at);
|
||||
sync();
|
||||
} else if (drag.kind === 'move') {
|
||||
const step = (v) => (grid.snap ? Math.round(v / grid.step) * grid.step : v);
|
||||
shapes[active] = transform(drag.orig, 1, 0, 0, step(p.x - drag.from.x), step(p.y - drag.from.y));
|
||||
@@ -693,8 +802,8 @@
|
||||
} else if (sel < 0) return;
|
||||
else if (mv) {
|
||||
checkpoint();
|
||||
pts[sel].x = snap(pts[sel].x + mv[0] * step);
|
||||
pts[sel].y = snap(pts[sel].y + mv[1] * step);
|
||||
const w = wpt(pts[sel]);
|
||||
setPoint(sel, { x: snap(w.x + mv[0] * step), y: snap(w.y + mv[1] * step) });
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (pts.length <= 3) return shake();
|
||||
checkpoint();
|
||||
@@ -728,6 +837,16 @@
|
||||
shapes[active] = transform(sh, 1, c.x, c.y, tx, ty);
|
||||
sync(); render();
|
||||
};
|
||||
// Shrinks the shape along the long side of its stretch until there's none left, around the pivot.
|
||||
$('se-unstretch').onclick = () => {
|
||||
const sh = shapes[active], info = stretchInfo(sh);
|
||||
if (!info) return;
|
||||
const c = pivot(sh), f = 1 / info.ratio - 1, { ux, uy } = info;
|
||||
const A = [1 + f * ux * ux, f * ux * uy, f * ux * uy, 1 + f * uy * uy];
|
||||
checkpoint();
|
||||
shapes[active] = applyWorld(sh, A, c.x - A[0] * c.x - A[1] * c.y, c.y - A[2] * c.x - A[3] * c.y);
|
||||
sync(); render();
|
||||
};
|
||||
$('se-mirror').addEventListener('click', (e) => {
|
||||
const p = e.target.closest('[data-pivot]');
|
||||
if (p) { pivotMode = p.dataset.pivot; render(); }
|
||||
@@ -762,15 +881,16 @@
|
||||
const v = clamp(+e.target.value, view.lo, view.hi);
|
||||
if (e.target.value === '' || !Number.isFinite(v)) return render(); // puts the current value back
|
||||
if (mode === 'move' && pickedCorner >= 0 && activeBox) {
|
||||
// A corner moves with the opposite one fixed and the proportions kept, so the other coordinate follows.
|
||||
const c = boxCorners(activeBox), a = c[(pickedCorner + 2) % 4], k0 = (v - a[k]) / (c[pickedCorner][k] - a[k]);
|
||||
// A corner moves with the opposite one fixed and the proportions kept, so the other coordinate follows;
|
||||
// a side moves with the opposite side fixed, stretching the shape.
|
||||
const c = boxHandles(activeBox), a = c[opposite(pickedCorner)], k0 = (v - a[k]) / (c[pickedCorner][k] - a[k]);
|
||||
if (!(k0 > 0.05)) return render(); // not past the opposite corner, nor down to nothing
|
||||
checkpoint();
|
||||
shapes[active] = transform(shapes[active], k0, a.x, a.y, a.x, a.y);
|
||||
shapes[active] = pickedCorner < 4 ? transform(shapes[active], k0, a.x, a.y, a.x, a.y) : stretched(shapes[active], k, k0, a[k]);
|
||||
sync();
|
||||
} else if (pts && sel >= 0) {
|
||||
checkpoint();
|
||||
pts[sel][k] = v;
|
||||
setPoint(sel, { ...wpt(pts[sel]), [k]: v });
|
||||
} else return;
|
||||
render();
|
||||
})
|
||||
|
||||
+10
-3
@@ -145,9 +145,9 @@ noindex: true # and out of search engines
|
||||
.se-mirror button[aria-pressed="true"] { background: var(--md-primary-container); color: var(--md-on-primary-container); }
|
||||
.se-mirror svg { width: 20px; height: 20px; }
|
||||
.se-pivot { display: flex; gap: 2px; padding-left: 5px; margin-left: 3px; border-left: 1px solid var(--md-outline-variant); }
|
||||
.se-pivot-mark { pointer-events: none; fill: none; stroke: var(--md-primary); stroke-width: .6; stroke-linecap: round; }
|
||||
.se-pivot-mark { pointer-events: none; fill: none; stroke: var(--md-primary); stroke-width: .6; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.se-stage-btn:hover, .se-mirror button:hover { background: var(--md-surface-container-high); }
|
||||
.se-stage-tools[hidden], .se-mirror[hidden] { display: none; }
|
||||
.se-stage-tools[hidden], .se-mirror[hidden], .se-stage-btn[hidden] { display: none; }
|
||||
.se-stage-btn svg { width: 22px; height: 22px; }
|
||||
/* Shape cards under the canvas. */
|
||||
.se-cards { display: flex; gap: 10px; overflow-x: auto; padding: 12px 2px 6px; }
|
||||
@@ -197,6 +197,7 @@ noindex: true # and out of search engines
|
||||
<rect class="se-bounds" x="0" y="0" width="100" height="100"/>
|
||||
<g id="se-lines"></g>
|
||||
<polygon id="se-outline" class="se-outline"/>
|
||||
<g id="se-stretch-mark" class="se-pivot-mark" style="display: none"><circle r=".8" fill="var(--md-primary)" stroke="none"/><path/></g>
|
||||
<g id="se-adders"></g>
|
||||
<g id="se-handles"></g>
|
||||
<g id="se-move" style="display: none">
|
||||
@@ -206,13 +207,19 @@ noindex: true # and out of search engines
|
||||
<rect class="se-resize" width="4" height="4" style="cursor: nesw-resize"/>
|
||||
<rect class="se-resize" width="4" height="4" style="cursor: nwse-resize"/>
|
||||
<rect class="se-resize" width="4" height="4" style="cursor: nesw-resize"/>
|
||||
<g id="se-pivot-mark" class="se-pivot-mark"><circle r="1.8"/><path d="M0 -4V-2.2M0 2.2V4M-4 0H-2.2M2.2 0H4"/><circle r=".5" fill="var(--md-primary)" stroke="none"/></g>
|
||||
<!-- sides, in the same order: top, right, bottom, left -->
|
||||
<rect class="se-resize" width="6" height="2.2" rx="1.1" style="cursor: ns-resize"/>
|
||||
<rect class="se-resize" width="2.2" height="6" rx="1.1" style="cursor: ew-resize"/>
|
||||
<rect class="se-resize" width="6" height="2.2" rx="1.1" style="cursor: ns-resize"/>
|
||||
<rect class="se-resize" width="2.2" height="6" rx="1.1" style="cursor: ew-resize"/>
|
||||
<g id="se-pivot-mark" class="se-pivot-mark"><circle r="1.8"/><path d="M0 -4V-2.2M0 2.2V4M-4 0H-2.2M2.2 0H4"/><circle r=".5" fill="var(--md-primary)" stroke="none"/><path id="se-stretch-arrows"/></g>
|
||||
</g>
|
||||
</svg>
|
||||
<div id="se-fit-tools" class="se-stage-tools" hidden>
|
||||
<button id="se-fit-shape" class="se-stage-btn" type="button" title="Fit this shape to the box" aria-label="Fit this shape to the box"><span class="material-symbols-outlined" aria-hidden="true"></span></button>
|
||||
<button id="se-fit-quarter" class="se-stage-btn" type="button" title="Fit this shape into the top-left quarter" aria-label="Fit this shape into the top-left quarter"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-dasharray="3 2"/><rect x="5.5" y="5.5" width="6.5" height="6.5" rx="1.5" fill="currentColor"/></svg></button>
|
||||
<button id="se-center" class="se-stage-btn" type="button" title="Centre this shape" aria-label="Centre this shape"><span class="material-symbols-outlined" aria-hidden="true"></span></button>
|
||||
<button id="se-unstretch" class="se-stage-btn" type="button" title="Remove the stretch (shrinks the long side)" aria-label="Remove the stretch" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v18" stroke-dasharray="2.5 2.5"/><path d="M3 12h6M6.5 9.5 9 12l-2.5 2.5M21 12h-6M17.5 9.5 15 12l2.5 2.5"/></svg></button>
|
||||
</div>
|
||||
<div id="se-mirror" class="se-mirror" role="group" aria-label="Mirror" hidden>
|
||||
<button type="button" data-mirror="none" title="No mirroring" aria-label="No mirroring"><svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l6 6-6 6z"/></svg></button>
|
||||
|
||||
Reference in new issue
Block a user