A shader could only say what colour a pixel has. Where the picture is drawn was fixed to the four corners of its rectangle, so nothing could wave, bend or wobble. - The new node Vertex output takes x and y, in pixels of the picture, and moves every corner of the wearer's grid by them. The UVs stay with the corners, so the picture bends along. - Subdivisions on that node says how fine the grid is: cells per axis, 1 to 64, 8 by default. 1 is the plain quad. A shader without the node keeps the quad every sprite shares. - A graph holds one Vertex output at most. UV, Time, Property, Number and the math nodes may feed it, and one node may feed both outputs. - Texture color, Texture alpha, Sample texture, Texture size and Color cannot feed it. The node says "Not available per corner" and the shader does not compile. - Sprites, texts and the preview in the shader tab wear the grid. A nine-slice sprite and particles draw as if the node were not there. - Picking, the selection frame, the size and colliders stay with the rectangle. A wearer with a Vertex output is never culled, as with Padding, and with Padding the grid covers the room around the picture too. - tools/perf-shaders.mjs takes GM_PERF_SUBDIVISIONS to measure a grid against the plain quad. Measured on stein2 (Intel HD 630), frames per second, one run per row: at 800 sprites 58 with a shader on the quad, 46 with 8 cells, 41 with 32. Up to 400 sprites a grid hardly shows. Every sprite holds its own grid. Tests: - Gate on melektaus-atx: 220 files, 3416 tests, CHECK EXIT 0. - 15 new unit tests for the compiler and the grid. - Four new e2e checks in modules 28 and 30: a fixed offset moves the picture, 8 cells bend only the lower half where 1 cell shears it all, Texture alpha is refused at the node, a text moves. - e2e 28,30 on melektaus-atx: 110 checks, all pass. - The e2e total of 1699 is counted (1695 + 4), the full suite did not run. - No counter-probe, no screenshot. Not looked at: the preview, Time in the browser, flipped sprites, Padding together with the grid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
440 lines
21 KiB
JavaScript
440 lines
21 KiB
JavaScript
import { edge, node } from '../lib/builders.mjs';
|
||
import { LANES, pickAssetType } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Font assets (#74): uploaded like a picture – picked from what the server holds through "+ → Font",
|
||
* or dropped onto the editor – and worn by a text object through its Font row. No font means the
|
||
* editor's own typeface, which is also what a reference that no longer resolves falls back to.
|
||
*
|
||
* The seed puts two DejaVu faces on the server without making assets of them: this step makes them.
|
||
* Everything lives in its own lane (`__gm.lookAt`), because symbols draw above objects and the
|
||
* origin is crowded by what the earlier steps left behind.
|
||
*/
|
||
const LANE = LANES.font;
|
||
const TEXT_ASSET = 'ft-text';
|
||
const TEXT_OBJECT = 'ft-obj';
|
||
/** The two files the seed uploads, told apart by their exact size rather than by their name. */
|
||
const SERIF_BYTES = 380660;
|
||
const MONO_BYTES = 343140;
|
||
/** How many pixels across the text a check reads. */
|
||
const SAMPLE = 120;
|
||
const RED_SHADER = {
|
||
id: 'ft-red',
|
||
type: 'shader',
|
||
name: 'Red glyphs',
|
||
props: {},
|
||
graph: {
|
||
nodes: [
|
||
node('ft-red-color', 'ShaderColor', 0, 0, { value: 0xe63c3c }),
|
||
node('ft-red-out', 'ShaderOutput', 260, 0),
|
||
],
|
||
edges: [edge('ft-red-e1', 'ft-red-color', 'color', 'ft-red-out', 'color')],
|
||
},
|
||
};
|
||
|
||
export async function font(t) {
|
||
const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot, showTab } =
|
||
t;
|
||
const { select } = t;
|
||
const assetsOfType = (type) =>
|
||
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)})
|
||
.map((a) => ({ id: a.id, name: a.name, file: a.file }))`);
|
||
const info = () => json(`__gm.textInfo(${JSON.stringify(TEXT_OBJECT)})`);
|
||
/** The overlay is drawn last, so a menu entry wins over a row of the asset list saying the same. */
|
||
const clickLast = async (text) => {
|
||
const p = await locate(text, true);
|
||
await click(p.x, p.y);
|
||
};
|
||
/** A face arrives over the network, so what it changes is awaited rather than slept for. */
|
||
const waitFor = async (read, done, tries = 20) => {
|
||
let value = await read();
|
||
for (let i = 0; i < tries && !done(value); i++) {
|
||
await sleep(150);
|
||
value = await read();
|
||
}
|
||
return value;
|
||
};
|
||
|
||
await press('Escape');
|
||
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
|
||
await evaluate('__gm.state.select(null)');
|
||
await showTab('scene');
|
||
|
||
// 30a. What the server holds, straight from the API: the two files the seed put there.
|
||
// `evaluate`, not `json`: the latter stringifies the promise itself rather than what it yields.
|
||
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=fonts').then((r) => r.json())`);
|
||
const serif = stored.find((item) => item.bytes === SERIF_BYTES);
|
||
const mono = stored.find((item) => item.bytes === MONO_BYTES);
|
||
check(
|
||
'the server holds the two seeded typefaces, named by their content',
|
||
Boolean(serif && mono) && /^[a-f0-9]{64}\.ttf$/.test(serif.file),
|
||
JSON.stringify({ serif: serif?.file, mono: mono?.file, count: stored.length }),
|
||
);
|
||
if (!serif || !mono) return;
|
||
// The hash says nothing, so the name the file was uploaded under is kept beside it (#83).
|
||
check(
|
||
'the listing carries the name each file was uploaded under',
|
||
serif.name === 'DejaVuSerif.ttf' && mono.name === 'DejaVuSansMono.ttf',
|
||
JSON.stringify({ serif: serif.name, mono: mono.name }),
|
||
);
|
||
|
||
// 30b. "+ → Font" offers them; a font is picked rather than created empty, like a texture.
|
||
await evaluate('__gm.console.clear()');
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Font');
|
||
await sleep(500);
|
||
const picker = await json('__gm.fontPicker()');
|
||
check(
|
||
'the + menu opens the font picker on what the server holds, none of it in the project yet',
|
||
Boolean(picker) && picker.files.includes(serif.file) && picker.used.length === 0,
|
||
JSON.stringify(picker),
|
||
);
|
||
// A row the project does not wear yet is captioned by the file's own name (#83); without it
|
||
// there was nothing to read but its byte count.
|
||
check(
|
||
'a row nobody wears yet is captioned with the name the file was uploaded under',
|
||
picker.captions[picker.files.indexOf(serif.file)] === 'DejaVuSerif.ttf',
|
||
JSON.stringify(picker.captions),
|
||
);
|
||
await shot('30-font-picker');
|
||
// A row is a container holding its background, not a Graphics with children: Pixi v8 warns
|
||
// about the latter, and the warning is the only sign that the rows are built the wrong way.
|
||
const logged = await json('__gm.console.lines()');
|
||
check(
|
||
'building the rows draws no complaint from Pixi',
|
||
!logged.some((line) => /Deprecation/i.test(JSON.stringify(line))),
|
||
JSON.stringify(logged.slice(0, 3)),
|
||
);
|
||
const row = await json(`__gm.fontPickerRow(${JSON.stringify(serif.file)})`);
|
||
await click(row.x, row.y);
|
||
await clickText('Use font');
|
||
const fonts = await assetsOfType('font');
|
||
check(
|
||
'picking a row makes a font asset of that file, named after the file rather than "Font"',
|
||
fonts.length === 1 && fonts[0].file === serif.file && fonts[0].name === 'DejaVuSerif',
|
||
JSON.stringify(fonts),
|
||
);
|
||
if (fonts.length !== 1) return;
|
||
const fontId = fonts[0].id;
|
||
// Renamed away from "Font": the inspector row is called that too, and a lookup by text would
|
||
// find the asset's row in the panel instead of the row being tested.
|
||
await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fontId)}).name = 'Bookish'`);
|
||
|
||
// 30c. A text in its own lane, drawn in the editor's own typeface while it wears no font.
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
project.assets.push({ id: ${JSON.stringify(TEXT_ASSET)}, type: 'text', name: 'Title',
|
||
props: { enabled: true, scale: 1, rotation: 0, mirrorX: false, mirrorY: false,
|
||
text: 'Handgloves', font: '', fontSize: 48, color: 0xffffff, align: 'center' } });
|
||
__gm.state.activeScene.objects.push({ id: ${JSON.stringify(TEXT_OBJECT)}, assetId: ${JSON.stringify(TEXT_ASSET)},
|
||
name: '', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 },
|
||
overrides: {}, parentId: '', instanceEdits: {} });
|
||
__gm.lookAt(0, ${LANE});`);
|
||
const plain = await info();
|
||
check(
|
||
'a text wearing no font is drawn in the editor’s own typeface',
|
||
Boolean(plain) && !plain.family.startsWith('gm-font-') && plain.width > 0,
|
||
JSON.stringify(plain),
|
||
);
|
||
if (!plain) return;
|
||
|
||
// 30d. Wearing the font, chosen the way a user chooses it: the Font row of the text object.
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT });
|
||
// The label is no anchor on its own: the assets panel has a row per font. Parent and Shader (#169)
|
||
// say "None" as well, so the Font row's dropdown is the "None" at its height.
|
||
const fontRow = await json("__gm.inspectorField('Font', true)");
|
||
const nones = await evaluate(`(() => {
|
||
const out = [];
|
||
const walk = (node) => {
|
||
if (!node.visible) return;
|
||
if (node.text === 'None') out.push(Math.round(node.getBounds().y + node.getBounds().height / 2));
|
||
for (const child of node.children ?? []) walk(child);
|
||
};
|
||
walk(__gm.app.stage);
|
||
return out;
|
||
})()`);
|
||
check(
|
||
'a text has a Font row, and it reads None',
|
||
Boolean(fontRow) && nones.some((y) => Math.abs(y - fontRow.y) < 12),
|
||
JSON.stringify({ fontRow, nones }),
|
||
);
|
||
await click(fontRow.x, fontRow.y);
|
||
await clickLast('Bookish (font)');
|
||
const chosen = await json(
|
||
`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides.font`,
|
||
);
|
||
const worn = await waitFor(info, (value) => Boolean(value?.family.startsWith('gm-font-')));
|
||
check(
|
||
'picking it in the Font row redraws the text in that face, and it reflows',
|
||
chosen === fontId && Boolean(worn) && worn.width !== plain.width,
|
||
JSON.stringify({ chosen, plain, worn }),
|
||
);
|
||
await shot('30-font-scene');
|
||
// The asset's own inspector is where a font is looked at; it has nothing else to show.
|
||
await select({ kind: 'asset', assetId: fontId });
|
||
await shot('30-font-asset');
|
||
|
||
// 30e. The game draws through the same path, so the face is in play as well. The camera frames
|
||
// the origin, so the text has to leave its lane for as long as the game runs.
|
||
const place = (expression) =>
|
||
mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
const cam = scene.objects.find((o) => o.id === 'o3').position;
|
||
const text = scene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)});
|
||
Object.assign(text.position, ${expression});`);
|
||
await place('{ x: cam.x, y: cam.y }');
|
||
await play();
|
||
await sleep(600);
|
||
const played = await json(`__gm.gameTextInfo(${JSON.stringify(TEXT_OBJECT)})`);
|
||
check(
|
||
'the running game draws the text in the same face',
|
||
Boolean(played?.family.startsWith('gm-font-')) && played.family === worn?.family,
|
||
JSON.stringify(played),
|
||
);
|
||
await stop();
|
||
await place(`{ x: 0, y: ${LANE} }`);
|
||
|
||
// 30f. An id that no longer resolves is no font at all – the text falls back rather than vanishing.
|
||
await mutate(
|
||
`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides.font = 'no-such-asset'`,
|
||
);
|
||
const dangling = await waitFor(info, (value) => !value?.family.startsWith('gm-font-'));
|
||
check(
|
||
'a dangling font id falls back to the editor’s own typeface, at the width from before',
|
||
dangling?.family === plain.family && dangling?.width === plain.width,
|
||
JSON.stringify(dangling),
|
||
);
|
||
|
||
// 30g. Dropping a font file onto the editor uploads it and makes an asset named after the file.
|
||
const dropped = await evaluate(`
|
||
(async () => {
|
||
const bytes = await fetch('/game-maker/data/fonts/${mono.file}').then((r) => r.arrayBuffer());
|
||
const file = new File([bytes], 'DejaVuSansMono.ttf', { type: 'font/ttf' });
|
||
const data = new DataTransfer();
|
||
data.items.add(file);
|
||
const canvas = document.querySelector('canvas');
|
||
canvas.dispatchEvent(new DragEvent('drop', { dataTransfer: data, bubbles: true, cancelable: true }));
|
||
return file.size;
|
||
})()`);
|
||
await waitFor(
|
||
() => assetsOfType('font'),
|
||
(list) => list.length === 2,
|
||
);
|
||
const both = await assetsOfType('font');
|
||
check(
|
||
'a dropped typeface is uploaded and becomes an asset named after the file',
|
||
dropped === MONO_BYTES &&
|
||
both.length === 2 &&
|
||
both.some((a) => a.file === mono.file && a.name === 'DejaVuSansMono'),
|
||
JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }),
|
||
);
|
||
|
||
// 30h. A text wears a shader like a sprite (#169), picked in its own Shader row. The graph paints
|
||
// every glyph pixel one red and keeps the text's alpha, so no white may survive it.
|
||
const overrides = `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides`;
|
||
await mutate(`
|
||
Object.assign(${overrides}, { font: ${JSON.stringify(fontId)} });
|
||
__gm.state.project.assets.push(${JSON.stringify(RED_SHADER)});`);
|
||
await waitFor(info, (value) => Boolean(value?.family.startsWith('gm-font-')));
|
||
/** A run of pixels across the middle of the text, where every glyph is cut. */
|
||
const rowAcross = async (view = 'objectRect') => {
|
||
const r = await json(`__gm.${view}(${JSON.stringify(TEXT_OBJECT)})`);
|
||
return r ? json(`__gm.pixelRow(${Math.round(r.x - SAMPLE / 2)}, ${Math.round(r.y)}, ${SAMPLE})`) : [];
|
||
};
|
||
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||
// Red is counted by hue, not by the exact colour: drawn small, a glyph's pixels blend into the background.
|
||
const reddish = (hex) => {
|
||
const [r, g, b] = channels(hex);
|
||
return r > 0x60 && r > 2 * g && r > 2 * b;
|
||
};
|
||
const summary = (row) => ({
|
||
red: row.filter(reddish).length,
|
||
white: row.filter((hex) => channels(hex).every((c) => c > 0xd0)).length,
|
||
});
|
||
const white = summary(await rowAcross());
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT });
|
||
const shaderRow = await json("__gm.inspectorField('Shader', true)");
|
||
const noShader = await locate('None', true);
|
||
check(
|
||
'a text has a Shader row below its own, and it reads None',
|
||
Boolean(shaderRow) && Math.abs(noShader.y - shaderRow.y) < 12 && white.white > 0 && white.red === 0,
|
||
JSON.stringify({ shaderRow, noShader, white }),
|
||
);
|
||
await click(shaderRow.x, shaderRow.y);
|
||
await clickLast('Red glyphs (shader)');
|
||
const redInfo = await info();
|
||
const red = summary(await rowAcross());
|
||
check(
|
||
'picking a shader draws the text through it: its glyphs red, none of them white',
|
||
redInfo?.shader === RED_SHADER.id && red.red > 0 && red.white === 0,
|
||
JSON.stringify({ redInfo, red }),
|
||
);
|
||
await shot('30-font-text-shader');
|
||
|
||
// 30i. New content is rasterised anew, and the shader stays on it.
|
||
await mutate(`${overrides}.text = 'Handgloves Handgloves'`);
|
||
const longer = await info();
|
||
const redLonger = summary(await rowAcross());
|
||
check(
|
||
'changing the text keeps the shader on the new glyphs',
|
||
longer?.shader === RED_SHADER.id &&
|
||
longer.width > redInfo.width &&
|
||
redLonger.red > 0 &&
|
||
redLonger.white === 0,
|
||
JSON.stringify({ longer, redLonger }),
|
||
);
|
||
|
||
// 30i-ii. A shader's Padding gives a text room around its rectangle (#69), in the text's own
|
||
// units. The graph fills the whole quad here, so the margin shows as red left of the text.
|
||
const shaderGraph = `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(RED_SHADER.id)}).graph`;
|
||
const filled = (padding) =>
|
||
mutate(`
|
||
const graph = ${shaderGraph};
|
||
graph.nodes = graph.nodes.filter((n) => n.id !== 'ft-red-full');
|
||
graph.edges = graph.edges.filter((e) => e.id !== 'ft-red-e2');
|
||
graph.nodes.push(${JSON.stringify(node('ft-red-full', 'ShaderNumber', 0, 160, { value: 1 }))});
|
||
graph.edges.push(${JSON.stringify(edge('ft-red-e2', 'ft-red-full', 'value', 'ft-red-out', 'alpha'))});
|
||
Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'transparent', padding: ${padding} });`);
|
||
// The red run on the row through the text: where the filled quad begins and ends on screen.
|
||
const redSpan = async () => {
|
||
const r = await json(`__gm.objectRect(${JSON.stringify(TEXT_OBJECT)})`);
|
||
const from = Math.max(0, Math.round(r.x) - 600);
|
||
const row = await json(`__gm.pixelRow(${from}, ${Math.round(r.y)}, 1200)`);
|
||
const red = row.map(reddish);
|
||
return {
|
||
first: from + red.indexOf(true),
|
||
last: from + red.lastIndexOf(true),
|
||
unit: r.w / (await info()).width,
|
||
};
|
||
};
|
||
await evaluate('__gm.state.select(null)');
|
||
await filled(0);
|
||
const textTight = await redSpan();
|
||
await filled(6);
|
||
const textRoomy = await redSpan();
|
||
await shot('30-font-text-padding');
|
||
const reach = 6 * textTight.unit;
|
||
check(
|
||
'a padded shader draws six units past the text’s rectangle on either side, and none further',
|
||
textTight.last > textTight.first &&
|
||
Math.abs(textTight.first - textRoomy.first - reach) <= 2 &&
|
||
Math.abs(textRoomy.last - textTight.last - reach) <= 2,
|
||
JSON.stringify({ textTight, textRoomy, reach }),
|
||
);
|
||
// 30i-iii. A shader's Vertex output moves a text's corners as it does a sprite's (#79), in the
|
||
// text's own units: six of them to the right, and the filled quad stands six further on.
|
||
await filled(0);
|
||
await mutate(`
|
||
const graph = ${shaderGraph};
|
||
graph.nodes.push(${JSON.stringify(node('ft-red-by', 'ShaderNumber', 0, 320, { value: 6 }))});
|
||
graph.nodes.push(${JSON.stringify(node('ft-red-vout', 'ShaderVertexOutput', 260, 320, { subdivisions: 4 }))});
|
||
graph.edges.push(${JSON.stringify(edge('ft-red-e3', 'ft-red-by', 'value', 'ft-red-vout', 'x'))});`);
|
||
const textMoved = await redSpan();
|
||
check(
|
||
'a Vertex output fed 6 draws the text six units to the right of where it stands',
|
||
Math.abs(textMoved.first - textTight.first - reach) <= 2 &&
|
||
Math.abs(textMoved.last - textTight.last - reach) <= 2,
|
||
JSON.stringify({ textTight, textMoved, reach }),
|
||
);
|
||
await mutate(`
|
||
const graph = ${shaderGraph};
|
||
graph.nodes = graph.nodes.filter((n) => !['ft-red-full', 'ft-red-by', 'ft-red-vout'].includes(n.id));
|
||
graph.edges = graph.edges.filter((e) => !['ft-red-e2', 'ft-red-e3'].includes(e.id));
|
||
Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'clamp', padding: 0 });`);
|
||
|
||
// 30j. The game draws a text through the same picture, shader included. In front of everything:
|
||
// in the chained run, earlier steps leave pictures of their own at the camera.
|
||
await place('{ x: cam.x, y: cam.y, z: 1000 }');
|
||
await play();
|
||
await sleep(600);
|
||
const playedShaded = await json(`__gm.gameTextInfo(${JSON.stringify(TEXT_OBJECT)})`);
|
||
const playedPixels = await rowAcross('gameRect');
|
||
const playedRow = summary(playedPixels);
|
||
check(
|
||
'the running game draws the text through its shader as well',
|
||
playedShaded?.shader === RED_SHADER.id && playedRow.red > 0 && playedRow.white === 0,
|
||
JSON.stringify({
|
||
playedShaded,
|
||
playedRow,
|
||
rect: await json(`__gm.gameRect(${JSON.stringify(TEXT_OBJECT)})`),
|
||
scale: await json('__gm.gameScale()'),
|
||
playedPixels,
|
||
}),
|
||
);
|
||
await stop();
|
||
await place(`{ x: 0, y: ${LANE}, z: 0 }`);
|
||
|
||
// 30k. The font's Filter (#169 comment): Nearest rasterises a pixel per unit and magnifies without
|
||
// smoothing. At scale 4 a sharp glyph is blocks of four screen pixels, a smooth one blends
|
||
// its edges – so the shortest run of one colour inside the row tells them apart.
|
||
// Nothing selected: the outline of a selected text would cross the row with runs of its own.
|
||
await mutate(`Object.assign(${overrides}, { shader: '', text: 'Hg', fontSize: 16, scale: 4 })`);
|
||
await evaluate('__gm.state.select(null)');
|
||
// The scene's grid shows through where the text is transparent, in runs of its own, so everything
|
||
// darker than the glyphs counts as one background.
|
||
const shortestRun = (pixels) => {
|
||
const row = pixels.map((hex) => (channels(hex)[0] > 0x60 ? hex : 'background'));
|
||
const runs = [];
|
||
let length = 1;
|
||
for (let i = 1; i < row.length; i++) {
|
||
if (row[i] === row[i - 1]) length++;
|
||
else {
|
||
runs.push(length);
|
||
length = 1;
|
||
}
|
||
}
|
||
// The first and the last run are cut off by the ends of the row.
|
||
return Math.min(...runs.slice(1));
|
||
};
|
||
const smoothInfo = await info();
|
||
const smoothRow = await rowAcross();
|
||
const smoothRun = shortestRun(smoothRow);
|
||
await select({ kind: 'asset', assetId: fontId });
|
||
const filterRow = await json("__gm.inspectorField('Filter', true)");
|
||
const linear = await locate('Linear', true);
|
||
check(
|
||
'a font has a Filter row, Linear by default, and its text blends the edges of its glyphs',
|
||
Boolean(filterRow) && Math.abs(linear.y - filterRow.y) < 12 && smoothInfo?.smooth && smoothRun <= 2,
|
||
JSON.stringify({ filterRow, linear, smoothInfo, smoothRun, smoothRow }),
|
||
);
|
||
await click(filterRow.x, filterRow.y);
|
||
await clickLast('Nearest');
|
||
const filter = await json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fontId)}).props.filter`,
|
||
);
|
||
const sharpInfo = await info();
|
||
const sharpRow = await rowAcross();
|
||
const sharpRun = shortestRun(sharpRow);
|
||
check(
|
||
'Nearest draws its text a pixel per unit, magnified in blocks',
|
||
filter === 'nearest' && sharpInfo?.smooth === false && sharpInfo.resolution === 1 && sharpRun >= 3,
|
||
JSON.stringify({ filter, sharpInfo, sharpRun, sharpRow }),
|
||
);
|
||
await shot('30-font-nearest');
|
||
|
||
// 30k-ii. …and its texels are hard: rasterised finer and cut at half coverage, so no glyph edge is
|
||
// left half covered – Chrome on Windows smooths canvas text sideways, whatever the filter.
|
||
// The fixture's face is no pixel font, so rasterised 1:1 its edges would be half covered here too.
|
||
check(
|
||
'Nearest leaves no half-covered texel in the text, the smooth face does',
|
||
smoothInfo?.partial > 0 && sharpInfo?.partial === 0,
|
||
JSON.stringify({ smooth: smoothInfo?.partial, sharp: sharpInfo?.partial }),
|
||
);
|
||
|
||
// 30l. The step takes back everything it made, so the steps after it see the fixture again.
|
||
await mutate(`
|
||
const gone = new Set([${JSON.stringify(TEXT_ASSET)}, ${JSON.stringify(RED_SHADER.id)},
|
||
...${JSON.stringify(both.map((a) => a.id))}]);
|
||
const project = __gm.state.project;
|
||
project.assets = project.assets.filter((a) => !gone.has(a.id));
|
||
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter(
|
||
(o) => o.id !== ${JSON.stringify(TEXT_OBJECT)});
|
||
__gm.state.select(null);
|
||
__gm.lookAt(0, 0);`);
|
||
check(
|
||
'the step leaves nothing of its own behind',
|
||
(await assetsOfType('font')).length === 0 && (await info()) === null,
|
||
JSON.stringify(await assetsOfType('font')),
|
||
);
|
||
}
|