// Pure geometry for the Latent Size 🆎 node: snapping, ratio math, drag // mapping, and readouts. DOM-free so node:test covers it // (tests/resolution_math.test.mjs); js/resolution/index.js wires it to the // panel. export const DIM_MIN = 64; export const DIM_MAX = 8192; export const SNAP_STEPS = [8, 16, 32, 64]; export const DEFAULT_SNAP = 32; export function clampDim(value) { const number = Math.round(Number(value)); if (!Number.isFinite(number)) return DIM_MIN; return Math.max(DIM_MIN, Math.min(DIM_MAX, number)); } // Round to the step without ever snapping below one step, so a small // dimension snaps UP instead of collapsing to zero. export function snapDim(value, step = DEFAULT_SNAP) { const s = Number(step) > 0 ? Number(step) : 1; return clampDim(Math.max(s, Math.round(Number(value) / s) * s)); } function gcd(a, b) { let x = Math.abs(Math.round(a)); let y = Math.abs(Math.round(b)); while (y) [x, y] = [y, x % y]; return x || 1; } function lcm(a, b) { return (a * b) / gcd(a, b); } // One snap system for ratio-locked sizes: with width = rx*k and // height = ry*k, both dimensions land on the snap grid exactly when k is a // multiple of lcm(step/gcd(step,rx), step/gcd(step,ry)). 7:4 at /32 gives // stride 32 (k=192 -> 1344x768); 16:9 at /16 gives stride 16 (k=80 -> // 1280x720). The ratio stays mathematically exact AND on-grid - no second // snapping pass, no drift. export function ratioStride(ratio, step) { const s = Number(step) > 0 ? Math.round(step) : 1; if (s <= 1) return 1; return lcm(s / gcd(s, ratio.x), s / gcd(s, ratio.y)); } // Some ratio/step pairs only align at coarse strides (16:9 at /32 moves // width in 512px jumps). The panel warns past this per-axis step so the // user can pick a finer snap instead of thinking the drag is stuck. export function strideWarning(ratio, step, threshold = 256) { const stride = ratioStride(ratio, step); return stride * Math.max(ratio.x, ratio.y) > threshold; } // Reduced integer ratio of a size, e.g. 1344x768 -> {x: 7, y: 4}. export function reduceRatio(width, height) { const d = gcd(width, height); return { x: Math.round(width / d), y: Math.round(height / d) }; } // Human ratio label: exact when the reduced terms stay small, otherwise a // one-decimal approximation ("~1.78:1" / "~1:1.42") that reads better than // a monster fraction like 341:192. export function ratioLabel(width, height) { const { x, y } = reduceRatio(width, height); if (x <= 32 && y <= 32) return `${x}:${y}`; const value = width / height; return value >= 1 ? `~${(Math.round(value * 100) / 100)}:1` : `~1:${(Math.round((1 / value) * 100) / 100)}`; } export function megapixels(width, height) { return Math.round((width * height) / 10000) / 100; } export function readout(width, height) { return `${width} × ${height} · ${megapixels(width, height).toFixed(2)} MP · ${ratioLabel(width, height)}`; } // Largest w x h with the given aspect that fits a box - the chip and stage // thumbnails. export function fitRectInBox(aspectW, aspectH, boxW, boxH) { const scale = Math.min(boxW / aspectW, boxH / aspectH); return { w: Math.max(1, aspectW * scale), h: Math.max(1, aspectH * scale) }; } // A size on an exact integer ratio nearest a pixel target for the driving // axis: width = rx*k, height = ry*k for whole k, so the ratio never drifts // however far the drag travels or however coarse the snap. When snapping, // k moves in ratioStride multiples so BOTH dimensions stay on the grid. export function ratioSizeForAxis(ratio, axis, target, step = 1) { const r = axis === "height" ? ratio.y : ratio.x; const kStep = ratioStride(ratio, step); const minK = Math.ceil(Math.max(DIM_MIN / ratio.x, DIM_MIN / ratio.y) / kStep) * kStep; const maxK = Math.floor(Math.min(DIM_MAX / ratio.x, DIM_MAX / ratio.y) / kStep) * kStep; if (minK > maxK) { // Extreme ratios and coarse grids may have no exact solution. Keep // dimensions legal and use the nearest bounded approximation. const factor = Math.min(DIM_MAX / Math.max(ratio.x, ratio.y), Math.max(DIM_MIN / Math.min(ratio.x, ratio.y), target / r)); return { width: snapDim(ratio.x * factor, step), height: snapDim(ratio.y * factor, step) }; } const k = Math.max(minK, Math.min(maxK, Math.round(target / r / kStep) * kStep)); const width = ratio.x * k; const height = ratio.y * k; return { width, height }; } // Nearest size on the ratio to a free wxh point, judged by area, driven by // the longer axis for stability. export function ratioSizeNear(ratio, width, height, step = 1) { const byW = ratioSizeForAxis(ratio, "width", width, step); const byH = ratioSizeForAxis(ratio, "height", height, step); const errW = Math.abs(byW.width - width) + Math.abs(byW.height - height); const errH = Math.abs(byH.width - width) + Math.abs(byH.height - height); return errW <= errH ? byW : byH; } // ---------- the stage drag model ---------- // // The stage shows the current rectangle centered in a fixed box at a live // scale (px per CSS px). A drag grabs an edge or corner and moves that // edge; the rectangle stays centered, so moving the right edge out grows // width symmetrically. deltas are CSS px; scale converts to image px. // // handle: "e" | "w" grow width; "n" | "s" grow height; // "ne" "nw" "se" "sw" grow both. // modifiers: snap (default true; false = 1px freedom), // lockRatio (true = keep the grab-time ratio exactly). export function stageScale(width, height, boxW, boxH) { // Fit with fixed headroom for the handles, corner hints, and the label // (the design's 90px horizontal / 60px vertical margins), never below a // sliver on a tiny stage. const usableW = Math.max(40, boxW - 90); const usableH = Math.max(40, boxH - 60); return Math.max(0.002, Math.min(usableW / width, usableH / height)); } const HANDLE_AXES = { e: { x: 1, y: 0 }, w: { x: 1, y: 0 }, n: { x: 0, y: 1 }, s: { x: 0, y: 1 }, ne: { x: 1, y: 1 }, nw: { x: 1, y: 1 }, se: { x: 1, y: 1 }, sw: { x: 1, y: 1 }, }; // Outward-positive delta for a handle: dragging the east edge right, the // west edge left, the north edge up, or the south edge down all GROW. export function outwardDelta(handle, dx, dy) { const sx = handle.includes("w") ? -1 : handle.includes("e") ? 1 : 0; const sy = handle.includes("n") ? -1 : handle.includes("s") ? 1 : 0; return { ox: dx * sx, oy: dy * sy }; } export function dragResize({ handle, startWidth, startHeight, dx, dy, scale, snap = DEFAULT_SNAP, snapOn = true, lockRatio = false, }) { const axes = HANDLE_AXES[handle] ?? { x: 1, y: 1 }; const { ox, oy } = outwardDelta(handle, dx, dy); // Centered rectangle: an edge moving out d grows the dimension 2d. const growX = axes.x ? (ox * 2) / scale : 0; const growY = axes.y ? (oy * 2) / scale : 0; const step = snapOn ? snap : 1; if (lockRatio) { const ratio = reduceRatio(startWidth, startHeight); // Exact-k locking is only pleasant when the ratio aligns to the grid // at a workable stride. Custom ratios (a snapped 88:115, say) would // jump hundreds of pixels per step, so they scale proportionally at // snap granularity instead - fluid, ratio approximately kept. if (snapOn && strideWarning(ratio, snap)) { const factor = Math.max( 0.05, 1 + (growX / startWidth + growY / startHeight) / 2, ); return { width: snapDim(startWidth * factor, step), height: snapDim(startHeight * factor, step), }; } // The dominant travel axis drives; the other follows the exact ratio. const useWidth = axes.x && (!axes.y || Math.abs(growX) >= Math.abs(growY)); return useWidth ? ratioSizeForAxis(ratio, "width", startWidth + growX, step) : ratioSizeForAxis(ratio, "height", startHeight + growY, step); } return { width: axes.x ? snapDim(startWidth + growX, step) : snapDim(startWidth, 1), height: axes.y ? snapDim(startHeight + growY, step) : snapDim(startHeight, 1), }; } // ---------- chip policy: SHAPE x BUDGET ---------- // Clicking a ratio chip changes the SHAPE while keeping the pixel budget // and the current orientation: 3:2 clicked while portrait yields 2:3, and // the area stays as close to the current area as the grid allows. From the // 1024x1024 default this lands exactly on the real-world sizes: 7:4 at /32 // gives 1344x768, 16:9 at /16 gives 1280x720. // portrait defaults to the size's own orientation; a square caller passes // the orientation it wants, since a square has none to read. export function applyRatioAtBudget(width, height, ratio, step = DEFAULT_SNAP, portrait = height > width) { const flipped = portrait !== ratio.y > ratio.x && ratio.x !== ratio.y; const shaped = flipped ? { x: ratio.y, y: ratio.x } : { x: ratio.x, y: ratio.y }; const area = width * height; const idealWidth = Math.sqrt(area * (shaped.x / shaped.y)); const kStep = ratioStride(shaped, step); const candidates = [ ratioSizeForAxis(shaped, "width", idealWidth, step), ratioSizeForAxis(shaped, "width", idealWidth + kStep * shaped.x, step), ratioSizeForAxis(shaped, "width", idealWidth - kStep * shaped.x, step), ]; let best = candidates[0]; let bestError = Infinity; for (const size of candidates) { const error = Math.abs(size.width * size.height - area); if (error < bestError) { bestError = error; best = size; } } return best; } // How the current size relates to a chip's ratio: "exact", "near" (within // tolerance, rendered outlined), or null. Orientation-blind - a portrait // 2:3 lights the 3:2 chip with flipped:true. export function chipMatch(width, height, ratio, tolerance = 0.015) { const current = reduceRatio(width, height); const flippedMatch = current.x === ratio.y && current.y === ratio.x; if ((current.x === ratio.x && current.y === ratio.y) || (flippedMatch && ratio.x !== ratio.y)) { return { state: "exact", flipped: flippedMatch && ratio.x !== ratio.y }; } const value = width / height; const chipValue = ratio.x / ratio.y; const drift = Math.abs(Math.log(value / chipValue)); const driftFlipped = Math.abs(Math.log(value / (ratio.y / ratio.x))); if (drift <= tolerance) return { state: "near", flipped: false }; if (ratio.x !== ratio.y && driftFlipped <= tolerance) return { state: "near", flipped: true }; return null; } // Parse the gear menu's editable rail text ("1:1, 4:3, 7:4") into ratio // objects; junk entries drop, duplicates collapse, and an empty result // falls back to the shipped defaults so the rail can never vanish. export const DEFAULT_RAIL = "1:1, 4:3, 3:2, 16:9, 7:4, 2:1"; export function parseRail(text) { const seen = new Set(); const rail = []; for (const token of String(text ?? "").split(",")) { const match = token.trim().match(/^(\d{1,3})\s*[:x]\s*(\d{1,3})$/i); if (!match) continue; const x = Number(match[1]); const y = Number(match[2]); if (!x || !y) continue; const d = gcd(x, y); const key = `${x / d}:${y / d}`; if (seen.has(key)) continue; seen.add(key); rail.push({ x: x / d, y: y / d, label: `${x}:${y}` }); } return rail.length ? rail : parseRail(DEFAULT_RAIL); } // The rail shows every ratio in the CANVAS's current orientation - a // portrait canvas renders the 3:2 chip as "2:3" with a portrait glyph - // so what you see is exactly what a click gives you. export function orientRatio(ratio, portrait) { const long = Math.max(ratio.x, ratio.y), short = Math.min(ratio.x, ratio.y); const x = portrait ? short : long, y = portrait ? long : short; return { x, y, label: `${x}:${y}` }; } // ---------- quick sizes ---------- // The per-ratio quick list, anchored to the canonical square areas // (512^2, 768^2, 1024^2, 1280^2, 1536^2, 1792^2, 2048^2): each rung is // the nearest on-ratio, on-grid size to that pixel budget. Generated, // never curated - so 1:1 at /64 IS the familiar 512/768/1024/1280/1536/ // 1792/2048 ladder, and every other ratio gets the equivalent rungs // (16:9 at /16 includes 1280x720, 7:4 at /32 includes 1344x768). export const LADDER_EDGES = [512, 768, 1024, 1280, 1536, 1792, 2048]; export function quickSizes(width, height, step, count = 8) { const ratio = reduceRatio(width, height); const kStep = ratioStride(ratio, step); const area = ratio.x * ratio.y; const seen = new Set(); const list = []; for (const edge of LADDER_EDGES) { const idealK = Math.sqrt((edge * edge) / area); const k = Math.max(kStep, Math.round(idealK / kStep) * kStep); const w = ratio.x * k; const h = ratio.y * k; if (w < DIM_MIN || h < DIM_MIN || w > DIM_MAX || h > DIM_MAX) continue; const key = `${w}x${h}`; if (seen.has(key)) continue; seen.add(key); list.push({ width: w, height: h }); } return list .sort((a, b) => a.width * a.height - b.width * b.height) .slice(0, count); } // Every ratio's canonical default - the ~1MP rung, exactly what a chip // click lands on (1:1 -> 1024x1024, 16:9 at /16 -> 1280x720, 7:4 at /32 // -> 1344x768). Same idea as Pixaroma's hand-written per-ratio default // table, derived instead of curated. export function defaultSizeForRatio(ratio, step) { return applyRatioAtBudget(1024, 1024, ratio, step, ratio.y > ratio.x); } // ---------- megapixel ladder ---------- export const MP_LADDER = [0.25, 0.5, 1, 1.5, 2, 3, 4]; // Walk the rung ladder from wherever the current area sits: nearest rung // first (by log distance), then one step in the asked direction. export function stepMegapixels(currentMp, direction) { let nearest = 0; let bestDistance = Infinity; MP_LADDER.forEach((rung, index) => { const distance = Math.abs(Math.log(Math.max(0.01, currentMp) / rung)); if (distance < bestDistance) { bestDistance = distance; nearest = index; } }); const next = Math.max(0, Math.min(MP_LADDER.length - 1, nearest + Math.sign(direction))); return MP_LADDER[next]; } // ---------- pins (user presets) ---------- export const MAX_PINS = 12; export function parsePins(raw) { try { const data = JSON.parse(raw); if (!Array.isArray(data)) return []; return data .filter((pin) => pin && Number.isFinite(pin.w) && Number.isFinite(pin.h)) .map((pin) => ({ w: clampDim(pin.w), h: clampDim(pin.h) })) .slice(0, MAX_PINS); } catch { return []; } } export function serializePins(pins) { return JSON.stringify(pins); } // Star toggles: pinning an already-pinned size removes it. export function togglePin(pins, width, height) { const list = pins.filter((pin) => !(pin.w === width && pin.h === height)); if (list.length === pins.length) list.unshift({ w: clampDim(width), h: clampDim(height) }); return list.slice(0, MAX_PINS); } export function pinLabel(pin) { return `${pin.w}×${pin.h}`; } // ---------- misc panel helpers ---------- export function offGrid(width, height, step) { const s = Number(step) > 0 ? step : 1; return width % s !== 0 || height % s !== 0; } // The W/H boxes reuse the pack's scrub grammar: pointer travel past a dead // zone moves the value in snap-step increments. export function scrubDim(startValue, deltaX, step, pixelsPerStep = 4, deadZone = 3) { if (Math.abs(deltaX) <= deadZone) return clampDim(startValue); const travel = deltaX - Math.sign(deltaX) * deadZone; const steps = Math.round(travel / pixelsPerStep); return snapDim(startValue + steps * (Number(step) > 0 ? step : 1), Number(step) > 0 ? step : 1); } // ---------- megapixel retarget ---------- // Scale a size to a megapixel target. Exact-ratio version of sqrt scaling: // walk the reduced ratio's integer k to the area nearest the target, so // 2/3-ish sizes do not drift ratio when retargeted. export function sizeForMegapixels(width, height, targetMp, step = DEFAULT_SNAP) { const ratio = reduceRatio(width, height); const targetPixels = Math.max(0.05, Number(targetMp) || 0.05) * 1_000_000; const idealWidth = Math.sqrt(targetPixels * (ratio.x / ratio.y)); const candidates = [ ratioSizeForAxis(ratio, "width", idealWidth, step), ratioSizeForAxis(ratio, "width", idealWidth + step, step), ratioSizeForAxis(ratio, "width", idealWidth - step, step), ]; let best = candidates[0]; let bestError = Infinity; for (const size of candidates) { const error = Math.abs(size.width * size.height - targetPixels); if (error < bestError) { bestError = error; best = size; } } return best; }