Files
feuerwerk/tools/e2e/42-shapes.mjs
T
adminandClaude Opus 5 2d79230b55
game-maker / gate (push) Successful in 2m13s
game-maker / suite (push) Successful in 7m42s
A frame's collision outline can be generated from its pixels (#161)
Drawing an outline by hand for every frame of a sheet is slow, and the
outline mostly follows the opaque part of the image anyway. The texture
tab now traces it: one click, a corner budget, and every Frame
collision collider and tile using that frame blocks with the result.

- "Generate collision..." sits in the texture tab header and is off
  while the texture has no frames.
- The dialog has Max points (default 8, 3 to 64), Solid from ... %
  alpha (default 50) and All frames of the texture. A live line says
  what Generate will write, and Generate stays off when nothing is
  solid enough.
- traceCollision walks each island of solid pixels along the pixel
  edges, then drops the least important corners until at most N are
  left and no stair step remains. A slope becomes one straight edge.
- An outline never crosses itself. Where the image would force that,
  the island's convex hull is taken instead.
- Crumbs under 1 % of the frame area are dropped unless they are the
  only island. Holes are not reproduced.
- Existing outlines are replaced. One frame or all frames is a single
  undo step.
- TextureLibrary hands out the sheet's RGBA data, and a pure function
  cuts a frame's region from it.

Findings:
- The wedge in the e2e fixture gets 4 corners, not 3. Its tip is one
  pixel wide in the image, and that real edge stays while corners are
  still free.

Tests:
- ./check.sh: 131 files, 1941 tests, CHECK EXIT 0 (was 130 / 1931).
- 10 new unit tests in tests/core/trace-collision.test.ts, including
  150 random shapes that must all be valid and within the budget.
- GM_STEPS=42, freshly seeded: ALL PASSED, 20 checks (was 16).
- Three counter-probes bite: no hull fallback, no stair threshold,
  dialog ignoring the alpha threshold.
- Check sum 1202 in README is computed (1198 + 4), CI measures it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:00:30 +02:00

