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

+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
});