A timed Set sound FX parameters ran only linearly, and a Lerp had four quadratic eases of its own. An animation key already had the full set. Now all three offer the same choice: Linear, Step, the ten curves from Sine to Bounce with In, Out or In-out, and a drawn Curve asset. - easingParams in src/core/graph/nodes/easing.ts gives a node the fields Easing, Direction and Curve from the key's own lists, and easingOfParams reads them back as a key interpolation. - There is no migration. A Lerp saved with the old in, out or inOut runs linear until its easing is picked again. - Set sound FX parameters hands the easing to the player. Linear and Step stay ramps of the audio clock, every other easing goes to setValueCurveAtTime as 200 samples a second, in semitones and dB. - Step now reaches its end at t 1. A key never asked for that, it reads the next key itself, so animations play as before. - The curve-in-use count and the warning for a gone curve cover the sound node too. Tests: - node-catalog: Lerp with cubic in, quad out and in-out, a curve without a direction, step, an unknown easing as linear. The sound node hands on a curve, step and a gone curve as linear. - sound-library: an eased change becomes a curve in semitones and decibels, a step a jump at the end. - node-catalog and curve after dropping the old values: 399, green. - e2e 6, 34, 53 on melektaus-atx: ALL PASSED, 198 checks. In Chrome a 2 s Quad in from depth 7 to -5 read speed 0.864 early and 0.5 at the end, a 1 s Step held 1 and then jumped to 0.5. - Counter-check: with curves sent as linear ramps, the curve test of the sound library fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
537 lines
22 KiB
JavaScript
537 lines
22 KiB
JavaScript
import { edge, node } from '../lib/builders.mjs';
|
||
|
||
/**
|
||
* Curve assets (#247): made from the + menu and opened by its row, bent in the Curve tab with the
|
||
* real mouse – a point put down on free space by a double-click, turned smooth through the header, a
|
||
* handle pulled, a point taken away by Delete and by its menu –
|
||
* and then picked where a built-in ease is picked, on an animation key and on a `Lerp` node. The
|
||
* values those two produce are compared with the curve worked out here, by walking its Bezier
|
||
* segments, not with the editor's own solver.
|
||
*
|
||
* Everything it adds carries a `cv-` id or is the curve it made, and goes again at the end.
|
||
*/
|
||
|
||
/** The curve's value at `t`, from its segments walked finely and read off in between. */
|
||
function expectedAt(points, t) {
|
||
for (let i = 1; i < points.length; i++) {
|
||
const from = points[i - 1];
|
||
const to = points[i];
|
||
if (t > to.x) continue;
|
||
const c1 = { x: from.x + (from.out?.x ?? 0), y: from.y + (from.out?.y ?? 0) };
|
||
const c2 = { x: to.x + (to.in?.x ?? 0), y: to.y + (to.in?.y ?? 0) };
|
||
const at = (s, a, b, c, d) =>
|
||
(1 - s) ** 3 * a + 3 * (1 - s) ** 2 * s * b + 3 * (1 - s) * s * s * c + s ** 3 * d;
|
||
let previous = { x: from.x, y: from.y };
|
||
for (let step = 1; step <= 20000; step++) {
|
||
const s = step / 20000;
|
||
const here = { x: at(s, from.x, c1.x, c2.x, to.x), y: at(s, from.y, c1.y, c2.y, to.y) };
|
||
if (here.x >= t) {
|
||
const share = here.x > previous.x ? (t - previous.x) / (here.x - previous.x) : 1;
|
||
return previous.y + (here.y - previous.y) * share;
|
||
}
|
||
previous = here;
|
||
}
|
||
return to.y;
|
||
}
|
||
return points.at(-1).y;
|
||
}
|
||
|
||
export async function curves(t) {
|
||
const { check, json, evaluate, mutate, click, doubleClick, drag, settle, sleep, shot, showTab, undo } = t;
|
||
const inOverlay = async (text) => {
|
||
const box = await json('__gm.overlayRect()');
|
||
const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? [];
|
||
return (
|
||
hits.find((h) => h.x >= box.x && h.x <= box.x + box.w && h.y >= box.y && h.y <= box.y + box.h) ?? null
|
||
);
|
||
};
|
||
const menuRow = async (label) => {
|
||
const rows = (await json('__gm.menuRows()')) ?? [];
|
||
const row = rows.find((r) => typeof r === 'string' && (r === label || r.endsWith(` ${label}`)));
|
||
return row ? inOverlay(row) : null;
|
||
};
|
||
const pointsOf = (id) => json(`__gm.curve.points(${JSON.stringify(id)})`);
|
||
|
||
// 53a. The + menu makes one; its row opens the Curve tab on it, a straight line of two points.
|
||
await showTab('scene');
|
||
await t.clickText('+');
|
||
const offered = (await json('__gm.menuRows()')) ?? [];
|
||
const entry = await inOverlay('Curve');
|
||
check('the + menu offers Curve', offered.includes('Curve') && Boolean(entry), JSON.stringify(offered));
|
||
if (!entry) return;
|
||
await click(entry.x, entry.y);
|
||
await settle();
|
||
const curveId = (
|
||
await json("__gm.state.project.assets.filter((a) => a.type === 'curve').map((a) => a.id)")
|
||
).at(-1);
|
||
// "Curve" is the new asset's name, the Easing value and the row label at once – a name of its own
|
||
// keeps `inspectorField('Curve')` on the row.
|
||
await mutate(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(curveId)}).name = 'Swoop 53'`,
|
||
);
|
||
const row = await json(`__gm.assetRow(${JSON.stringify(curveId)})`);
|
||
await doubleClick(row.x, row.y);
|
||
await settle();
|
||
const fresh = await pointsOf(curveId);
|
||
check(
|
||
'a double-click on the new curve opens the Curve tab on a straight line from (0, 0) to (1, 1)',
|
||
(await json('__gm.curve.current()')) === curveId &&
|
||
JSON.stringify(fresh) ===
|
||
JSON.stringify([
|
||
{ x: 0, y: 0 },
|
||
{ x: 1, y: 1 },
|
||
]) &&
|
||
(await json('__gm.curve.info()')) === '2 points',
|
||
JSON.stringify({ current: await json('__gm.curve.current()'), fresh }),
|
||
);
|
||
|
||
// 53b. A double-click on free space puts a point there, and it is the picked one (#288); a click
|
||
// alone adds nothing.
|
||
const spot = await json('__gm.curve.at(0.5, 0.9)');
|
||
// Past the double-click that opened the tab, and again past the click that must not count.
|
||
await sleep(500);
|
||
await click(spot.x, spot.y);
|
||
const onlyClicked = (await pointsOf(curveId))?.length;
|
||
await sleep(500);
|
||
await doubleClick(spot.x, spot.y);
|
||
await settle();
|
||
const put = await pointsOf(curveId);
|
||
const middle = put?.[1];
|
||
check(
|
||
'a double-click on free space puts a point there, between the ends, and picks it – a click adds none',
|
||
onlyClicked === 2 &&
|
||
put?.length === 3 &&
|
||
Math.abs(middle.x - 0.5) < 0.01 &&
|
||
Math.abs(middle.y - 0.9) < 0.01 &&
|
||
!middle.in &&
|
||
!middle.out &&
|
||
(await json('__gm.curve.picked()')) === 1,
|
||
JSON.stringify({ put, picked: await json('__gm.curve.picked()') }),
|
||
);
|
||
|
||
// 53c. Point: Smooth grows its handles, and pulling the right one up bends the curve – the left
|
||
// handle turns with it, the other way round.
|
||
const pointButton = await json('__gm.curve.pointButton()');
|
||
await click(pointButton.x, pointButton.y);
|
||
await sleep(100);
|
||
const smooth = await menuRow('Smooth');
|
||
if (smooth) await click(smooth.x, smooth.y);
|
||
await settle();
|
||
const rounded = await pointsOf(curveId);
|
||
const grown = rounded?.[1];
|
||
check(
|
||
'Point: Smooth grows both handles of the picked point, in line',
|
||
grown?.mode === 'smooth' &&
|
||
Boolean(grown.in && grown.out) &&
|
||
Math.abs(grown.in.x * grown.out.y - grown.in.y * grown.out.x) < 1e-9,
|
||
JSON.stringify({ grown, button: pointButton }),
|
||
);
|
||
if (!grown?.out) return;
|
||
// The handle reaches above what the straight line was fitted to; Fit frames it again.
|
||
const fit = await json('__gm.curve.fitAt()');
|
||
await click(fit.x, fit.y);
|
||
await settle();
|
||
const tip = await json(`__gm.curve.at(${grown.x + grown.out.x}, ${grown.y + grown.out.y})`);
|
||
await drag(tip.x, tip.y, tip.x, tip.y - 60, { steps: 8 });
|
||
await settle();
|
||
const bent = await pointsOf(curveId);
|
||
const pulled = bent?.[1];
|
||
const across = pulled && pulled.in.x * pulled.out.y - pulled.in.y * pulled.out.x;
|
||
check(
|
||
'dragging the right handle up raises it, and the smooth point turns its left handle to match',
|
||
pulled?.out.y > grown.out.y + 0.1 && Math.abs(across) < 1e-6 && pulled.in.x < 0 && pulled.in.y < 0,
|
||
JSON.stringify({ before: grown, after: pulled }),
|
||
);
|
||
await shot('53-curve-tab');
|
||
|
||
// The preview stands still until its button runs it; pressed again, it holds.
|
||
// Three samples over 0.6 s: longer than the half second the dot rests at the end of each pass.
|
||
const sample = async () => {
|
||
const seen = [];
|
||
for (let i = 0; i < 3; i++) {
|
||
if (i > 0) await sleep(300);
|
||
seen.push((await json('__gm.curve.preview()'))?.t);
|
||
}
|
||
return seen;
|
||
};
|
||
const previewButton = await json('__gm.curve.previewButton()');
|
||
const idle = await sample();
|
||
await click(previewButton.x, previewButton.y);
|
||
await settle();
|
||
const runningButton = await json('__gm.curve.previewButton()');
|
||
const running = await sample();
|
||
await click(previewButton.x, previewButton.y);
|
||
await settle();
|
||
const held = [(await json('__gm.curve.preview()'))?.t];
|
||
await sleep(250);
|
||
held.push((await json('__gm.curve.preview()'))?.t);
|
||
check(
|
||
'the preview stands still at first, the Preview button runs it, and pressed again holds it',
|
||
!previewButton.toggled &&
|
||
new Set(idle).size === 1 &&
|
||
runningButton.toggled &&
|
||
new Set(running).size > 1 &&
|
||
!(await json('__gm.curve.previewButton()')).toggled &&
|
||
held[0] === held[1],
|
||
JSON.stringify({ previewButton, idle, running, held }),
|
||
);
|
||
|
||
// The preview's dot sits on the line at its t, and its twin beside the graph at the same height.
|
||
await evaluate('__gm.curve.setPreviewTime(0.35)');
|
||
const preview = await json('__gm.curve.preview()');
|
||
const onLine = await json(`__gm.curve.at(${preview?.t}, ${expectedAt(bent, preview?.t ?? 0)})`);
|
||
check(
|
||
'the preview dot runs along the drawn curve, and its twin beside the graph at the same height',
|
||
Math.abs(preview?.t - 0.35) < 1e-9 &&
|
||
Math.abs(preview.v - expectedAt(bent, 0.35)) < 0.002 &&
|
||
Math.abs(preview.dot.x - onLine.x) < 1 &&
|
||
Math.abs(preview.dot.y - onLine.y) < 1 &&
|
||
Math.abs(preview.bar.y - preview.dot.y) < 1e-6 &&
|
||
preview.bar.x > preview.dot.x,
|
||
JSON.stringify({ preview, onLine }),
|
||
);
|
||
|
||
// 53d. An animation key picks the curve in the inspector, and the preview draws the value it gives.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const sprite = p.assets.find((a) => a.type === 'sprite');
|
||
p.assets.push({ id: 'cv-anim', type: 'animation', name: 'Curve slide', props: { fps: 10, playback: 'once' },
|
||
root: sprite.id,
|
||
tracks: [{ id: 'cv-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 10, value: 100 }] }] });`);
|
||
await evaluate("__gm.openDocument('asset:cv-anim')");
|
||
await settle();
|
||
const cell = await json("__gm.animation.cellAt('cv-row', 0)");
|
||
await click(cell.x, cell.y);
|
||
await settle();
|
||
const easing = await json("__gm.inspectorField('Easing')");
|
||
if (easing) {
|
||
await click(easing.x, easing.y);
|
||
await sleep(100);
|
||
const curveEntry = await menuRow('Curve');
|
||
if (curveEntry) await click(curveEntry.x, curveEntry.y);
|
||
await settle();
|
||
}
|
||
const key = async () =>
|
||
(await json("__gm.state.project.assets.find((a) => a.id === 'cv-anim').tracks[0].keys[0]"))
|
||
?.interpolation;
|
||
const curveRow = await json("__gm.inspectorField('Curve', true)");
|
||
check(
|
||
'Easing: Curve on the key takes the one curve there is and shows it in a Curve row',
|
||
(await key()) === `curve:${curveId}` && Boolean(curveRow),
|
||
JSON.stringify({ interpolation: await key(), curveRow, easing }),
|
||
);
|
||
const rootId = await json("__gm.animation.preview.objectId('')");
|
||
const drawnAt = async (frame) => {
|
||
await evaluate(`__gm.animation.scrub(${frame})`);
|
||
await sleep(100);
|
||
return (await json(`__gm.animation.preview.item(${JSON.stringify(rootId)})`))?.x;
|
||
};
|
||
const drawn = [await drawnAt(3), await drawnAt(5), await drawnAt(8)];
|
||
const wanted = [0.3, 0.5, 0.8].map((share) => 100 * expectedAt(bent, share));
|
||
check(
|
||
'the preview puts the sprite where the curve says, not where a straight line would',
|
||
drawn.every((x, i) => Math.abs(x - wanted[i]) < 0.05) && Math.abs(drawn[1] - 50) > 5,
|
||
JSON.stringify({ drawn, wanted }),
|
||
);
|
||
await shot('53-curve-key');
|
||
|
||
// 53e. A Lerp picks it too – Easing: Curve, then the curve in its row – and the game logs its value.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.push({ id: 'cv-lerpy', type: 'behavior', name: 'Curve lerp', props: { enabled: true, runInEditor: false },
|
||
variables: [], graph: ${JSON.stringify({
|
||
nodes: [
|
||
node('cv-start', 'OnStart', 0, 0),
|
||
node('cv-lerp', 'Lerp', 0, 160, { a: 0, b: 100, t: 0.3 }),
|
||
node('cv-log', 'Log', 300, 0),
|
||
],
|
||
edges: [
|
||
edge('cv-e1', 'cv-start', 'exec', 'cv-log', 'exec'),
|
||
edge('cv-e2', 'cv-lerp', 'result', 'cv-log', 'message'),
|
||
],
|
||
})} });
|
||
const scene = __gm.state.activeScene;
|
||
const holder = structuredClone(scene.objects[0]);
|
||
Object.assign(holder, { id: 'cv-holder', assetId: 'cv-lerpy', parentId: '', name: 'Curve holder',
|
||
position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
|
||
delete holder.groupId;
|
||
scene.objects.push(holder);
|
||
__gm.state.select({ kind: 'node', assetId: 'cv-lerpy', nodeId: 'cv-lerp', nodeIds: ['cv-lerp'], annotationIds: [] });`);
|
||
await showTab('graph');
|
||
await settle();
|
||
const pick = async (field, label) => {
|
||
const at = await json(`__gm.inspectorField(${JSON.stringify(field)}, true)`);
|
||
if (!at) return null;
|
||
await click(at.x, at.y);
|
||
await sleep(100);
|
||
const option = await menuRow(label);
|
||
if (option) await click(option.x, option.y);
|
||
else await t.press('Escape');
|
||
await settle();
|
||
return option;
|
||
};
|
||
await pick('Easing', 'Curve');
|
||
const unpicked = await json("__gm.graph.nodeSettings('cv-lerp')");
|
||
const name = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(curveId)}).name`);
|
||
const picked = await pick('Curve', `${name} (curve)`);
|
||
const params = await json(
|
||
"__gm.state.project.assets.find((a) => a.id === 'cv-lerpy').graph.nodes.find((n) => n.id === 'cv-lerp').params",
|
||
);
|
||
const settings = await json("__gm.graph.nodeSettings('cv-lerp')");
|
||
check(
|
||
'Easing: Curve brings the Curve row onto the node, and picking the curve there sets it',
|
||
params?.ease === 'curve' &&
|
||
params.curve === curveId &&
|
||
Boolean(picked) &&
|
||
unpicked?.length === 2 &&
|
||
settings?.some((s) => s.includes(name)),
|
||
JSON.stringify({ params, unpicked, settings, picked }),
|
||
);
|
||
await shot('53-curve-lerp');
|
||
const logFrom = t.consoleLog.length;
|
||
await t.play();
|
||
await sleep(600);
|
||
await t.stop();
|
||
const logged = t.consoleLog
|
||
.slice(logFrom)
|
||
.filter((line) => line.startsWith('[behavior Curve holder]'))
|
||
.map((line) => Number(line.split(']').pop()));
|
||
const lerpWanted = 100 * expectedAt(bent, 0.3);
|
||
check(
|
||
'the running Lerp gives what the curve says at t 0.3',
|
||
logged.length === 1 && Math.abs(logged[0] - lerpWanted) < 0.05 && Math.abs(logged[0] - 30) > 1,
|
||
JSON.stringify({ logged, lerpWanted }),
|
||
);
|
||
|
||
// 53f. Undo takes the pick back to the plain Lerp; then everything made here goes.
|
||
await showTab('graph');
|
||
await undo();
|
||
await settle();
|
||
const undone = await json(
|
||
"__gm.state.project.assets.find((a) => a.id === 'cv-lerpy').graph.nodes.find((n) => n.id === 'cv-lerp').params",
|
||
);
|
||
check('undo takes the picked curve back off the Lerp', !undone?.curve, JSON.stringify(undone));
|
||
|
||
// 53g. A preset starts the curve over from a built-in ease, and the key follows it at once.
|
||
await evaluate(`__gm.openDocument('asset:${curveId}')`);
|
||
await settle();
|
||
const presetButton = await json('__gm.curve.presetButton()');
|
||
await click(presetButton.x, presetButton.y);
|
||
await sleep(100);
|
||
const presets = (await json('__gm.menuRows()')) ?? [];
|
||
// Thirty-one entries run past the bottom of the popup; its search box is the way to the last ones.
|
||
await t.typeText('bounce out');
|
||
await t.press('Enter');
|
||
await settle();
|
||
const bounced = await pointsOf(curveId);
|
||
await evaluate("__gm.openDocument('asset:cv-anim')");
|
||
await settle();
|
||
const bouncedAt = await drawnAt(5);
|
||
check(
|
||
'Preset → Bounce out replaces the curve with the traced ease, and the key follows it (76.6 at the middle)',
|
||
presets.filter((r) => typeof r === 'string').length === 31 &&
|
||
bounced?.length > 8 &&
|
||
Math.abs(bouncedAt - 76.5625) < 0.4,
|
||
JSON.stringify({ presets: presets.length, points: bounced?.length, bouncedAt }),
|
||
);
|
||
await shot('53-curve-preset');
|
||
|
||
await headerAndScrub(t, curveId, bounced);
|
||
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('cv-'));
|
||
p.assets = p.assets.filter((a) => !a.id.startsWith('cv-') && a.id !== ${JSON.stringify(curveId)});
|
||
__gm.state.select(null)`);
|
||
await showTab('scene');
|
||
}
|
||
|
||
/**
|
||
* 53h–53k on the Bounce out curve the preset left: the picked point's `t` and `v` fields, the plain
|
||
* arrow over points, the magnet, and the preview dragged by its knob and by its dot beside the graph.
|
||
*/
|
||
async function headerAndScrub(t, curveId, bounced) {
|
||
const { check, json, evaluate, click, doubleClick, drag, press, settle, sleep, shot, undo } = t;
|
||
const pointsOf = () => json(`__gm.curve.points(${JSON.stringify(curveId)})`);
|
||
const at = (tt, v) => json(`__gm.curve.at(${tt}, ${v})`);
|
||
const onGrid = (n) => Math.abs(n / 0.05 - Math.round(n / 0.05)) < 1e-6;
|
||
await evaluate(`__gm.openDocument('asset:${curveId}')`);
|
||
await settle();
|
||
const fit = await json('__gm.curve.fitAt()');
|
||
await click(fit.x, fit.y);
|
||
await settle();
|
||
// Somewhere left of t 0: no point goes there, so a click only drops the pick.
|
||
const aside = await at(-0.08, 0.5);
|
||
|
||
// 53h. A picked point shows its t and v in the header; typed in, it goes there, one undo step each.
|
||
const target = bounced[2];
|
||
const spot = await at(target.x, target.y);
|
||
await click(spot.x, spot.y);
|
||
await settle();
|
||
const shownT = await json("__gm.curve.placeField('t')");
|
||
const shownV = await json("__gm.curve.placeField('v')");
|
||
const typeInto = async (axis, text) => {
|
||
const field = await json(`__gm.curve.placeField(${JSON.stringify(axis)})`);
|
||
await click(field.x, field.y);
|
||
await t.typeText(text);
|
||
await t.press('Enter');
|
||
await settle();
|
||
};
|
||
const middle = ((bounced[1].x + bounced[3].x) / 2).toFixed(3);
|
||
await typeInto('t', middle);
|
||
await typeInto('v', '0.42');
|
||
const typed = await pointsOf();
|
||
await click(aside.x, aside.y);
|
||
await undo();
|
||
const oneBack = await pointsOf();
|
||
await undo();
|
||
const twoBack = await pointsOf();
|
||
check(
|
||
'the t and v fields show the picked point, move it when typed into, and each is one undo step',
|
||
(await json('__gm.curve.picked()')) === -1 &&
|
||
shownT.enabled &&
|
||
Math.abs(Number(shownT.text) - target.x) < 0.001 &&
|
||
Math.abs(Number(shownV.text) - target.y) < 0.001 &&
|
||
typed?.length === bounced.length &&
|
||
typed[2].x === Number(middle) &&
|
||
typed[2].y === 0.42 &&
|
||
oneBack[2].x === Number(middle) &&
|
||
oneBack[2].y === target.y &&
|
||
JSON.stringify(twoBack) === JSON.stringify(bounced),
|
||
JSON.stringify({
|
||
shownT,
|
||
shownV,
|
||
middle,
|
||
typed: typed?.[2],
|
||
oneBack: oneBack?.[2],
|
||
twoBack: twoBack?.[2],
|
||
}),
|
||
);
|
||
|
||
// 53i. An end keeps its t: the field is off, its v is not. Over a point the arrow stays plain.
|
||
const start = await at(0, bounced[0].y);
|
||
await click(start.x, start.y);
|
||
await settle();
|
||
const endT = await json("__gm.curve.placeField('t')");
|
||
const endV = await json("__gm.curve.placeField('v')");
|
||
await t.mouse('mouseMoved', start.x, start.y);
|
||
await settle();
|
||
const overPoint = await json('__gm.curve.cursor()');
|
||
const free = await at(0.5, 0.2);
|
||
await t.mouse('mouseMoved', free.x, free.y);
|
||
await settle();
|
||
const overSpace = await json('__gm.curve.cursor()');
|
||
check(
|
||
"an end's t field is off and its v field on; over a point the cursor stays the plain arrow",
|
||
!endT.enabled && endV.enabled && overPoint === 'default' && overSpace === 'crosshair',
|
||
JSON.stringify({ endT, endV, overPoint, overSpace }),
|
||
);
|
||
|
||
// 53j. With the magnet on, a point put down and then dragged lands on the 0.05 grid in t and v.
|
||
const magnet = await json('__gm.curve.snapButton()');
|
||
await click(magnet.x, magnet.y);
|
||
await settle();
|
||
const put = await at(0.337, 0.412);
|
||
await sleep(500);
|
||
await doubleClick(put.x, put.y);
|
||
await settle();
|
||
const index = await json('__gm.curve.picked()');
|
||
const dropped = (await pointsOf())?.[index];
|
||
await drag(put.x, put.y, put.x + 3, put.y - 37, { steps: 8 });
|
||
const dragged = (await pointsOf())?.[index];
|
||
const magnetOn = await json('__gm.curve.snapButton()');
|
||
await click(magnet.x, magnet.y);
|
||
await click(aside.x, aside.y);
|
||
await undo();
|
||
await undo();
|
||
const restored = await pointsOf();
|
||
check(
|
||
'with the magnet on a point is put and dragged onto the 0.05 grid of t and v',
|
||
!magnet.toggled &&
|
||
magnetOn.toggled &&
|
||
!(await json('__gm.curve.snapButton()')).toggled &&
|
||
Math.abs(dropped?.x - 0.35) < 1e-6 &&
|
||
Math.abs(dropped?.y - 0.4) < 1e-6 &&
|
||
onGrid(dragged?.x) &&
|
||
onGrid(dragged?.y) &&
|
||
dragged.y > dropped.y + 0.04 &&
|
||
JSON.stringify(restored) === JSON.stringify(bounced),
|
||
JSON.stringify({ magnet, dropped, dragged, restored: restored?.length }),
|
||
);
|
||
|
||
// 53j-ii. Taking a point away (#288): Delete takes the picked one, and a right-click on a point
|
||
// opens its menu – Delete point and its type; an end's Delete point is greyed out.
|
||
const between = (bounced[0].x + bounced[1].x) / 2;
|
||
const onLine = await at(between, expectedAt(bounced, between));
|
||
await sleep(500);
|
||
await doubleClick(onLine.x, onLine.y);
|
||
const added = await pointsOf();
|
||
const pickedNew = await json('__gm.curve.picked()');
|
||
await press('Delete');
|
||
const removed = await pointsOf();
|
||
const inner = await at(bounced[2].x, bounced[2].y);
|
||
await click(inner.x, inner.y, 'right');
|
||
const pointMenu = await json('__gm.menuRows()');
|
||
const innerOn = await json('__gm.menuEnabled()');
|
||
const pickedByMenu = await json('__gm.curve.picked()');
|
||
await press('Escape');
|
||
const endAt = await at(0, bounced[0].y);
|
||
await click(endAt.x, endAt.y, 'right');
|
||
const endOn = await json('__gm.menuEnabled()');
|
||
await press('Escape');
|
||
await click(inner.x, inner.y, 'right');
|
||
await t.clickText('Delete point');
|
||
const byMenu = await pointsOf();
|
||
await undo();
|
||
await undo();
|
||
await undo();
|
||
await click(aside.x, aside.y);
|
||
check(
|
||
'a double-click on the line adds a picked point, Delete and the menu of a point take one away, never an end',
|
||
added?.length === bounced.length + 1 &&
|
||
pickedNew === 1 &&
|
||
removed?.length === bounced.length &&
|
||
JSON.stringify(pointMenu) ===
|
||
JSON.stringify(['Delete point', null, 'Straight', 'Corner', 'Smooth', 'Symmetric']) &&
|
||
innerOn?.[0] === true &&
|
||
pickedByMenu === 2 &&
|
||
endOn?.[0] === false &&
|
||
byMenu?.length === bounced.length - 1 &&
|
||
JSON.stringify(await pointsOf()) === JSON.stringify(bounced),
|
||
JSON.stringify({
|
||
added: added?.length,
|
||
pickedNew,
|
||
removed: removed?.length,
|
||
pointMenu,
|
||
innerOn,
|
||
pickedByMenu,
|
||
endOn,
|
||
byMenu: byMenu?.length,
|
||
}),
|
||
);
|
||
|
||
// 53k. Held, the preview moves only when dragged: its knob sideways sets t, its dot beside the
|
||
// graph up or down jumps to the first t the curve reaches that height. The curve stays.
|
||
const before = await json('__gm.curve.preview()');
|
||
const seventy = await at(0.7, 0);
|
||
await drag(before.knob.x, before.knob.y, seventy.x, before.knob.y, { steps: 10 });
|
||
const scrubbed = await json('__gm.curve.preview()');
|
||
const half = await at(0, 0.5);
|
||
await drag(scrubbed.bar.x, scrubbed.bar.y, scrubbed.bar.x, half.y, { steps: 10 });
|
||
const jumped = await json('__gm.curve.preview()');
|
||
await shot('53-curve-scrub');
|
||
const earlier = expectedAt(bounced, Math.max(jumped.t - 0.02, 0));
|
||
check(
|
||
'dragging the knob puts the preview at that t, dragging its twin jumps to where the curve first reaches that value',
|
||
!(await json('__gm.curve.previewButton()')).toggled &&
|
||
Math.abs(scrubbed.t - 0.7) < 0.01 &&
|
||
Math.abs(scrubbed.v - expectedAt(bounced, scrubbed.t)) < 0.002 &&
|
||
Math.abs(jumped.v - 0.5) < 0.01 &&
|
||
earlier < 0.5 &&
|
||
jumped.t < scrubbed.t &&
|
||
JSON.stringify(await pointsOf()) === JSON.stringify(bounced),
|
||
JSON.stringify({ before, scrubbed, jumped, earlier }),
|
||
);
|
||
}
|