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 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-27 01:45:01 +03:00
1 parent 91b1d808ce
commit 5ff43038ae
4 files changed
+374 -26

No files matched your search

+12
View File
@@ -29,6 +29,18 @@ export type AtlStep =
| { op: "function"; f: any } | { op: "function"; f: any }
| { op: "time"; t: AtlValue<number> }; | { op: "time"; t: AtlValue<number> };
/** Names of the properties a timeline sets or moves (descending into its sub-blocks). */
export function blockProperties(block: AtlBlock | null | undefined, into = new Set<string>(), depth = 0): Set<string> {
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 { export interface AtlHost {
/** Timeline of a named animated transform. */ /** Timeline of a named animated transform. */
block(name: string): AtlBlock | null; block(name: string): AtlBlock | null;
+82 -26
View File
@@ -1,7 +1,8 @@
// DOM renderer for the image layer: one absolutely positioned element per shown tag. // 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. // 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 { ColorMatrix, matrixFilter } from "./colormatrix";
import { movieDisplayable } from "./movie"; import { movieDisplayable } from "./movie";
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types"; 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[] } = {}) { show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[] } = {}) {
const prev = this.shown.get(tag); const prev = this.shown.get(tag);
const atSpecs = at.length ? at : prev?.at ?? []; const atSpecs = at.length ? at : prev?.at ?? [];
this.panWanted = this.usesPan(img.def, atSpecs);
const el = this.build(img.def); const el = this.build(img.def);
this.panWanted = false;
el.classList.add("pg-image"); el.classList.add("pg-image");
const base = this.place(el, img.def, atSpecs); const base = this.place(el, img.def, atSpecs);
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el, 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.master.insertBefore(el, behind ?? later ?? null);
} }
this.shown.set(tag, entry); 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); 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<string>();
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. */ /** Start the ATL timelines of an image; each works on its own copy of the properties. */
private animate(entry: Entry, base: Record<string, any>, specs: AtSpec[], timeline?: AtlBlock) { private animate(entry: Entry, base: Record<string, any>, specs: AtSpec[], timeline?: AtlBlock) {
if (this.fast) return; if (this.fast) return;
@@ -227,6 +266,7 @@ export class Renderer {
if (def.timeline) return this.timelineBox(def, depth); if (def.timeline) return this.timelineBox(def, depth);
if (def.frames?.length) return this.animated(def, depth); if (def.frames?.length) return this.animated(def, depth);
if (def.layered) return this.layered(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) { if (def.file) {
const img = document.createElement("img"); const img = document.createElement("img");
img.draggable = false; img.draggable = false;
@@ -404,39 +444,55 @@ export class Renderer {
return p; 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<string, any>) { private style(el: HTMLElement, p: Record<string, any>) {
normalize(p); normalize(p);
const pos = (v: number, total: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * total}px`); (el as any).__style = p;
const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`); if (el.dataset.pan) { // tile, then pan (Ren'Py's tile_and_pan): copies of the picture shifted by a fraction of its width
el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`; const nw = Number(el.dataset.nw), nh = Number(el.dataset.nh);
el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`; if (!nw || !nh) return;
// `size`/`xsize`/`ysize` stretch the picture; a missing side keeps the picture's proportions const xtile = Math.max(1, Math.round(p.xtile ?? 1)), ytile = Math.max(1, Math.round(p.ytile ?? 1));
const [sx, sy] = p.xysize ?? p.size ?? [p.xsize, p.ysize]; const wrap = (angle: number) => ((angle % 360) + 360) % 360 / 360;
if (typeof sx === "number") el.style.width = `${sx}px`; const panX = p.xpan === undefined || p.xpan === null ? 0 : nw * wrap(p.xpan);
if (typeof sy === "number") el.style.height = `${sy}px`; const panY = p.ypan === undefined || p.ypan === null ? 0 : nh * wrap(p.ypan);
const zx = (p.zoom ?? 1) * (p.xzoom ?? 1); el.style.width = `${p.xpan === undefined || p.xpan === null ? nw * xtile : nw}px`;
const zy = (p.zoom ?? 1) * (p.yzoom ?? 1); el.style.height = `${p.ypan === undefined || p.ypan === null ? nh * ytile : nh}px`;
const crop = Array.isArray(p.crop) && p.crop.length === 4 ? (p.crop as number[]).map(Number) : null; el.style.backgroundSize = `${nw}px ${nh}px`;
let ax = anchor(p.xanchor); el.style.backgroundPosition = `${-panX}px ${-panY}px`;
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.style.transformOrigin = `${ax} ${ay}`; const size = this.childSize(el);
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : ""); 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); if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
const filters: string[] = []; const filters: string[] = [];
if (p.filter) filters.push(`${p.filter}(1)`); if (p.filter) filters.push(`${p.filter}(1)`);
if (typeof p.blur === "number" && p.blur > 0) filters.push(`blur(${p.blur}px)`); 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 (p.matrixcolor instanceof ColorMatrix) filters.push(matrixFilter(p.matrixcolor));
if (filters.length) el.style.filter = filters.join(" "); el.style.filter = filters.join(" ");
} }
// --- transitions --------------------------------------------------------------- // --- transitions ---------------------------------------------------------------
+221
View File
@@ -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<number>(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 };
}
+59
View File
@@ -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
});