On the WSL runner every __gm.pixelAt costs about 7 ms, three times as much as on stein2. Each call renders the whole stage into a fresh render texture and stalls until the GPU hands one pixel back. pixelRow and several checks read hundreds of pixels that way. Module 05 spent 36.8 of its 40 s on these reads, module 04 11.3 of 20 s. - stagePixels in src/ui/render.ts renders once around all points and reads them in one go. Only whole pixels at resolution 1 take that path. Anything else stays point by point. - The hook __gm.pixels(points) exposes it, and pixelRow uses it. - The loops in e2e 04, 05, 14 and 44 read their grids in one call. - The old comment in pixelRow said a wider frame comes back resampled. That is not true. The colours match exactly, so the comment is gone. Measured on the RTX (d3d12) and on stein2 (Intel, Vulkan): diff 0 at 10 680 points on each GPU. One screen row takes 4 to 18 ms instead of 2.4 to 9.9 s. The six affected modules need 32 s instead of 139 s under WSL, faster than on stein2. The rest of the gap is in Chromium's GPU process. perf puts three quarters of its time in the drivers (NVIDIA D3D12, Mesa d3d12, dxg). Four browser and Mesa settings were measured, none helped. Tests: - ./check.sh on melektaus-atx: 195 files, 2827 tests, CHECK EXIT 0 (+4) - e2e 1-16,30,37,44,58 under WSL: ALL PASSED, 587 checks - counter-check: a wrong index in stagePixels fails the unit test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
7.5 KiB
JavaScript
165 lines
7.5 KiB
JavaScript
import { clickDocumentTab } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Tabs as open documents (#171): a double-click in the asset panel opens a tab per asset, several of
|
||
* one kind at once; the tab keeps its document whatever is selected, and its zoom when another tab
|
||
* was on screen; the close cross and a middle click close it, a rename renames it and a deleted
|
||
* asset takes its tab along.
|
||
*/
|
||
export async function documents(t) {
|
||
const { check, json, evaluate, mutate, click, doubleClick, wheel, shot, showTab } = t;
|
||
await showTab('scene');
|
||
for (const doc of (await json('__gm.documents()')).docs)
|
||
await evaluate(`__gm.closeDocument(${JSON.stringify(doc)})`);
|
||
|
||
// Short names of their own: an earlier step leaves them too long for a tab to show whole.
|
||
const [a, b] = await json(`(() => {
|
||
const two = __gm.state.project.assets.filter((x) => x.type === 'texture' && x.width > 0).slice(0, 2);
|
||
const out = two.map((x, i) => ({ id: x.id, name: 'Sheet ' + 'AB'[i], was: x.name }));
|
||
two.forEach((x, i) => (x.name = out[i].name));
|
||
__gm.state.preview();
|
||
return out;
|
||
})()`);
|
||
const behavior = await json(
|
||
`(() => { const x = __gm.state.project.assets.find((x) => x.type === 'behavior' && x.graph.nodes.length > 0); return { id: x.id, node: x.graph.nodes[0].id }; })()`,
|
||
);
|
||
const docs = () => json('__gm.documents()');
|
||
const current = () => json('__gm.texture.current()');
|
||
const place = async (doc) => (await json('__gm.tabRow()')).shown.find((p) => p.tab === doc);
|
||
|
||
// 44a. Two textures, two tabs; each names its kind in front of the name.
|
||
for (const texture of [a, b]) {
|
||
const row = await json(`__gm.assetRow(${JSON.stringify(texture.id)})`);
|
||
await doubleClick(row.x, row.y);
|
||
}
|
||
const opened = await docs();
|
||
check(
|
||
'double-clicking two textures opens a tab for each, the second one showing',
|
||
JSON.stringify(opened.docs) === JSON.stringify([`asset:${a.id}`, `asset:${b.id}`]) &&
|
||
opened.active === `asset:${b.id}` &&
|
||
(await current()) === b.id,
|
||
JSON.stringify({ opened, current: await current() }),
|
||
);
|
||
const placeA = await place(`asset:${a.id}`);
|
||
const inTab = (p) => p.x > placeA.x && p.x < placeA.right && p.y > placeA.y && p.y < placeA.y + placeA.h;
|
||
const nameHits = await json(`__gm.locateAll(${JSON.stringify(a.name)})`);
|
||
const kindWords = (await json(`__gm.locateAll('Texture')`)).filter(inTab);
|
||
// The icon's 16 px in front of the name, read against the tab's own background at its left edge.
|
||
const iconInk = await json(`(() => {
|
||
const x0 = ${placeA.x} + 6, y = ${placeA.y + placeA.h / 2};
|
||
const grid = [];
|
||
for (let dy = -7; dy <= 7; dy++) for (let dx = 0; dx < 16; dx++) grid.push({ x: x0 + dx, y: y + dy });
|
||
const [background, ...ink] = __gm.pixels([{ x: ${placeA.x} + 1, y }, ...grid]);
|
||
return ink.filter((p) => p !== background).length;
|
||
})()`);
|
||
check(
|
||
'a tab shows the icon of its kind, then the asset’s name – the kind no longer as a word',
|
||
placeA?.kind === 'Texture' && nameHits.some(inTab) && kindWords.length === 0 && iconInk >= 20,
|
||
JSON.stringify({ placeA, nameHits, kindWords, iconInk }),
|
||
);
|
||
|
||
// 44b. Selecting the other texture is a look at the inspector, not a change of document.
|
||
await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(a.id)} })`);
|
||
check('selecting the other texture leaves the tab on its own', (await current()) === b.id, await current());
|
||
|
||
// 44c. Zoom and scroll stay with the document.
|
||
const view = () =>
|
||
json(
|
||
`(() => { const o = __gm.texture.pointAt(0, 0); const p = __gm.texture.pointAt(10, 0); return { x: o.x, y: o.y, scale: (p.x - o.x) / 10 }; })()`,
|
||
);
|
||
const fitted = await view();
|
||
const rect = await json('__gm.texture.rect()');
|
||
await wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, -300);
|
||
const zoomed = await view();
|
||
check('the wheel zooms the texture', zoomed.scale > fitted.scale * 1.2, JSON.stringify({ fitted, zoomed }));
|
||
await clickDocumentTab(t, `asset:${a.id}`);
|
||
check('its neighbour’s tab shows the other texture', (await current()) === a.id, await current());
|
||
await clickDocumentTab(t, `asset:${b.id}`);
|
||
const back = await view();
|
||
check(
|
||
'coming back, the texture is zoomed and scrolled as it was left',
|
||
(await current()) === b.id &&
|
||
Math.abs(back.scale - zoomed.scale) < 1e-6 &&
|
||
Math.abs(back.x - zoomed.x) < 0.5 &&
|
||
Math.abs(back.y - zoomed.y) < 0.5,
|
||
JSON.stringify({ zoomed, back }),
|
||
);
|
||
|
||
// 44d. A rename renames the tab.
|
||
await mutate(
|
||
`__gm.state.project.assets.find((x) => x.id === ${JSON.stringify(b.id)}).name = 'Renamed sheet'; __gm.state.preview();`,
|
||
);
|
||
const renamedAt = await place(`asset:${b.id}`);
|
||
const renamedHits = await json(`__gm.locateAll('Renamed sheet')`);
|
||
check(
|
||
'renaming the asset renames its tab',
|
||
renamedHits.some(
|
||
(p) =>
|
||
p.x > renamedAt.x && p.x < renamedAt.right && p.y > renamedAt.y && p.y < renamedAt.y + renamedAt.h,
|
||
),
|
||
JSON.stringify({ renamedAt, renamedHits }),
|
||
);
|
||
await mutate(
|
||
`__gm.state.project.assets.find((x) => x.id === ${JSON.stringify(b.id)}).name = ${JSON.stringify(b.name)}; __gm.state.preview();`,
|
||
);
|
||
|
||
// 44e. A node lives only in its graph: selecting one brings that graph up, in a tab of its own.
|
||
await evaluate(
|
||
`__gm.state.select({ kind: 'node', assetId: ${JSON.stringify(behavior.id)}, nodeId: ${JSON.stringify(behavior.node)} })`,
|
||
);
|
||
const withGraph = await docs();
|
||
check(
|
||
'selecting a node opens its behavior next to the texture showing',
|
||
withGraph.active === `asset:${behavior.id}` &&
|
||
JSON.stringify(withGraph.docs) ===
|
||
JSON.stringify([`asset:${a.id}`, `asset:${b.id}`, `asset:${behavior.id}`]) &&
|
||
(await json('__gm.tab()')) === 'graph',
|
||
JSON.stringify(withGraph),
|
||
);
|
||
await shot('44-documents');
|
||
|
||
// 44f. The close cross closes the tab on screen; its left neighbour comes up.
|
||
const graphTab = await place(`asset:${behavior.id}`);
|
||
await click(graphTab.close.x, graphTab.close.y);
|
||
const afterClose = await docs();
|
||
check(
|
||
'the close cross closes the tab, and the one left of it shows',
|
||
!afterClose.docs.includes(`asset:${behavior.id}`) &&
|
||
afterClose.active === `asset:${b.id}` &&
|
||
(await current()) === b.id,
|
||
JSON.stringify(afterClose),
|
||
);
|
||
|
||
// 44g. A middle click closes a tab that is not showing, and leaves the one that is.
|
||
const tabA = await place(`asset:${a.id}`);
|
||
await click((tabA.x + tabA.close.x) / 2, tabA.y + tabA.h / 2, 'middle');
|
||
const afterMiddle = await docs();
|
||
check(
|
||
'a middle click closes a tab in the background',
|
||
JSON.stringify(afterMiddle.docs) === JSON.stringify([`asset:${b.id}`]) &&
|
||
afterMiddle.active === `asset:${b.id}`,
|
||
JSON.stringify(afterMiddle),
|
||
);
|
||
|
||
// 44h. A deleted asset takes its tab along, and the last one gone leaves the Scene tab showing.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
window.__deletedSheet = p.assets.find((x) => x.id === ${JSON.stringify(b.id)});
|
||
p.assets = p.assets.filter((x) => x.id !== ${JSON.stringify(b.id)});
|
||
__gm.state.preview();
|
||
`);
|
||
const afterDelete = await docs();
|
||
check(
|
||
'deleting the asset closes its tab and goes back to the scene',
|
||
afterDelete.docs.length === 0 && (await json('__gm.tab()')) === 'scene',
|
||
JSON.stringify({ afterDelete, tab: await json('__gm.tab()') }),
|
||
);
|
||
await mutate(
|
||
`__gm.state.project.assets.push(window.__deletedSheet); delete window.__deletedSheet; __gm.state.preview();`,
|
||
);
|
||
await mutate(`
|
||
for (const { id, was } of ${JSON.stringify([a, b])}) __gm.state.project.assets.find((x) => x.id === id).name = was;
|
||
__gm.state.preview();
|
||
`);
|
||
}
|