Files
adminandClaude Opus 5.5 e933fad446 Give a shader room to draw around the picture it wears (#69)
A shader ran only inside the rectangle of what wears it: the quad
has the size of the sprite, and outside of it no fragment runs. An
outline or a glow built with Sample texture was cut off at the
border. The issue still spoke of Filter.padding, but since #77 the
shader is no filter any more, the limit is the geometry.

- The Output node has a number Padding next to Edge: so many pixels
  of the picture per side the wearer draws beyond it. 0 is the
  default and draws as before, 256 is the most.
- The quad grows and its UVs grow with it, so the picture keeps its
  place and size. In the margin the UVs run on below 0 and above 1.
- Transparent is a fourth Edge mode: outside of the picture there
  is nothing. It is cut in the fragment (texelAt), so it needs no
  copy of the picture with an address mode of its own.
- Edge holds everywhere alike, also for Texture color, Texture
  alpha and unconnected inputs.
- Padding above 0 with Edge on Clamp puts the hint "Padding with
  Clamp smears the border" on the Output. The shader still
  compiles. A chosen Clamp and the default cannot be told apart.
- All four wearers draw the margin. A sprite counts in pixels of
  its frame, a text in its units, a particle source grows every
  particle about its centre.
- A nine-slice sprite gets a ring around its nine fields, as wide
  as so many border pixels. The fields themselves stay.
- Without a picture a pixel is one unit of the wearer's own size,
  or of the particle size.
- Particles now take the Edge mode of their shader. It had no
  effect on them before.
- An object wearing a padded shader is never culled, or its margin
  would vanish at the edge of the screen.
- The preview in the shader tab shows the margin and leaves room
  for it.
- Picking, the selection frame, the size and the collider stay with
  the rectangle without the margin.

Tests:
- Gate on melektaus-atx right before this commit: 216 files,
  3336 tests (3319 + 17), exit 0.
- e2e 28,30,31,37,41 on melektaus-atx: 178 checks, all pass.
  e2e 1-37,47,48,58 ran before the last change (no culling with
  padding): 1281 checks, all pass.
- 17 new unit tests: the compiler (padding, Transparent, the hint)
  and the geometry (quad, nine-slice ring).
- Seven new e2e checks: four at the sprite in module 28, one each
  for text, particles and nine-slice.
- 17 unit counter-probes all fail their tests. Three browser
  counter-probes fail their checks (5, 3 and 1 of them).
- Three screenshots looked at: the sprite with Clamp and margin in
  the scene, the preview in the shader tab, the text.
- The e2e sum of 1687 is counted (1680 + 7), the full suite and
  the smokes did not run.
- Not seen: the hint at the node in a picture, particles and
  nine-slice in a picture, the game view while playing, sprites
  without a picture, vector and pixel art pictures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:33:20 +02:00

334 lines
15 KiB
JavaScript
Raw Permalink 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 { colourDistance } from '../lib/harness.mjs';
/**
* Nine-slice frames (#41). The borders live on the frame of a texture, and what only a running
* editor can answer is in here: that a corner keeps its pixel size while the box grows, that the
* same picture without borders does not, and that "repeat" really tiles the middle instead of
* stretching it. All three are read off the screen, because all three are about the geometry a
* sprite is drawn with – no unit test can see that.
*/
/**
* A 24×24 button: an 8-pixel red ring around a blue middle, and a 2-pixel white stripe down the
* left of that middle. The stripe is what tells a tiled middle from a stretched one – tiled it
* comes back every 8 units, stretched there is exactly one of it.
*/
const PASTE_BUTTON = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 24;
canvas.height = 24;
const g = canvas.getContext('2d');
g.fillStyle = '#e63c3c'; g.fillRect(0, 0, 24, 24);
g.fillStyle = '#466ef0'; g.fillRect(8, 8, 8, 8);
g.fillStyle = '#ffffff'; g.fillRect(8, 8, 2, 8);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'button.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
const RED = '#e63c3c';
const BLUE = '#466ef0';
const WHITE = '#ffffff';
const SIZE = 200;
export async function nineSlice(t) {
const { check, sleep, json, evaluate, mutate, clickText, drag, press, shot, showTab, settle } = t;
const { undo, redo, select, has } = t;
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)');
// 37a. A picture of its own, and one frame over the whole of it: the borders are a property of
// the frame, so a texture nobody cut has nothing to slice.
await evaluate(PASTE_BUTTON);
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 button picture is imported at its own size',
texture?.w === 24 && texture?.h === 24,
JSON.stringify(texture),
);
await mutate(`
const texture = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)});
texture.props.filter = 'nearest';
texture.frames.push({ id: 'ns-frame', name: 'Button', x: 0, y: 0, w: 24, h: 24 });`);
await evaluate(
`__gm.state.select({ kind: 'frame', assetId: ${JSON.stringify(texture.id)}, frameId: 'ns-frame' })`,
);
await showTab('texture');
await settle();
const frame = () =>
json(
`(() => { const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})` +
`.frames.find((f) => f.id === 'ns-frame');` +
' return { x: f.x, y: f.y, w: f.w, h: f.h, border: f.border ?? null, fill: f.fill ?? null }; })()',
);
check('a frame starts unsliced, with no borders at all', (await frame()).border === null);
// 37b. Dragging the border lines in the Texture tab. Grabbed away from the frame's own handles:
// at zero the line sits on the frame's edge, and the handle there resizes the frame itself.
const before = await frame();
const from = await json('__gm.texture.pointAt(0, 6)');
const to = await json('__gm.texture.pointAt(8, 6)');
await drag(from.x, from.y, to.x, to.y);
await settle();
const topFrom = await json('__gm.texture.pointAt(6, 0)');
const topTo = await json('__gm.texture.pointAt(6, 8)');
await drag(topFrom.x, topFrom.y, topTo.x, topTo.y);
await settle();
const dragged = await frame();
check(
'dragging the two lines sets the borders and leaves the frame where it was',
dragged.border?.left === 8 &&
dragged.border?.top === 8 &&
dragged.x === before.x &&
dragged.y === before.y &&
dragged.w === before.w &&
dragged.h === before.h,
JSON.stringify(dragged),
);
await shot('37-frame-borders');
// One drag, one entry – and the last one takes the keys away again rather than writing zeros:
// a frame nobody sliced must read exactly as it did before, or every document would grow four
// numbers it never asked for.
await undo();
const halfUndone = await frame();
await undo();
const undone = await frame();
check(
'each border drag is one undo step, and the last one leaves no slice at all',
halfUndone.border?.left === 8 && halfUndone.border?.top === 0 && undone.border === null,
JSON.stringify({ halfUndone, undone }),
);
await redo();
await redo();
// 37b-ii. The sort line (#228) sits on the bottom edge with the bottom border and the resize
// handle, so it is dragged by its knob left of the frame: 6 pixels up, one undo step.
const sortLine = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})` +
`.frames.find((f) => f.id === 'ns-frame').sortLine ?? null`,
);
const knob = (await json('__gm.texture.drawInfo()')).sortKnob;
const knobTo = await json('__gm.texture.pointAt(0, 18)');
await drag(knob.x, knob.y, knob.x, knobTo.y);
await settle();
const lifted = await sortLine();
const afterLift = await frame();
await undo();
const dropped = await sortLine();
check(
'dragging the knob sets the sort line and leaves the frame and its borders, undo takes it away',
lifted === 6 && dropped === null && afterLift.h === 24 && afterLift.border?.bottom === 0,
JSON.stringify({ knob, lifted, dropped, afterLift }),
);
check('the sort line is a row of the frame inspector', await has('Sort line'));
await mutate(`
const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})
.frames.find((f) => f.id === 'ns-frame');
f.border = { left: 8, top: 8, right: 8, bottom: 8 };`);
// 37c. A sprite wearing the frame, far bigger than the picture. Cloned from one that exists,
// because a literal misses the fields the viewport needs (see step 12).
await mutate(`
const project = __gm.state.project;
const sprite = structuredClone(project.assets.find((a) => a.type === 'sprite'));
sprite.id = 'ns-sprite';
sprite.name = 'Button';
sprite.props = { ...sprite.props, width: ${SIZE}, height: ${SIZE}, color: 0xffffff, texture: 'ns-frame' };
project.assets.push(sprite);
const scene = __gm.state.activeScene;
const object = structuredClone(scene.objects.find((o) => o.id === 'otex') ?? scene.objects[0]);
object.id = 'ons';
object.name = 'Button';
object.assetId = 'ns-sprite';
object.overrides = {};
object.position = { ...object.position, z: 950 };
scene.objects.push(object);
// The stage to itself: the fixture's other objects draw gizmos over everything – a collider
// outline, the behavior icons – and a pixel reading cannot tell those from the picture.
window.__nsOthers = scene.objects.filter((o) => o.id !== 'ons');
scene.objects = scene.objects.filter((o) => o.id === 'ons');
__gm.state.select(null)`);
await showTab('scene');
await sleep(600);
const box = await json("__gm.objectRect('ons')");
check('the nine-sliced sprite is on screen', Boolean(box) && box.w > 100, JSON.stringify(box));
if (!box) return;
const scale = box.w / SIZE;
// A run of pixels from just inside the left edge: one read rather than a hundred round trips.
// Across the middle of the sprite, which is where the parts between the corners are.
const rowY = Math.round(box.y);
const rowAcross = (count) => json(`__gm.pixelRow(${Math.round(box.x - box.w / 2 + 1)}, ${rowY}, ${count})`);
const runOf = (row, colour) => {
let n = 0;
while (n < row.length && colourDistance(row[n], colour) < 60) n++;
return n;
};
const sliced = await rowAcross(120);
const slicedEdge = runOf(sliced, RED);
check(
'the border keeps its own pixel size while the sprite is eight times the picture',
Math.abs(slicedEdge - 8 * scale) <= 2 && colourDistance(sliced[Math.round(100 * scale)], BLUE) < 60,
JSON.stringify({ run: slicedEdge, expected: 8 * scale, row: sliced.slice(0, 16) }),
);
await shot('38-nine-sliced-sprite');
// The counter-proof: the very same picture without borders. Without it, "the edge is 8 wide"
// could just as well mean the measurement is wrong.
await mutate(`
const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})
.frames.find((f) => f.id === 'ns-frame');
delete f.border;`);
await sleep(400);
const stretched = await rowAcross(120);
const stretchedEdge = runOf(stretched, RED);
check(
'and without the borders the very same picture scales its edge with the box',
stretchedEdge > 8 * scale * 4 && Math.abs(stretchedEdge - (8 / 24) * SIZE * scale) <= 4,
JSON.stringify({ sliced: slicedEdge, stretched: stretchedEdge }),
);
// 37d. Repeat against stretch, picked in the inspector. The white stripe down the left of the
// middle is the witness: tiled there is one per 8 units, stretched there is one of it.
// Repeat is the default, so a frame nobody picked a fill for already tiles.
await mutate(`
const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})
.frames.find((f) => f.id === 'ns-frame');
f.border = { left: 8, top: 8, right: 8, bottom: 8 };`);
const stripes = async () => {
const row = await rowAcross(Math.round(SIZE * scale) - 2);
let runs = 0;
for (let i = 0; i < row.length; i++) {
const white = colourDistance(row[i], WHITE) < 60;
const wasWhite = i > 0 && colourDistance(row[i - 1], WHITE) < 60;
if (white && !wasWhite) runs++;
}
return runs;
};
const repeatedStripes = await stripes();
await evaluate(
`__gm.state.select({ kind: 'frame', assetId: ${JSON.stringify(texture.id)}, frameId: 'ns-frame' })`,
);
await showTab('texture');
await clickText('Repeat');
// Repeat first: the default leads the list.
const offered = (await json('__gm.overlayTexts()')).filter((text) => /^(Repeat|Stretch)$/.test(text));
await clickText('Stretch');
await settle();
const picked = await frame();
check(
'the inspector offers repeat before stretch, and picking stretch writes the frame',
picked.fill === 'stretch' && offered.join() === 'Repeat,Stretch',
JSON.stringify({ picked, offered }),
);
await showTab('scene');
await sleep(400);
const stretchedStripes = await stripes();
check(
'a frame repeats unless told otherwise, and stretch draws the middle once',
repeatedStripes > 15 && stretchedStripes === 1,
JSON.stringify({ repeated: repeatedStripes, stretched: stretchedStripes }),
);
await shot('39-nine-slice-repeat');
// 37e. Narrower than its own two borders: they are scaled down together until the corners meet,
// so the row across it is all corner and no middle. Kept tall, so the reading stays in the
// band the gizmos leave alone.
await mutate(`
Object.assign(__gm.state.project.assets.find((a) => a.id === 'ns-sprite').props, { width: 8 });`);
await sleep(400);
const small = await json("__gm.objectRect('ons')");
const seam = await json(
`__gm.pixelRow(${Math.round(small.x - small.w / 2 + 1)}, ${rowY}, ${Math.max(2, Math.round(small.w) - 2)})`,
);
check(
'a sprite narrower than its two borders is all corner, with no middle between them',
seam.length > 2 && seam.every((c) => colourDistance(c, RED) < 60),
JSON.stringify({ w: small.w, seam }),
);
// 37f. The texture's nine-slice scale (#96): the same 8-pixel border, drawn 3 units per pixel.
// Set on the texture and not on the frame, so the sprite must follow a value it never names.
await mutate(`
Object.assign(__gm.state.project.assets.find((a) => a.id === 'ns-sprite').props, { width: ${SIZE} });
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.sliceScale = 3;`);
await sleep(400);
const tripled = runOf(await rowAcross(120), RED);
await mutate(`
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.sliceScale = 1;`);
await sleep(400);
const single = runOf(await rowAcross(120), RED);
check(
"the texture's nine-slice scale draws every border that many times bigger, and 1 undoes it",
Math.abs(tripled - 24 * scale) <= 2 && Math.abs(single - 8 * scale) <= 2,
JSON.stringify({ tripled, single, expected: [24 * scale, 8 * scale] }),
);
await shot('40-nine-slice-scale');
await select({ kind: 'asset', assetId: texture.id });
check("the scale is a row of the texture's inspector", await has('Nine-slice scale'));
// 37g. A shader's Padding on a sliced sprite (#69): a ring around the nine parts, as wide as that
// many border pixels. With Edge Clamp the frame's red rim goes on through it, so the ring
// shows as red left of the box – and the border inside keeps its 8 pixels.
await mutate(`
__gm.state.project.assets.push({
id: 'ns-shader', type: 'shader', name: 'Rim', props: {},
graph: { nodes: [
{ id: 'ns-texel', type: 'ShaderTextureColor', x: 0, y: 0, params: {} },
{ id: 'ns-out', type: 'ShaderOutput', x: 260, y: 0, params: { edge: 'clamp', padding: 0 } },
], edges: [
{ id: 'ns-e1', from: { node: 'ns-texel', port: 'color' }, to: { node: 'ns-out', port: 'color' } },
] },
});
__gm.state.project.assets.find((a) => a.id === 'ns-sprite').props.shader = 'ns-shader';
__gm.state.select(null)`);
const rim = async (padding) => {
await mutate(`
__gm.state.project.assets.find((a) => a.id === 'ns-shader')
.graph.nodes.find((n) => n.id === 'ns-out').params.padding = ${padding};`);
await sleep(400);
// From twelve units left of the box to twelve units into it.
const reach = Math.round(12 * scale);
const row = await json(`__gm.pixelRow(${Math.round(box.x - box.w / 2) - reach}, ${rowY}, ${2 * reach})`);
const outside = row.slice(0, reach).filter((c) => colourDistance(c, RED) < 60).length;
const inside = row.slice(reach).filter((c) => colourDistance(c, RED) < 60).length;
return { outside, inside };
};
const tight = await rim(0);
const ringed = await rim(4);
await shot('41-nine-slice-padding');
check(
'a shader’s padding puts a ring of four border pixels around a sliced sprite and leaves its parts alone',
tight.outside === 0 &&
Math.abs(ringed.outside - 4 * scale) <= 2 &&
Math.abs(tight.inside - 8 * scale) <= 2 &&
Math.abs(ringed.inside - 8 * scale) <= 2,
JSON.stringify({ tight, ringed, expected: [4 * scale, 8 * scale] }),
);
await mutate(`
const project = __gm.state.project;
delete project.assets.find((a) => a.id === 'ns-sprite').props.shader;
project.assets = project.assets.filter((a) => a.id !== 'ns-shader');`);
// The scene back as it was, without the sprite this step brought: later readers count what is in it.
await mutate(`
__gm.state.activeScene.objects = window.__nsOthers ?? [];
delete window.__nsOthers;
__gm.state.select(null)`);
await settle();
}