// DasuMae Frame — layout engine (pure geometry, no DOM, no dependencies). // // Single source of truth for where the screenshot(s) are drawn on the export // canvas. The live preview and the export renderer both call the same // functions here so what you see is exactly what you get. // // Model // ----- // 1. The canvas is canvasW × canvasH device pixels (an export preset size, // e.g. 1600×900, or the Original fit size). // 2. Padding insets the canvas on all four sides, leaving the *content rect* // — the area the image(s) are allowed to occupy. Padding per side is // pad = (paddingPct / 100) * (min(canvasW, canvasH) / 2) // i.e. paddingPct is a fraction of the shorter side split across the two // opposite edges. It is clamped so the content rect never collapses below // MIN_CONTENT px on either axis. // 3. The content rect is split into *cells*: 1 cell in single mode (today's // behaviour, byte-for-byte unchanged), or 2 cells in 2-up ("duo") mode — // columns (left/right) or rows (top/bottom) — separated by a gap (px at // export scale). The gap lives only *between* cells; the padding still // sits outside, so the outer edges are never pushed in by the gap and the // gap is never doubled at the canvas edge. See cellRects(). // 4. Each cell runs today's per-image math independently (placeInRect): the // image is fitted into the cell rect with "contain" semantics // (baseScale = min(cellW/imageW, cellH/imageH)), then multiplied by the // user scale. drawnW/H = imageW/H * baseScale * scale. // 5. The image is centred in its cell, then shifted by the drag offset // (offsetX, offsetY) in canvas pixels. The offset is clamped to that // cell (see below) and the *clamped* value is returned. // // Offset clamp rule (documented, one rule for both regimes) // --------------------------------------------------------- // On each axis independently let // limit = |contentSize - drawnSize| / 2 // and clamp the offset to [-limit, +limit]. // • Zoomed in (drawnSize >= contentSize): limit = (drawn - content)/2. // The image always fully covers the content rect; an edge can travel up // to the matching content edge but never past it, so dragging never // exposes a background gap *inside* the content area. // • Fitted (drawnSize < contentSize): limit = (content - drawn)/2. // There is intended padding slack; the image may slide until one of its // edges meets a content edge, but no further — so it can be repositioned // without being flung out and leaving an absurd gap. // When drawnSize == contentSize the limit is 0 (no travel), which is correct. export const MIN_CONTENT = 1; // px — content rect never smaller than this /** Clamp a number to [lo, hi]. */ export function clamp(v, lo, hi) { if (hi < lo) return lo; return v < lo ? lo : v > hi ? hi : v; } /** * Content rect (the padded area the image lives in) for a canvas. * @returns {{x:number,y:number,w:number,h:number,pad:number}} */ export function contentRect(canvasW, canvasH, paddingPct) { const W = Math.max(0, canvasW); const H = Math.max(0, canvasH); const pct = clamp(paddingPct, 0, 100); let pad = (pct / 100) * (Math.min(W, H) / 2); // Keep at least MIN_CONTENT on each axis. const maxPad = Math.max(0, (Math.min(W, H) - MIN_CONTENT) / 2); pad = clamp(pad, 0, maxPad); return { x: pad, y: pad, w: W - 2 * pad, h: H - 2 * pad, pad }; } /** "contain" fit scale of an image into a box. */ export function fitScale(boxW, boxH, imageW, imageH) { if (imageW <= 0 || imageH <= 0 || boxW <= 0 || boxH <= 0) return 0; return Math.min(boxW / imageW, boxH / imageH); } /** Per-axis max offset magnitude for the clamp rule above. */ export function offsetLimit(contentSize, drawnSize) { return Math.abs(contentSize - drawnSize) / 2; } /** * Place one image inside an arbitrary rectangle ("contain" fit + user scale + * clamped drag offset). This is today's per-image math, factored out so a * single cell (single mode) and each of two cells (2-up) share exactly the * same geometry — the clamp is always relative to the rect it is given. * * @param {{x:number,y:number,w:number,h:number}} rect the cell the image lives in * @param {number} imageW source image width (px) * @param {number} imageH source image height (px) * @param {number} [scale=1] user scale multiplier (>0), applied on top of fit * @param {number} [offsetX=0] drag offset X (px, before clamp) * @param {number} [offsetY=0] drag offset Y (px, before clamp) * @returns {{ * image:{x:number,y:number,w:number,h:number}, * baseScale:number, effectiveScale:number, * offset:{x:number,y:number}, limit:{x:number,y:number} * }} */ export function placeInRect(rect, imageW, imageH, scale, offsetX, offsetY) { const iW = Math.max(0, imageW || 0); const iH = Math.max(0, imageH || 0); const s = scale == null ? 1 : Math.max(0, scale); const oX = offsetX || 0; const oY = offsetY || 0; // Degenerate image: nothing to draw (anchored at the cell's top-left). if (iW === 0 || iH === 0) { return { image: { x: rect.x, y: rect.y, w: 0, h: 0 }, baseScale: 0, effectiveScale: 0, offset: { x: 0, y: 0 }, limit: { x: 0, y: 0 }, }; } const baseScale = fitScale(rect.w, rect.h, iW, iH); const effectiveScale = baseScale * s; const drawnW = iW * effectiveScale; const drawnH = iH * effectiveScale; const limitX = offsetLimit(rect.w, drawnW); const limitY = offsetLimit(rect.h, drawnH); const clampedX = clamp(oX, -limitX, limitX); const clampedY = clamp(oY, -limitY, limitY); // Centre of the cell. const cx = rect.x + rect.w / 2; const cy = rect.y + rect.h / 2; return { image: { x: cx - drawnW / 2 + clampedX, y: cy - drawnH / 2 + clampedY, w: drawnW, h: drawnH, }, baseScale, effectiveScale, offset: { x: clampedX, y: clampedY }, limit: { x: limitX, y: limitY }, }; } /** * Split a content rect into cells. * • single → one cell equal to the whole content rect. * • duo / columns → left + right cells, each (contentW − gap)/2 wide, * full content height, with `gap` px between them. * • duo / rows → top + bottom cells, each (contentH − gap)/2 tall, * full content width, with `gap` px between them. * The gap sits ONLY between the two cells; the padding stays outside, so the * outer edges are the content-rect edges (never inset by the gap) and the gap * is never doubled at the canvas edge. The gap is clamped so neither cell * collapses below MIN_CONTENT on the split axis. * * @returns {Array<{x:number,y:number,w:number,h:number}>} */ export function cellRects(content, mode = 'single', orientation = 'columns', gap = 0) { if (mode !== 'duo') { return [{ x: content.x, y: content.y, w: content.w, h: content.h }]; } const g0 = Math.max(0, gap || 0); if (orientation === 'rows') { const g = clamp(g0, 0, Math.max(0, content.h - 2 * MIN_CONTENT)); const cellH = (content.h - g) / 2; return [ { x: content.x, y: content.y, w: content.w, h: cellH }, { x: content.x, y: content.y + cellH + g, w: content.w, h: cellH }, ]; } const g = clamp(g0, 0, Math.max(0, content.w - 2 * MIN_CONTENT)); const cellW = (content.w - g) / 2; return [ { x: content.x, y: content.y, w: cellW, h: content.h }, { x: content.x + cellW + g, y: content.y, w: cellW, h: content.h }, ]; } /** * Compute the drawn image rectangle on the export canvas (single image). * Thin wrapper over contentRect() + placeInRect() — its result is byte-for-byte * identical to the original single-image engine, so Phase 1 is unchanged. * * @param {object} p * @param {number} p.canvasW export/preview canvas width (px) * @param {number} p.canvasH export/preview canvas height (px) * @param {number} p.imageW source image width (px) * @param {number} p.imageH source image height (px) * @param {number} [p.paddingPct=0] padding as % of shorter side (0..100) * @param {number} [p.scale=1] user scale multiplier (>0), applied on top of fit * @param {number} [p.offsetX=0] drag offset X (px, before clamp) * @param {number} [p.offsetY=0] drag offset Y (px, before clamp) * @returns {{ * content:{x:number,y:number,w:number,h:number,pad:number}, * image:{x:number,y:number,w:number,h:number}, * baseScale:number, * effectiveScale:number, * offset:{x:number,y:number}, * limit:{x:number,y:number} * }} */ export function computeLayout(p) { const canvasW = Math.max(0, p.canvasW || 0); const canvasH = Math.max(0, p.canvasH || 0); const paddingPct = p.paddingPct == null ? 0 : p.paddingPct; const content = contentRect(canvasW, canvasH, paddingPct); const placed = placeInRect(content, p.imageW, p.imageH, p.scale, p.offsetX, p.offsetY); return { content, ...placed }; } /** * Compute the full composite layout: content rect → cells → per-cell image * placement. Single mode returns exactly one cell (identical to computeLayout). * * @param {object} p * @param {number} p.canvasW * @param {number} p.canvasH * @param {number} [p.paddingPct=0] * @param {'single'|'duo'} [p.mode='single'] * @param {'columns'|'rows'} [p.orientation='columns'] (duo only) * @param {number} [p.gap=0] px at export scale, between the two cells (duo only) * @param {Array<{imageW:number,imageH:number,scale?:number,offsetX?:number,offsetY?:number}>} [p.images=[]] * @returns {{ * content:{x:number,y:number,w:number,h:number,pad:number}, * mode:'single'|'duo', orientation:'columns'|'rows', gap:number, * cells: Array<{ * cell:{x:number,y:number,w:number,h:number}, * image:{x:number,y:number,w:number,h:number}, * baseScale:number, effectiveScale:number, * offset:{x:number,y:number}, limit:{x:number,y:number} * }> * }} */ export function computeComposite(p) { const canvasW = Math.max(0, p.canvasW || 0); const canvasH = Math.max(0, p.canvasH || 0); const paddingPct = p.paddingPct == null ? 0 : p.paddingPct; const content = contentRect(canvasW, canvasH, paddingPct); const mode = p.mode === 'duo' ? 'duo' : 'single'; const orientation = p.orientation === 'rows' ? 'rows' : 'columns'; const gap = mode === 'duo' ? Math.max(0, p.gap || 0) : 0; const rects = cellRects(content, mode, orientation, gap); const images = p.images || []; const cells = rects.map((rect, i) => { const im = images[i] || {}; const placed = placeInRect(rect, im.imageW, im.imageH, im.scale, im.offsetX, im.offsetY); return { cell: rect, ...placed }; }); return { content, mode, orientation, gap, cells }; }