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:
1 parent
91b1d808ce
commit
5ff43038ae
4 files changed
+374
-26
No files matched your search
@@ -29,6 +29,18 @@ export type AtlStep =
|
||||
| { op: "function"; f: any }
|
||||
| { 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 {
|
||||
/** Timeline of a named animated transform. */
|
||||
block(name: string): AtlBlock | null;
|
||||
|
||||
@@ -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<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. */
|
||||
private animate(entry: Entry, base: Record<string, any>, 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<string, any>) {
|
||||
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 ---------------------------------------------------------------
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
Reference in new issue
Block a user