A frame with borders tiles unless told to stretch
The fill of a sliced frame defaulted to `Stretch`. It is `Repeat` now, and the Fill dropdown lists `Repeat` first. The saved document keeps writing only the fill that differs from the default, so the key it carries is now `stretch`, and a frame without it repeats. A frame saved before with the key absent and meaning stretch would therefore tile; none of the stored projects has one - both sliced frames on the server carry `fill: "repeat"` explicitly, so they draw as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94a0a578ed
commit
bf0be66c9d
6 files changed
+54
-27
No files matched your search
@@ -117,7 +117,7 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n
|
||||
}
|
||||
|
||||
export const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
export const DEFAULT_FILL: FrameFill = 'stretch';
|
||||
export const DEFAULT_FILL: FrameFill = 'repeat';
|
||||
|
||||
/** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */
|
||||
export const MIN_SLICE_MIDDLE = 1;
|
||||
@@ -127,7 +127,7 @@ export function frameBorders(frame: TextureFrame): FrameBorders {
|
||||
}
|
||||
|
||||
export function frameFill(frame: TextureFrame): FrameFill {
|
||||
return frame.fill === 'repeat' ? 'repeat' : DEFAULT_FILL;
|
||||
return frame.fill === 'stretch' ? 'stretch' : DEFAULT_FILL;
|
||||
}
|
||||
|
||||
export function hasBorders(border: FrameBorders): boolean {
|
||||
@@ -181,9 +181,9 @@ export function setFrameSlice(frame: TextureFrame, slice: FrameSlice): void {
|
||||
const border = normalizeBorders(slice.border, frame.w, frame.h);
|
||||
if (hasBorders(border)) frame.border = border;
|
||||
else delete frame.border;
|
||||
// Kept even without borders: a fill chosen before the borders are dragged would otherwise
|
||||
// snap back to "stretch" under the hand that just set it.
|
||||
if (slice.fill === 'repeat') frame.fill = 'repeat';
|
||||
// Only the fill that differs from `DEFAULT_FILL` is written. Kept even without borders: a fill
|
||||
// chosen before the borders are dragged would otherwise snap back under the hand that just set it.
|
||||
if (slice.fill !== DEFAULT_FILL) frame.fill = slice.fill;
|
||||
else delete frame.fill;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,13 @@ import {
|
||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||
import { readScope } from '../model/Scope';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile, normalizeBorders, normalizeFrameRect, setFrameSlice } from '../model/texture';
|
||||
import {
|
||||
DEFAULT_FILL,
|
||||
isTextureFile,
|
||||
normalizeBorders,
|
||||
normalizeFrameRect,
|
||||
setFrameSlice,
|
||||
} from '../model/texture';
|
||||
import { hasParentCycle } from '../model/walk';
|
||||
import {
|
||||
DECLARED_PORT_KINDS,
|
||||
@@ -448,7 +454,7 @@ function normalizeTexture(
|
||||
rect.w,
|
||||
rect.h,
|
||||
),
|
||||
fill: f.fill === 'repeat' ? 'repeat' : 'stretch',
|
||||
fill: f.fill === 'stretch' ? 'stretch' : DEFAULT_FILL,
|
||||
});
|
||||
frames.push(frame);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import {
|
||||
DEFAULT_FILL,
|
||||
frameBorders,
|
||||
frameFill,
|
||||
frameRect,
|
||||
@@ -125,16 +126,16 @@ function fillRow(host: InspectorHost, assetId: string, frameId: string): void {
|
||||
key: 'fill',
|
||||
label: 'Fill',
|
||||
kind: 'enum',
|
||||
default: 'stretch',
|
||||
default: DEFAULT_FILL,
|
||||
options: [
|
||||
{ value: 'stretch', label: 'Stretch' },
|
||||
{ value: 'repeat', label: 'Repeat' },
|
||||
{ value: 'stretch', label: 'Stretch' },
|
||||
],
|
||||
},
|
||||
frame ? frameFill(frame) : 'stretch',
|
||||
frame ? frameFill(frame) : DEFAULT_FILL,
|
||||
sliceHandlers(host, assetId, frameId, (input, slice) => ({
|
||||
...slice,
|
||||
fill: textOf(input) === 'repeat' ? 'repeat' : 'stretch',
|
||||
fill: textOf(input) === 'stretch' ? 'stretch' : 'repeat',
|
||||
})),
|
||||
);
|
||||
host.fields.set('fill', row);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '../../src/core/commands/frameCommands';
|
||||
import type { TextureAsset, TextureFrame } from '../../src/core/model/Asset';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { DEFAULT_SLICE, findFrame, type FrameRect } from '../../src/core/model/texture';
|
||||
import { DEFAULT_SLICE, findFrame, frameFill, type FrameRect } from '../../src/core/model/texture';
|
||||
import { makeProject } from '../helpers';
|
||||
|
||||
const FILE = `${'ab'.repeat(32)}.png`;
|
||||
@@ -275,11 +275,11 @@ describe('SliceFramesCommand', () => {
|
||||
sheet.stack.execute(
|
||||
new SetFrameSliceCommand(sheet.textureId, frameId, {
|
||||
border: { left: 8, top: 8, right: 8, bottom: 8 },
|
||||
fill: 'repeat',
|
||||
fill: 'stretch',
|
||||
}),
|
||||
);
|
||||
expect(frame().border).toEqual({ left: 8, top: 8, right: 8, bottom: 8 });
|
||||
expect(frame().fill).toBe('repeat');
|
||||
expect(frame().fill).toBe('stretch');
|
||||
|
||||
sheet.stack.undo();
|
||||
// Absent, not zero: a frame nobody sliced saves exactly as it did before #41.
|
||||
@@ -287,6 +287,22 @@ describe('SliceFramesCommand', () => {
|
||||
expect(frame().fill).toBeUndefined();
|
||||
});
|
||||
|
||||
it('repeats unless told to stretch, and writes the fill only when it is not the default', () => {
|
||||
const sheet = makeSheet();
|
||||
const frameId = addFrame(sheet, { x: 0, y: 0, w: 24, h: 24 });
|
||||
const frame = () => sheet.frames().find((f) => f.id === frameId)!;
|
||||
expect(frameFill(frame())).toBe('repeat');
|
||||
|
||||
sheet.stack.execute(
|
||||
new SetFrameSliceCommand(sheet.textureId, frameId, {
|
||||
border: { left: 8, top: 8, right: 8, bottom: 8 },
|
||||
fill: 'repeat',
|
||||
}),
|
||||
);
|
||||
expect(frame().fill).toBeUndefined();
|
||||
expect(frameFill(frame())).toBe('repeat');
|
||||
});
|
||||
|
||||
it('keeps two opposite borders from eating the whole frame', () => {
|
||||
const sheet = makeSheet();
|
||||
const frameId = addFrame(sheet, { x: 0, y: 0, w: 24, h: 24 });
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('serialize', () => {
|
||||
w: 24,
|
||||
h: 24,
|
||||
border: { left: 8, right: 8 },
|
||||
fill: 'repeat',
|
||||
fill: 'stretch',
|
||||
},
|
||||
{ id: 'f2', name: 'Plain', x: 24, y: 0, w: 24, h: 24 },
|
||||
{
|
||||
@@ -96,7 +96,8 @@ describe('serialize', () => {
|
||||
if (texture.type !== 'texture') throw new Error('wrong types');
|
||||
const [sliced, plain, greedy] = texture.frames;
|
||||
expect(sliced?.border).toEqual({ left: 8, top: 0, right: 8, bottom: 0 });
|
||||
expect(sliced?.fill).toBe('repeat');
|
||||
// Only the fill that is not the default is written; an unknown one reads as the default.
|
||||
expect(sliced?.fill).toBe('stretch');
|
||||
expect(plain?.border).toBeUndefined();
|
||||
expect(plain?.fill).toBeUndefined();
|
||||
// Measured against the frame, not the image, and never leaving it without a middle.
|
||||
|
||||
+14
-11
@@ -178,8 +178,9 @@ export async function nineSlice(t) {
|
||||
JSON.stringify({ sliced: slicedEdge, stretched: stretchedEdge }),
|
||||
);
|
||||
|
||||
// 37d. Stretch against repeat, picked in the inspector. The white stripe down the left of the
|
||||
// middle is the witness: stretched there is one of it, tiled there is one per 8 units.
|
||||
// 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');
|
||||
@@ -194,28 +195,30 @@ export async function nineSlice(t) {
|
||||
}
|
||||
return runs;
|
||||
};
|
||||
const stretchedStripes = await stripes();
|
||||
const repeatedStripes = await stripes();
|
||||
|
||||
await evaluate(
|
||||
`__gm.state.select({ kind: 'frame', assetId: ${JSON.stringify(texture.id)}, frameId: 'ns-frame' })`,
|
||||
);
|
||||
await showTab('texture');
|
||||
await clickText('Stretch');
|
||||
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 the fill beside the borders, and picking it writes the frame',
|
||||
picked.fill === 'repeat',
|
||||
JSON.stringify(picked),
|
||||
'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 repeatedStripes = await stripes();
|
||||
const stretchedStripes = await stripes();
|
||||
check(
|
||||
'a repeated middle tiles the picture instead of stretching it once',
|
||||
stretchedStripes === 1 && repeatedStripes > 15,
|
||||
JSON.stringify({ stretched: stretchedStripes, repeated: repeatedStripes }),
|
||||
'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');
|
||||
|
||||
|
||||
Reference in new issue
Block a user