536 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edge, node } from '../lib/builders.mjs';
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* Capsules and polygons (#151), and the collision outline of a frame that a tile blocks with
* (#141). What only a running editor can show: the corners of a polygon collider are edited on
* its outline in the scene, the engine lets a capsule slide down a polygon ramp, the Texture tab
* draws a frame's outline with clicks, and a painted map blocks with that outline in the game – as
* does a collider that wears the frame's outline (#193), and "Generate collision…" reads that
* outline off the picture (#161).
*/
/** A 32 × 32 picture, pasted like a file: the texture the outline is drawn on. */
const PASTE_PICTURE = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const g = canvas.getContext('2d');
g.fillStyle = '#3c9a4a'; g.fillRect(0, 0, 32, 32);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'wedge.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
/**
* A 64 × 32 sheet, pasted like a file, painted pixel by pixel so no edge is smoothed: on the left a
* wedge (pixels with x + y ≥ 32) and a band of alpha 0.3 above it, on the right a 16 × 16 square.
*/
const PASTE_SHEET = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 32;
const g = canvas.getContext('2d');
g.fillStyle = '#c04040';
for (let y = 0; y < 32; y++) for (let x = 0; x < 32; x++) if (x + y >= 32) g.fillRect(x, y, 1, 1);
g.fillRect(40, 8, 16, 16);
g.fillStyle = 'rgba(64, 64, 192, 0.3)';
g.fillRect(0, 0, 20, 4);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'generate.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
/** Walks its owner right and a little down, colliding with solids: 2 and 0.6 a frame at 60 fps. */
const climbGraph = {
nodes: [
node('sc-update', 'OnUpdate', 0, 0),
node('sc-owner', 'Owner', 0, 160),
node('sc-move', 'Move', 300, 0, { dx: 1, dy: 0.3, scale: 120, collide: true }),
],
edges: [
edge('sc-e1', 'sc-update', 'exec', 'sc-move', 'exec'),
edge('sc-e2', 'sc-update', 'dt', 'sc-move', 'dt'),
edge('sc-e3', 'sc-owner', 'object', 'sc-move', 'object'),
],
};
const TRIANGLE = [
{ x: 0, y: -80 },
{ x: 80, y: 80 },
{ x: -80, y: 80 },
];
const TAN30 = Math.tan(Math.PI / 6);
/** A ramp falling right at 30°; its top face runs through the object's origin. */
const RAMP = [
{ x: -200, y: -200 * TAN30 },
{ x: 200, y: 200 * TAN30 },
{ x: 200, y: 150 },
{ x: -200, y: 150 },
];
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
export async function shapes(t) {
const { check, json, evaluate, mutate, select, click, doubleClick, drag, clickText, undo } = t;
const { object, settle, sleep, play, stop, shot, showTab, press } = t;
const Y = LANES.shapes;
const put = `
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
const o = make(id, assetId, parentId, y, name);
o.position.x = x;
o.overrides = overrides;
scene.objects.push(o);
};`;
// 42a. A polygon collider on a crate, selected: a handle on each corner. A corner is dragged, a
// press on an edge puts a corner there, a double-click takes it away – one undo step each.
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
${put}
assets.push({ id: 'sh-col-poly', type: 'collider', name: 'sh-col-poly',
props: { shape: 'polygon', points: ${JSON.stringify(TRIANGLE)}, isTrigger: false } });
put('sh-poly', 'crate', '', 0, ${Y}, 'Polygon crate', { width: 160, height: 160 });
put('sh-poly-col', 'sh-col-poly', 'sh-poly', 0, 0, 'Polygon collider');
__gm.state.select(null)`);
await evaluate(`__gm.lookAt(0, ${Y})`);
await select({ kind: 'object', holderId: 'scene1', objectId: 'sh-poly-col', objectIds: ['sh-poly-col'] });
await settle();
const corners = async () => (await json('__gm.polygonCorners()'))?.corners ?? [];
const points = async () => (await object('sh-poly-col'))?.overrides?.points;
const start = await corners();
check(
'a selected polygon collider shows a handle on each of its corners',
start.length === 3 && start[0].y < start[1].y && start[1].x > start[2].x,
JSON.stringify(start),
);
const zoom = distance(start[0], start[1]) / distance(TRIANGLE[0], TRIANGLE[1]);
await drag(start[1].x, start[1].y, start[1].x + 40, start[1].y);
const dragged = await points();
check(
"dragging a corner moves it in the collider's own units, and one undo puts it back",
dragged?.length === 3 &&
Math.abs(dragged[1].x - (80 + 40 / zoom)) <= 1 &&
dragged[1].y === 80 &&
(await undo(), (await points()) === undefined),
JSON.stringify({ dragged, zoom }),
);
const [a, b] = await corners();
await click((a.x + b.x) / 2, (a.y + b.y) / 2);
const inserted = await points();
check(
'a press on an edge puts a corner there',
inserted?.length === 4 && Math.abs(inserted[1].x - 40) <= 1 && Math.abs(inserted[1].y) <= 1,
JSON.stringify(inserted),
);
await shot('42-polygon-corners');
const fourth = (await corners())[1];
await doubleClick(fourth.x, fourth.y);
check(
'a double-click on a corner takes it away again',
JSON.stringify(await points()) === JSON.stringify(TRIANGLE),
JSON.stringify(await points()),
);
const clearShapes = () =>
mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('sh-'));
p.assets = p.assets.filter((a) => !a.id.startsWith('sh-'));
__gm.state.select(null)`);
await clearShapes();
// Every step clears its objects, so each has the lane to itself.
const RAMP_Y = Y + 100;
// 42b. The engine with the new shapes: a capsule with a rigidbody (no turning) falls onto a
// polygon ramp and slides down it, on the surface and never into it.
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
${put}
assets.push({ id: 'sh-col-ramp', type: 'collider', name: 'sh-col-ramp',
props: { shape: 'polygon', points: ${JSON.stringify(RAMP)}, isTrigger: false } });
assets.push({ id: 'sh-col-capsule', type: 'collider', name: 'sh-col-capsule',
props: { shape: 'capsule', width: 20, height: 40, friction: 0, isTrigger: false } });
assets.push({ id: 'sh-rb', type: 'rigidbody', name: 'sh-rb',
props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } });
put('sh-ramp', 'crate', '', 0, ${RAMP_Y}, 'Ramp', { width: 20, height: 20 });
put('sh-ramp-col', 'sh-col-ramp', 'sh-ramp', 0, 0);
put('sh-capsule', 'crate', '', -150, ${RAMP_Y - 200}, 'Capsule', { width: 20, height: 40 });
put('sh-capsule-col', 'sh-col-capsule', 'sh-capsule', 0, 0);
put('sh-capsule-rb', 'sh-rb', 'sh-capsule', 0, 0);
__gm.state.select(null)`);
await evaluate(`__gm.lookAt(0, ${RAMP_Y - 100})`);
await settle();
await shot('42-capsule-ramp');
await play();
await evaluate(`(() => {
const out = (window.__shapeSamples = []);
const begin = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const e = w.entities.find((x) => x.objectId === 'sh-capsule');
out.push({ x: e?.worldX, y: e?.worldY });
if (performance.now() - begin < 2500) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(2800);
const samples = await json('window.__shapeSamples');
await stop();
// The lower round end's centre is 10 above the capsule's centre; its distance to the ramp's face
// (through (0, RAMP_Y), normal (sin 30°, −cos 30°)) is what keeps it on the surface.
const n = { x: 0.5, y: -Math.cos(Math.PI / 6) };
const gap = (s) => (s.x - 0) * n.x + (s.y + 10 - RAMP_Y) * n.y - 10;
// Only while it is over the ramp, which ends at x = 200: past it the capsule falls free.
const landed = samples.filter((s) => s.x < 190 && gap(s) < 1);
check(
'a capsule falls onto a polygon ramp and slides down it on the surface',
samples.length > 60 &&
landed.length > 30 &&
Math.max(...landed.map((s) => s.x)) > -150 + 60 &&
// The landing sinks it in a little (measured 0.9 at ~6 a frame); the engine pushes it back out.
landed.every((s) => gap(s) > -2) &&
landed.slice(30).every((s) => Math.abs(gap(s)) < 0.1),
JSON.stringify({
frames: samples.length,
landed: landed.length,
furthest: Math.max(...landed.map((s) => s.x)),
deepest: Math.min(...landed.map(gap)),
settled: Math.max(...landed.slice(30).map((s) => Math.abs(gap(s)))),
}),
);
await clearShapes();
// 42c. The Texture tab draws a frame's collision outline (#141): with "Collision" on, three
// clicks in the selected frame are three corners; a corner drags; undo takes the drag back.
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate(PASTE_PICTURE);
await sleep(2500);
const texture = await json(
"(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" +
' return a ? { id: a.id, w: a.width, h: a.height } : null; })()',
);
check(
'the picture is imported at its own size',
texture?.w === 32 && texture?.h === 32,
JSON.stringify(texture),
);
await mutate(`
const texture = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)});
texture.frames.push({ id: 'sh-frame', name: 'Wedge', x: 0, y: 0, w: 32, h: 32 });`);
await select({ kind: 'frame', assetId: texture?.id, frameId: 'sh-frame' });
await showTab('texture');
await settle();
await clickText('Collision');
const outline = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)})` +
`.frames.find((f) => f.id === 'sh-frame').collision ?? null`,
);
for (const [x, y] of [
[0, 32],
[32, 0],
[32, 32],
]) {
const at = await json(`__gm.texture.pointAt(${x}, ${y})`);
await click(at.x, at.y);
}
const drawn = await outline();
check(
'with Collision on, clicks in the selected frame draw its outline corner by corner',
(await json('__gm.texture.collisionMode()')) === true &&
JSON.stringify(drawn) ===
JSON.stringify([
[
{ x: 0, y: 32 },
{ x: 32, y: 0 },
{ x: 32, y: 32 },
],
]),
JSON.stringify(drawn),
);
const from = await json('__gm.texture.pointAt(32, 0)');
const to = await json('__gm.texture.pointAt(16, 0)');
await drag(from.x, from.y, to.x, to.y);
const moved = await outline();
await shot('42-texture-outline');
await undo();
check(
'a corner of the outline drags, and one undo puts it back',
moved?.[0]?.[1]?.x === 16 && moved[0][1].y === 0 && (await outline())?.[0]?.[1]?.x === 32,
JSON.stringify({ moved, after: await outline() }),
);
// A frame holds several outlines: Ctrl+click starts a second one, and Delete takes the active
// outline away whole – not the frame, which Delete removes outside the mode.
for (const [i, [x, y]] of [
[2, 2],
[10, 2],
[2, 10],
].entries()) {
const at = await json(`__gm.texture.pointAt(${x}, ${y})`);
await click(at.x, at.y, 'left', i === 0 ? { ctrl: true } : {});
}
const two = await outline();
await shot('42-texture-two-outlines');
await press('Delete');
const left = await outline();
check(
'Ctrl+click starts a second outline in the frame, and Delete removes only that one',
two?.length === 2 &&
JSON.stringify(two[1]) ===
JSON.stringify([
{ x: 2, y: 2 },
{ x: 10, y: 2 },
{ x: 2, y: 10 },
]) &&
left?.length === 1 &&
left[0].length === 3 &&
(await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)}).frames.some((f) => f.id === 'sh-frame')`,
)) === true,
JSON.stringify({ two, left }),
);
await clickText('Collision');
await showTab('scene');
await settle();
// 42d. A plain tile of that frame blocks with the outline: a wedge on a floor of plain solid
// tiles. The scene view outlines it, and a walker whose Move collides climbs over it.
const MAP_Y = Y; // cell (x, y) spans x·32 − 192 … and y·32 − 64 + MAP_Y …; the floor's top is MAP_Y + 32
const floor = Object.fromEntries(Array.from({ length: 12 }, (_, x) => [`${x},3`, 'sh-wall']));
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
${put}
const behavior = structuredClone(assets.find((a) => a.id === 'mover'));
Object.assign(behavior, { id: 'sh-climb', name: 'Climb', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(climbGraph)} });
assets.push(behavior);
assets.push({ id: 'sh-wedge', type: 'tile', name: 'Wedge', props: { kind: 'none', solid: true }, pictures: ['sh-frame'] });
assets.push({ id: 'sh-wall', type: 'tile', name: 'Floor', props: { kind: 'none', solid: true }, pictures: [] });
assets.push({ id: 'sh-map', type: 'tilemap', name: 'Wedge map', props: { columns: 12, rows: 4, tileSize: 32 },
layers: [{ id: 'sh-layer', name: 'Layer 1', cells: { ...${JSON.stringify(floor)}, '6,2': 'sh-wedge' } }] });
assets.push({ id: 'sh-col-walker', type: 'collider', name: 'sh-col-walker',
props: { shape: 'box', width: 20, height: 20, friction: 0, isTrigger: false } });
put('sh-map-obj', 'sh-map', '', 0, ${MAP_Y}, 'Wedge map');
put('sh-walker', 'crate', '', -100, ${MAP_Y + 22}, 'Walker', { width: 20, height: 20 });
put('sh-walker-col', 'sh-col-walker', 'sh-walker', 0, 0);
put('sh-walker-climb', 'sh-climb', 'sh-walker', 0, 0, 'Climb');
__gm.state.select(null)`);
await evaluate(`__gm.lookAt(0, ${MAP_Y})`);
await settle();
const solids = await json(`__gm.tilemap.solids('sh-map-obj')`);
check(
'the scene view outlines the wedge cell with its outline, the floor as one rectangle',
solids?.visible === true &&
JSON.stringify(solids.rects) === JSON.stringify([{ x: 0, y: 3, w: 12, h: 1 }]) &&
JSON.stringify(solids.outlines) ===
JSON.stringify([
[
{ x: 6, y: 3 },
{ x: 7, y: 2 },
{ x: 7, y: 3 },
],
]),
JSON.stringify(solids),
);
// Drawn along the outer edges only (#173): the wedge's foot on the floor is no stroke.
const cornersOf = (line) => (line?.points ?? []).map((p) => `${p.x},${p.y}`).sort();
check(
'the scene view draws the wedge and the floor as one closed outline, without the edge they share',
solids?.boundary?.length === 1 &&
solids.boundary[0].closed === true &&
JSON.stringify(cornersOf(solids.boundary[0])) ===
JSON.stringify(['0,3', '0,4', '12,3', '12,4', '6,3', '7,2', '7,3'].sort()),
JSON.stringify(solids?.boundary),
);
await shot('42-wedge-map');
await play();
await evaluate(`(() => {
const out = (window.__climb = []);
const begin = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const e = w.entities.find((x) => x.objectId === 'sh-walker');
out.push({ x: e?.worldX, y: e?.worldY });
if (performance.now() - begin < 4500) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(4800);
const climb = await json('window.__climb');
await stop();
const highest = Math.min(...climb.map((s) => s.y));
check(
"a walker whose Move collides climbs the wedge tile's slope and goes on past it",
climb.length > 100 && highest < MAP_Y - 10 + 1 && highest > MAP_Y - 10 - 3 && climb.at(-1).x > 45,
JSON.stringify({ frames: climb.length, highest, top: MAP_Y - 10, last: climb.at(-1) }),
);
await clearShapes();
// 42e. A collider wears the same frame's outline (#193), linked: shape "Frame collision", the frame
// picked like a texture. It is the frame's size in the scene, and a walker climbs its slope –
// a box of that size would have stopped it at its side.
const F = Y; // the floor's top face; the wedge stands on it, its slope rising to x = 16
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
const texture = assets.find((a) => a.id === ${JSON.stringify(texture?.id)});
texture.frames.find((f) => f.id === 'sh-frame').collision = [[{ x: 0, y: 32 }, { x: 32, y: 0 }, { x: 32, y: 32 }]];
${MAKE_OBJECT}
${put}
const behavior = structuredClone(assets.find((a) => a.id === 'mover'));
Object.assign(behavior, { id: 'sh-climb', name: 'Climb', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(climbGraph)} });
assets.push(behavior);
assets.push({ id: 'sh-col-wedge', type: 'collider', name: 'sh-col-wedge',
props: { shape: 'frame', frame: 'sh-frame', isTrigger: false } });
assets.push({ id: 'sh-col-floor', type: 'collider', name: 'sh-col-floor',
props: { shape: 'box', width: 400, height: 32, isTrigger: false } });
assets.push({ id: 'sh-col-walker', type: 'collider', name: 'sh-col-walker',
props: { shape: 'box', width: 20, height: 20, friction: 0, isTrigger: false } });
put('sh-floor', 'crate', '', 0, ${F + 16}, 'Floor', { width: 20, height: 20 });
put('sh-floor-col', 'sh-col-floor', 'sh-floor', 0, 0);
put('sh-wedge', 'crate', '', 0, ${F - 16}, 'Wedge', { width: 20, height: 20 });
put('sh-wedge-col', 'sh-col-wedge', 'sh-wedge', 0, 0);
put('sh-walker', 'crate', '', -100, ${F - 10}, 'Walker', { width: 20, height: 20 });
put('sh-walker-col', 'sh-col-walker', 'sh-walker', 0, 0);
put('sh-walker-climb', 'sh-climb', 'sh-walker', 0, 0, 'Climb');
__gm.state.select(null)`);
await evaluate(`__gm.lookAt(0, ${F})`);
await settle();
const wedgeRect = await json(`__gm.objectRect('sh-wedge-col')`);
const floorRect = await json(`__gm.objectRect('sh-floor-col')`);
check(
'a collider wearing a frame outline is the size of the frame in the scene, not its own width',
wedgeRect &&
floorRect &&
Math.abs(wedgeRect.w - wedgeRect.h) < 1 &&
Math.abs(floorRect.w / wedgeRect.w - 400 / 32) < 0.2,
JSON.stringify({ wedgeRect, floorRect }),
);
await shot('42-frame-collider');
await play();
await evaluate(`(() => {
const out = (window.__frameClimb = []);
const begin = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const e = w.entities.find((x) => x.objectId === 'sh-walker');
out.push({ x: e?.worldX, y: e?.worldY });
if (performance.now() - begin < 3000) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(3300);
const frameClimb = await json('window.__frameClimb');
await stop();
// Over the wedge's top corner (16, F − 32) the walker's centre stands 10 higher.
const peak = Math.min(...frameClimb.map((s) => s.y));
check(
"a walker whose Move collides climbs the slope drawn in the collider's frame and goes on past it",
frameClimb.length > 100 && peak < F - 42 + 1 && peak > F - 42 - 3 && frameClimb.at(-1).x > 45,
JSON.stringify({ frames: frameClimb.length, peak, top: F - 42, last: frameClimb.at(-1) }),
);
await clearShapes();
// 42f. "Generate collision…" reads the outlines off the picture (#161): a wedge and a faint band
// (alpha 30 %) in one frame, a square in the other. From 50 % the band is not solid, from
// 20 % it is an outline of its own; "All frames" writes both frames in one undo step.
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate(PASTE_SHEET);
await sleep(2500);
const sheet = await json(
"(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" +
' return a ? { id: a.id, w: a.width, h: a.height } : null; })()',
);
await mutate(`
const sheet = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet?.id)});
sheet.frames.push({ id: 'gc-left', name: 'Left', x: 0, y: 0, w: 32, h: 32 },
{ id: 'gc-right', name: 'Right', x: 32, y: 0, w: 32, h: 32 });`);
await select({ kind: 'frame', assetId: sheet?.id, frameId: 'gc-left' });
await showTab('texture');
await settle();
await clickText('Generate collision…');
await settle();
const dialog = () => json('__gm.texture.generateDialog()');
const opened = await dialog();
check(
'Generate collision… opens on the selected frame and names what it would write from 50 % alpha',
sheet?.w === 64 &&
opened?.enabled === true &&
opened.all === false &&
/^Frame "Left": 1 outline, [34] corners$/.test(opened.summary),
JSON.stringify({ sheet, opened }),
);
const retype = async (at, text) => {
await click(at.x, at.y);
await press('a', { ctrl: true });
await t.typeText(text);
await press('Enter');
await settle();
};
await retype(opened.at.threshold, '20');
const faint = await dialog();
await shot('42-generate-dialog');
await retype(opened.at.threshold, '50');
check(
'from 20 % alpha the faint band is solid too and becomes an outline of its own',
/^Frame "Left": 2 outlines, \d+ corners$/.test(faint?.summary ?? '') &&
/^Frame "Left": 1 outline/.test((await dialog())?.summary ?? ''),
JSON.stringify(faint),
);
await click(opened.at.all.x, opened.at.all.y);
await settle();
const both = await dialog();
await clickText('Generate');
await settle();
const outlines = () =>
json(
`Object.fromEntries(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet?.id)})` +
`.frames.map((f) => [f.id, f.collision ?? null]))`,
);
const written = await outlines();
const cornerSet = (ring) => (ring ?? []).map((p) => `${p.x},${p.y}`).sort();
const ringArea = (ring) =>
Math.abs(
ring.reduce(
(sum, p, i) => sum + p.x * ring[(i + 1) % ring.length].y - ring[(i + 1) % ring.length].x * p.y,
0,
),
) / 2;
check(
'with All frames, Generate outlines the wedge and the square of both frames',
both?.all === true &&
/^2 frames: 2 outlines/.test(both.summary) &&
written['gc-left']?.length === 1 &&
written['gc-left'][0].length <= 4 &&
Math.abs(ringArea(written['gc-left'][0]) - 512) < 26 &&
JSON.stringify(cornerSet(written['gc-right']?.[0])) ===
JSON.stringify(['24,24', '24,8', '8,24', '8,8']),
JSON.stringify({ both, written }),
);
await clickText('Collision');
await settle();
await shot('42-generated-outline');
await clickText('Collision');
await undo();
const undone = await outlines();
check(
'one undo takes the outlines of both frames back',
undone['gc-left'] === null && undone['gc-right'] === null,
JSON.stringify(undone),
);
await showTab('scene');
await settle();
}