From 5ff43038aec0f955dda16cf32b1572158fd835a2 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:45:01 +0300 Subject: [PATCH] feat: Ren'Py's transform geometry ported exactly (crop, size/fit, zoom, rotate_pad, 3D, anchors, pan, tile, blend) rendertransform.ts follows RenderTransform from accelerator.pyx: the same matrices in the same order, with transform_anchor measuring the anchor in the child. Scene images are laid out from it (left/top plus a matrix3d with origin 0 0), so rotation turns about the picture's centre and the box grows to its diagonal as in Ren'Py; xpan/ypan are a wrapped shift of a repeating background, xtile/ytile repeat it, blend and a fully additive picture become mix-blend-mode. Checked in a real browser page and by unit tests of the math. Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/player/atl.ts | 12 ++ frontend/src/player/renderer.ts | 108 +++++++++--- frontend/src/player/rendertransform.ts | 221 ++++++++++++++++++++++++ frontend/tests/rendertransform.test.mjs | 59 +++++++ 4 files changed, 374 insertions(+), 26 deletions(-) create mode 100644 frontend/src/player/rendertransform.ts create mode 100644 frontend/tests/rendertransform.test.mjs diff --git a/frontend/src/player/atl.ts b/frontend/src/player/atl.ts index 6cb8754..63bcb9c 100644 --- a/frontend/src/player/atl.ts +++ b/frontend/src/player/atl.ts @@ -29,6 +29,18 @@ export type AtlStep = | { op: "function"; f: any } | { op: "time"; t: AtlValue }; +/** Names of the properties a timeline sets or moves (descending into its sub-blocks). */ +export function blockProperties(block: AtlBlock | null | undefined, into = new Set(), depth = 0): Set { + if (!block || depth > MAX_DEPTH) return into; + for (const step of block.steps) { + if (step.op === "set" || step.op === "move") for (const key of Object.keys(step.props)) into.add(key); + else if (step.op === "parallel") for (const b of step.blocks) blockProperties(b, into, depth + 1); + else if (step.op === "choice") for (const b of step.blocks) blockProperties(b.block, into, depth + 1); + else if (step.op === "block" || step.op === "on") blockProperties(step.block, into, depth + 1); + } + return into; +} + export interface AtlHost { /** Timeline of a named animated transform. */ block(name: string): AtlBlock | null; diff --git a/frontend/src/player/renderer.ts b/frontend/src/player/renderer.ts index 2eb8d1a..24751e0 100644 --- a/frontend/src/player/renderer.ts +++ b/frontend/src/player/renderer.ts @@ -1,7 +1,8 @@ // DOM renderer for the image layer: one absolutely positioned element per shown tag. // The stage has the game's native resolution and is scaled with a CSS transform. -import { AtlRunner, bindArguments, type AtlBlock } from "./atl"; +import { AtlRunner, bindArguments, blockProperties, type AtlBlock } from "./atl"; +import { blendMode, layoutTransform } from "./rendertransform"; import { ColorMatrix, matrixFilter } from "./colormatrix"; import { movieDisplayable } from "./movie"; import type { AtSpec, ImageDef, ShownImage, Transition } from "./types"; @@ -84,7 +85,9 @@ export class Renderer { show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[] } = {}) { const prev = this.shown.get(tag); const atSpecs = at.length ? at : prev?.at ?? []; + this.panWanted = this.usesPan(img.def, atSpecs); const el = this.build(img.def); + this.panWanted = false; el.classList.add("pg-image"); const base = this.place(el, img.def, atSpecs); const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el, @@ -98,9 +101,45 @@ export class Renderer { this.master.insertBefore(el, behind ?? later ?? null); } this.shown.set(tag, entry); + this.style(el, { ...base }); // now that it is in the page it has a size this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs], img.def.timeline); } + private panWanted = false; + + /** Whether any property of the image or its transforms is xpan, ypan, xtile or ytile (they need a tiling element). */ + private usesPan(def: ImageDef, at: AtSpec[]): boolean { + const PAN = ["xpan", "ypan", "xtile", "ytile"]; + const seen = new Set(); + for (const spec of [...(def.at ?? []), ...at]) { + for (const key of Object.keys({ ...(spec.props ?? {}), ...(spec.name ? this.host.transform(spec.name) ?? {} : {}) })) seen.add(key); + const block = typeof spec.atl === "string" ? this.host.atl(spec.atl) : spec.atl; + blockProperties(block, seen); + } + blockProperties(def.timeline, seen); + for (const key of Object.keys(def.props ?? {})) seen.add(key); + return PAN.some((key) => seen.has(key)); + } + + /** A picture that tiles and pans: its own element, with the picture as a repeating background. */ + private panElement(file: string): HTMLElement { + const box = document.createElement("div"); + box.dataset.pan = "1"; + const src = this.host.url(file); + if (!src) return box; + box.style.backgroundImage = `url("${src}")`; + box.style.backgroundRepeat = "repeat"; + const probe = new Image(); + probe.onload = () => { + const scale = this.host.assetScale?.(src) ?? 1; + box.dataset.nw = String(probe.naturalWidth / (scale > 0 ? scale : 1)); + box.dataset.nh = String(probe.naturalHeight / (scale > 0 ? scale : 1)); + if ((box as any).__style) this.style(box, (box as any).__style); + }; + probe.src = src; + return box; + } + /** Start the ATL timelines of an image; each works on its own copy of the properties. */ private animate(entry: Entry, base: Record, specs: AtSpec[], timeline?: AtlBlock) { if (this.fast) return; @@ -227,6 +266,7 @@ export class Renderer { if (def.timeline) return this.timelineBox(def, depth); if (def.frames?.length) return this.animated(def, depth); if (def.layered) return this.layered(def, depth); + if (def.file && this.panWanted && depth === 0) return this.panElement(def.file); if (def.file) { const img = document.createElement("img"); img.draggable = false; @@ -404,39 +444,55 @@ export class Renderer { return p; } + /** The size of an element before any transform: what Ren'Py calls the child's render size. */ + private childSize(el: HTMLElement): [number, number] | null { + const w = el.offsetWidth || (el as HTMLImageElement).naturalWidth || 0; + const h = el.offsetHeight || (el as HTMLImageElement).naturalHeight || 0; + if (w > 0 && h > 0) { + el.dataset.cw = String(w); + el.dataset.ch = String(h); + return [w, h]; + } + return el.dataset.cw ? [Number(el.dataset.cw), Number(el.dataset.ch)] : null; + } + private style(el: HTMLElement, p: Record) { normalize(p); - const pos = (v: number, total: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * total}px`); - const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`); - el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`; - el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`; - // `size`/`xsize`/`ysize` stretch the picture; a missing side keeps the picture's proportions - const [sx, sy] = p.xysize ?? p.size ?? [p.xsize, p.ysize]; - if (typeof sx === "number") el.style.width = `${sx}px`; - if (typeof sy === "number") el.style.height = `${sy}px`; - const zx = (p.zoom ?? 1) * (p.xzoom ?? 1); - const zy = (p.zoom ?? 1) * (p.yzoom ?? 1); - const crop = Array.isArray(p.crop) && p.crop.length === 4 ? (p.crop as number[]).map(Number) : null; - let ax = anchor(p.xanchor); - let ay = anchor(p.yanchor); - if (crop) { - // only the cropped rectangle is shown, and anchors/positions refer to it - const [cx, cy, cw, ch] = crop; - const frac = (v: number, size: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? v : v * size); - ax = `${cx + frac(p.xanchor, cw)}px`; - ay = `${cy + frac(p.yanchor, ch)}px`; - el.style.clipPath = `inset(${cy}px calc(100% - ${cx + cw}px) calc(100% - ${cy + ch}px) ${cx}px)`; - } else { - el.style.clipPath = ""; + (el as any).__style = p; + if (el.dataset.pan) { // tile, then pan (Ren'Py's tile_and_pan): copies of the picture shifted by a fraction of its width + const nw = Number(el.dataset.nw), nh = Number(el.dataset.nh); + if (!nw || !nh) return; + const xtile = Math.max(1, Math.round(p.xtile ?? 1)), ytile = Math.max(1, Math.round(p.ytile ?? 1)); + const wrap = (angle: number) => ((angle % 360) + 360) % 360 / 360; + const panX = p.xpan === undefined || p.xpan === null ? 0 : nw * wrap(p.xpan); + const panY = p.ypan === undefined || p.ypan === null ? 0 : nh * wrap(p.ypan); + el.style.width = `${p.xpan === undefined || p.xpan === null ? nw * xtile : nw}px`; + el.style.height = `${p.ypan === undefined || p.ypan === null ? nh * ytile : nh}px`; + el.style.backgroundSize = `${nw}px ${nh}px`; + el.style.backgroundPosition = `${-panX}px ${-panY}px`; } - el.style.transformOrigin = `${ax} ${ay}`; - el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : ""); + const size = this.childSize(el); + if (!size) { // not laid out yet (a picture still loading): place it once it has a size + el.style.visibility = "hidden"; + const again = () => { el.style.visibility = ""; if ((el as any).__style) this.style(el, (el as any).__style); }; + if (el instanceof HTMLImageElement && !el.complete) el.addEventListener("load", again, { once: true }); + else requestAnimationFrame(() => { if (this.childSize(el)) again(); }); + return; + } + el.style.visibility = ""; + const out = layoutTransform(p, size[0], size[1], this.width, this.height); + el.style.left = `${out.left}px`; + el.style.top = `${out.top}px`; + el.style.transformOrigin = "0 0"; + el.style.transform = out.transform; + el.style.clipPath = out.clip; + el.style.mixBlendMode = blendMode(p); if (p.alpha !== undefined) el.style.opacity = String(p.alpha); const filters: string[] = []; if (p.filter) filters.push(`${p.filter}(1)`); if (typeof p.blur === "number" && p.blur > 0) filters.push(`blur(${p.blur}px)`); if (p.matrixcolor instanceof ColorMatrix) filters.push(matrixFilter(p.matrixcolor)); - if (filters.length) el.style.filter = filters.join(" "); + el.style.filter = filters.join(" "); } // --- transitions --------------------------------------------------------------- diff --git a/frontend/src/player/rendertransform.ts b/frontend/src/player/rendertransform.ts new file mode 100644 index 0000000..5434af5 --- /dev/null +++ b/frontend/src/player/rendertransform.ts @@ -0,0 +1,221 @@ +// Ren'Py's Transform geometry (renpy/display/accelerator.pyx, RenderTransform), as a function from a transform's +// properties and its child's size to a CSS matrix. The same steps in the same order: crop, size/fit, zoom, +// rotate (with rotate_pad), the 3D rotations about matrixanchor, matrixtransform, zzoom, and the placement of the +// result by position and anchor (with transform_anchor choosing where the anchor is measured). +// +// Ren'Py's z axis points away from the viewer; CSS's points at the viewer. The matrices below are Ren'Py's, and +// `toCss` mirrors z when it hands one over. + +/** A 4x4 matrix in Ren'Py's convention: column vectors, `a.mul(b)` applies b first. Row-major, [row][column]. */ +export class Matrix4 { + constructor(public m: number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) {} + + static identity() { return new Matrix4(); } + + static offset(x: number, y: number, z = 0) { + return new Matrix4([1, 0, 0, x, 0, 1, 0, y, 0, 0, 1, z, 0, 0, 0, 1]); + } + + static scale(x: number, y: number, z = 1) { + return new Matrix4([x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1]); + } + + /** `Matrix.rotate(x, y, z)`: degrees about each axis, applied as Ren'Py's rotate_matrix does. */ + static rotate(x: number, y: number, z: number) { + const d = Math.PI / 180; + const sx = Math.sin(x * d), cx = Math.cos(x * d), sy = Math.sin(y * d), cy = Math.cos(y * d); + const sz = Math.sin(z * d), cz = Math.cos(z * d); + return new Matrix4([ + cy * cz, -cx * sz + cz * sx * sy, cx * cz * sy + sx * sz, 0, + cy * sz, cx * cz + sx * sy * sz, cx * sy * sz - cz * sx, 0, + -sy, cy * sx, cx * cy, 0, + 0, 0, 0, 1, + ]); + } + + /** `Matrix.perspective(w, h, n, p, f)` */ + static perspective(w: number, h: number, n: number, p: number, f: number) { + return new Matrix4([ + p, 0, -w / 2, 0, + 0, p, -h / 2, 0, + 0, 0, -(f + n) / (f - n), (-2 * f * n + p * (f + n)) / (f - n), + 0, 0, -1, p, + ]); + } + + mul(other: Matrix4): Matrix4 { + const a = this.m, b = other.m, out = new Array(16).fill(0); + for (let r = 0; r < 4; r++) + for (let c = 0; c < 4; c++) + for (let k = 0; k < 4; k++) out[r * 4 + c] += a[r * 4 + k] * b[k * 4 + c]; + return new Matrix4(out); + } + + /** `a * b` in Python code (matrixtransform): another matrix, or a number. */ + __binop__(op: string, other: any) { + if (op === "*" && other instanceof Matrix4) return this.mul(other); + if (op === "*" && typeof other === "number") return new Matrix4(this.m.map((v) => v * other)); + throw new TypeError(`Matrix ${op}`); + } + + transform(x: number, y: number, z = 0, w = 1): [number, number, number, number] { + const m = this.m; + return [m[0] * x + m[1] * y + m[2] * z + m[3] * w, m[4] * x + m[5] * y + m[6] * z + m[7] * w, + m[8] * x + m[9] * y + m[10] * z + m[11] * w, m[12] * x + m[13] * y + m[14] * z + m[15] * w]; + } + + isIdentity() { return this.m.every((v, i) => v === (i % 5 === 0 ? 1 : 0)); } + + /** `matrix3d(...)` for CSS: Ren'Py's z is flipped (S * M * S with S = diag(1, 1, -1, 1)), and CSS wants columns. */ + toCss(): string { + const m = this.m; + const at = (r: number, c: number) => m[r * 4 + c] * ((r === 2) !== (c === 2) ? -1 : 1); + const cols: number[] = []; + for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) cols.push(+at(r, c).toFixed(6)); + return `matrix3d(${cols.join(",")})`; + } +} + +export interface TransformProps { + xpos?: number; ypos?: number; xanchor?: number; yanchor?: number; xoffset?: number; yoffset?: number; + zoom?: number; xzoom?: number; yzoom?: number; + rotate?: number | null; rotate_pad?: boolean; + xsize?: number; ysize?: number; size?: [number, number]; xysize?: [number, number]; + fit?: string; maxsize?: [number, number]; + crop?: number[] | null; crop_relative?: boolean; + xrotate?: number; yrotate?: number; zrotate?: number; zpos?: number; zzoom?: boolean; + matrixanchor?: [number, number] | null; matrixtransform?: Matrix4 | null; + transform_anchor?: boolean; subpixel?: boolean; +} + +export interface Placement { + /** Where the element's top-left corner goes (its layout box is the child's own size). */ + left: number; + top: number; + /** `matrix3d(...)` or "" when the element is only translated. Always applied with transform-origin 0 0. */ + transform: string; + /** The visible part of the child before the transform (crop), as a CSS inset() or "". */ + clip: string; + /** Size of the transformed box (what a parent measures). */ + width: number; + height: number; +} + +/** Ren'Py's `blend` names (config.gl_blend_func) and fully additive pictures, as a CSS mix-blend-mode. */ +export function blendMode(p: { blend?: string | null; additive?: number }): string { + if ((p.additive ?? 0) >= 1) return "plus-lighter"; + switch (p.blend) { + case "add": return "plus-lighter"; + case "multiply": return "multiply"; + case "min": return "darken"; + case "max": return "lighten"; + default: return ""; + } +} + +/** Ren'Py's rule: 1.0 and below is a fraction, a whole number is pixels; here floats are fractions and ints pixels. */ +const compute = (value: number, whole: number) => (Number.isInteger(value) && Math.abs(value) > 1 ? value : value * whole); + +/** + * Lay the child out. `cw`, `ch` are its size; `areaW`, `areaH` the room the parent gives (the screen, for a scene + * image); `z11` the distance of the 1:1 plane when the transform lives in a perspective (0 when it does not). + */ +export function layoutTransform(p: TransformProps, cw: number, ch: number, areaW: number, areaH: number, z11 = 0): Placement { + let width = cw, height = ch; + + // cropping: the visible rectangle of the child + let vx = 0, vy = 0, vw = cw, vh = ch; + let clip = ""; + if (p.crop && p.crop.length === 4) { + const rel = p.crop_relative === true; + const [x, y, w, h] = p.crop; + const part = (v: number, whole: number) => (rel || !Number.isInteger(v) ? v * whole : v); + vx = part(x, cw); + vy = part(y, ch); + vw = part(w, rel ? cw : cw - vx); + vh = part(h, rel ? ch : ch - vy); + clip = `inset(${vy}px ${cw - vx - vw}px ${ch - vy - vh}px ${vx}px)`; + width = vw; + height = vh; + } + + // size and fit: one factor per axis, applied before zoom + let sx = 1, sy = 1; + const [xs, ys] = p.xysize ?? p.size ?? [p.xsize, p.ysize]; + const wanted: number[] = []; + if (typeof xs === "number") wanted.push(xs / width); + if (typeof ys === "number") wanted.push(ys / height); + let mul: number | null = null; + if (p.maxsize) mul = Math.min(p.maxsize[0] / width, p.maxsize[1] / height); + let fit = p.fit ?? null; + if (fit && !wanted.length) wanted.push(areaW / width, areaH / height); + if (!fit) fit = "fill"; + if (wanted.length && width && height) { + if (fit === "scale-up") mul = Math.max(1, ...wanted); + else if (fit === "scale-down") mul = Math.min(1, ...wanted); + else if (fit === "contain") mul = Math.min(...wanted); + else if (fit === "cover") mul = Math.max(...wanted); + else { + sx = typeof xs === "number" ? xs / width : 1; + sy = typeof ys === "number" ? ys / height : 1; + } + } + if (mul !== null) sx = sy = mul; + + // zoom + const zoom = p.zoom ?? 1; + const zx = sx * zoom * (p.xzoom ?? 1); + const zy = sy * zoom * (p.yzoom ?? 1); + let boxW = Math.abs(width * zx), boxH = Math.abs(height * zy); + + // rotate about the child's centre; the box grows to hold it (or to the diagonal when rotate_pad) + const rotate = p.rotate; + let angle = 0; + if (rotate !== undefined && rotate !== null && !z11) { + angle = rotate; + const a = angle * Math.PI / 180, cos = Math.cos(a), sin = Math.sin(a); + if (p.rotate_pad !== false) { + boxW = boxH = Math.hypot(boxW, boxH); + } else { + const w = boxW, h = boxH; + boxW = Math.abs(w * cos) + Math.abs(h * sin); + boxH = Math.abs(w * sin) + Math.abs(h * cos); + } + } + + // child -> box, as Ren'Py's reverse matrix does: scale (flips included), rotate about the centre, then the + // visible rectangle's centre goes to the box centre + let m = Matrix4.offset(boxW / 2, boxH / 2) + .mul(Matrix4.rotate(0, 0, angle)) + .mul(Matrix4.scale(zx, zy, 1)) + .mul(Matrix4.offset(-(vx + vw / 2), -(vy + vh / 2))); + + // 3D rotations and matrixtransform, about matrixanchor (the box centre by default) + const [mx, my] = p.matrixanchor ? [compute(p.matrixanchor[0], boxW), compute(p.matrixanchor[1], boxH)] : [boxW / 2, boxH / 2]; + const about = (n: Matrix4) => Matrix4.offset(mx, my).mul(n).mul(Matrix4.offset(-mx, -my)); + if (p.xrotate || p.yrotate || p.zrotate) m = about(Matrix4.rotate(p.xrotate ?? 0, p.yrotate ?? 0, p.zrotate ?? 0)).mul(m); + if (p.zpos) m = Matrix4.offset(0, 0, p.zpos).mul(m); + if (p.matrixtransform) m = about(p.matrixtransform).mul(m); + if (p.zzoom && z11) { + const k = (z11 - (p.zpos ?? 0)) / z11; + m = Matrix4.offset(boxW / 2, boxH / 2).mul(Matrix4.scale(k, k, 1)).mul(Matrix4.offset(-boxW / 2, -boxH / 2)).mul(m); + } + + // placement: the anchor is a fraction/number of the box, or (transform_anchor) a point of the child that follows it + let ax: number, ay: number; + if (p.transform_anchor) { + const [px, py] = [compute(p.xanchor ?? 0, cw), compute(p.yanchor ?? 0, ch)]; + const [tx, ty] = m.transform(px, py); + ax = tx; + ay = ty; + } else { + ax = compute(p.xanchor ?? 0, boxW); + ay = compute(p.yanchor ?? 0, boxH); + } + const x = compute(p.xpos ?? 0, areaW) + (p.xoffset ?? 0) - ax; + const y = compute(p.ypos ?? 0, areaH) + (p.yoffset ?? 0) - ay; + + const plain = m.isIdentity(); + return { left: x, top: y, transform: plain ? "" : m.toCss(), clip, width: boxW, height: boxH }; +} + diff --git a/frontend/tests/rendertransform.test.mjs b/frontend/tests/rendertransform.test.mjs new file mode 100644 index 0000000..67590aa --- /dev/null +++ b/frontend/tests/rendertransform.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/rendertransform.ts", import.meta.url))], + bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" }); +const { Matrix4, layoutTransform } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`); +const close = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) < eps, `${a} != ${b}`); + +test("zoom grows the box and scales the child about its centre; the anchor is a fraction of the grown box", () => { + const out = layoutTransform({ zoom: 2, xpos: 0.5, ypos: 1, xanchor: 0.5, yanchor: 1 }, 100, 50, 1920, 1080); + close(out.width, 200); close(out.height, 100); + close(out.left, 960 - 100); close(out.top, 1080 - 100); + assert.equal(out.transform, "matrix3d(2,0,0,0,0,2,0,0,0,0,1,0,0,0,0,1)"); +}); + +test("rotate pads the box to its diagonal (rotate_pad) and rotates about the centre", () => { + const out = layoutTransform({ rotate: 90, xpos: 0, ypos: 0 }, 60, 80, 1000, 1000); + close(out.width, 100); close(out.height, 100); + // the child's centre (30, 40) must land on the box centre (50, 50); rotating 90 degrees clockwise + const css = out.transform.match(/matrix3d\((.*)\)/)[1].split(",").map(Number); + const x = css[0] * 30 + css[4] * 40 + css[12], y = css[1] * 30 + css[5] * 40 + css[13]; + close(x, 50, 1e-4); close(y, 50, 1e-4); + // the child's top-left corner (0,0) goes to (50+40, 50-30) + close(css[12], 90, 1e-4); close(css[13], 20, 1e-4); +}); + +test("without rotate_pad the box is the bounding box of the rotated child", () => { + const out = layoutTransform({ rotate: 90, rotate_pad: false }, 60, 80, 1000, 1000); + close(out.width, 80); close(out.height, 60); +}); + +test("transform_anchor measures the anchor in the child and carries it through the transform", () => { + const out = layoutTransform({ zoom: 0.5, transform_anchor: true, xanchor: 985, yanchor: 1535, xpos: 0, ypos: 0 }, 2000, 3000, 1920, 1080); + close(out.left, -985 * 0.5); close(out.top, -1535 * 0.5); +}); + +test("a negative xzoom flips the child inside the same box", () => { + const out = layoutTransform({ xzoom: -1 }, 100, 50, 1000, 1000); + close(out.width, 100); + assert.equal(out.transform, "matrix3d(-1,0,0,0,0,1,0,0,0,0,1,0,100,0,0,1)"); +}); + +test("crop shows the rectangle and the box takes its size; size and fit scale first", () => { + const out = layoutTransform({ crop: [10, 20, 30, 40] }, 100, 100, 1000, 1000); + close(out.width, 30); close(out.height, 40); + assert.equal(out.clip, "inset(20px 60px 40px 10px)"); + const fit = layoutTransform({ xysize: [200, 200], fit: "contain" }, 100, 50, 1000, 1000); + close(fit.width, 200); close(fit.height, 100); +}); + +test("matrix products follow Ren'Py's order: a.mul(b) applies b first, and 3D rotation about the centre", () => { + const m = Matrix4.offset(10, 0).mul(Matrix4.scale(2, 2, 1)); + assert.deepEqual(m.transform(1, 1).slice(0, 2), [12, 2]); + const out = layoutTransform({ yrotate: 180 }, 100, 50, 1000, 1000); + const css = out.transform.match(/matrix3d\((.*)\)/)[1].split(",").map(Number); + close(css[0], -1, 1e-5); // the picture is seen from behind: mirrored +});