Spaces:
Running
Running
File size: 10,767 Bytes
fd3b6ac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | // 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 };
}
|