diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 6c8f70d3..6a68952e 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -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; } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index fe61b151..44b1f972 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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); } diff --git a/src/editor/inspector/frameSection.ts b/src/editor/inspector/frameSection.ts index 31150c43..8f9f4f7d 100644 --- a/src/editor/inspector/frameSection.ts +++ b/src/editor/inspector/frameSection.ts @@ -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); diff --git a/tests/core/frame-commands.test.ts b/tests/core/frame-commands.test.ts index c6312fea..d7fd9f8b 100644 --- a/tests/core/frame-commands.test.ts +++ b/tests/core/frame-commands.test.ts @@ -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 }); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index 83f5adf3..270de4d2 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -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. diff --git a/tools/e2e/37-nine-slice.mjs b/tools/e2e/37-nine-slice.mjs index d952ecc5..82e63e0e 100644 --- a/tools/e2e/37-nine-slice.mjs +++ b/tools/e2e/37-nine-slice.mjs @@ -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');