A frame that keeps its corners can be stretched to any size (#41)
game-maker / suite (push) Successful in 6m21s
game-maker / suite (push) Successful in 6m21s
TextureFrame carries optional `border` (left/right/top/bottom) and `fill` (stretch | repeat); absent keys mean "not sliced", so no format bump and undo restores the plain frame instead of four zeroes. The sliced sprite builds its own geometry instead of the shared UNIT_QUAD: a 4x4 grid, and for `repeat` one quad per tile carrying the frame cell's real UVs -- tiling in the geometry, not in the sampler, so a frame out of a sheet cannot pull its neighbour in. Above 64 tiles per axis it stretches, to bound the buffer. A sprite narrower than left+right scales both borders down together until the middle is exactly 0, so the corners meet at a seam and never overlap. The texture editor gets four draggable border lines plus the same four numbers in the inspector. Hit order is resize handles, then the lines, then the frame body -- handleAt reports 'move' anywhere inside a frame, so lines placed after it were unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b9f1a9d319
commit
be8f2331de
17 files changed
+1091
-31
No files matched your search
@@ -8,9 +8,13 @@ import type { Project } from '../model/Project';
|
||||
import {
|
||||
frameRect,
|
||||
type FrameRect,
|
||||
type FrameSlice,
|
||||
nextFrameNames,
|
||||
normalizeFrameRect,
|
||||
rawFrameSlice,
|
||||
restoreFrameSlice,
|
||||
setFrameRect,
|
||||
setFrameSlice,
|
||||
sliceFrames,
|
||||
type SliceOptions,
|
||||
uniqueFrameName,
|
||||
@@ -88,6 +92,32 @@ export class SetFrameRectCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The four borders and the fill of one frame (#41), in one command: dragging a border line and
|
||||
* picking a fill are the same edit to the reader of an undo list, and splitting them would make
|
||||
* "undo" take back half of what the eye saw change.
|
||||
*/
|
||||
export class SetFrameSliceCommand implements Command {
|
||||
readonly label = 'Nine-slice';
|
||||
private previous: Pick<TextureFrame, 'border' | 'fill'> = {};
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly frameId: string,
|
||||
private readonly slice: FrameSlice,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const frame = requireFrame(requireTexture(project, this.assetId), this.frameId);
|
||||
this.previous = rawFrameSlice(frame);
|
||||
setFrameSlice(frame, this.slice);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
restoreFrameSlice(requireFrame(requireTexture(project, this.assetId), this.frameId), this.previous);
|
||||
}
|
||||
}
|
||||
|
||||
export class RenameFrameCommand implements Command {
|
||||
readonly label = 'Rename frame';
|
||||
private previous = '';
|
||||
|
||||
+18
-1
@@ -61,7 +61,22 @@ export interface ContainerAsset extends AssetBase {
|
||||
objects: SceneObject[];
|
||||
}
|
||||
|
||||
/** `id` shares the generator with asset ids and is unique across the document (see `model/texture.ts`). */
|
||||
/** How the parts between the corners of a nine-slice frame fill the space they are given (#41). */
|
||||
export type FrameFill = 'stretch' | 'repeat';
|
||||
|
||||
/** Distances from the frame's own edges, in image pixels; all zero means the frame is not sliced. */
|
||||
export interface FrameBorders {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `id` shares the generator with asset ids and is unique across the document (see `model/texture.ts`).
|
||||
* `border` and `fill` are absent on a frame nobody sliced, which is what every document written
|
||||
* before #41 says – read them through `frameBorders`/`frameFill`, never off the object.
|
||||
*/
|
||||
export interface TextureFrame {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -69,6 +84,8 @@ export interface TextureFrame {
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
border?: FrameBorders;
|
||||
fill?: FrameFill;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+103
-1
@@ -1,5 +1,5 @@
|
||||
import { animationFrameAt } from './animation';
|
||||
import type { TextureAsset, TextureFrame } from './Asset';
|
||||
import type { FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset';
|
||||
import { type PropertyBag, textOf } from './Properties';
|
||||
import { findAsset, type Project } from './Project';
|
||||
|
||||
@@ -30,6 +30,21 @@ export interface TextureRef {
|
||||
rect: FrameRect | null;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
/** Set when the frame this ref names is sliced (#41); null means "draw it over the whole box". */
|
||||
slice: NineSlice | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sliced frame as the renderer needs it: the borders, how the parts between them fill, and the
|
||||
* size they are measured in. Carried on the ref rather than looked up while drawing, for the same
|
||||
* reason the rectangle is – drawing has no access to the project.
|
||||
*/
|
||||
export interface NineSlice {
|
||||
border: FrameBorders;
|
||||
fill: FrameFill;
|
||||
/** Size of the frame in image pixels; the borders are distances inside it. */
|
||||
sourceW: number;
|
||||
sourceH: number;
|
||||
}
|
||||
|
||||
export interface FrameRef {
|
||||
@@ -87,9 +102,96 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n
|
||||
rect: frame ? { x: frame.x, y: frame.y, w: frame.w, h: frame.h } : null,
|
||||
sourceWidth: asset.width,
|
||||
sourceHeight: asset.height,
|
||||
slice: frame ? nineSliceOf(frame) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
export const DEFAULT_FILL: FrameFill = 'stretch';
|
||||
|
||||
/** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */
|
||||
export const MIN_SLICE_MIDDLE = 1;
|
||||
|
||||
export function frameBorders(frame: TextureFrame): FrameBorders {
|
||||
return frame.border ? { ...frame.border } : { ...NO_BORDERS };
|
||||
}
|
||||
|
||||
export function frameFill(frame: TextureFrame): FrameFill {
|
||||
return frame.fill === 'repeat' ? 'repeat' : DEFAULT_FILL;
|
||||
}
|
||||
|
||||
export function hasBorders(border: FrameBorders): boolean {
|
||||
return border.left > 0 || border.top > 0 || border.right > 0 || border.bottom > 0;
|
||||
}
|
||||
|
||||
/** What a frame is sliced into, or null when nobody sliced it – then a sprite draws it as one piece. */
|
||||
export function nineSliceOf(frame: TextureFrame): NineSlice | null {
|
||||
const border = normalizeBorders(frameBorders(frame), frame.w, frame.h);
|
||||
if (!hasBorders(border)) return null;
|
||||
return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole pixels, never negative, and never eating the whole frame: two opposite borders are scaled
|
||||
* down together until at least one pixel is left between them. A middle of no pixels has nothing
|
||||
* to draw the space between the corners with, which would show as a hole rather than as an error.
|
||||
*/
|
||||
export function normalizeBorders(border: FrameBorders, w: number, h: number): FrameBorders {
|
||||
const [left, right] = fitPair(border.left, border.right, w);
|
||||
const [top, bottom] = fitPair(border.top, border.bottom, h);
|
||||
return { left, top, right, bottom };
|
||||
}
|
||||
|
||||
function fitPair(near: number, far: number, size: number): [number, number] {
|
||||
const a = Math.max(0, Math.round(Number.isFinite(near) ? near : 0));
|
||||
const b = Math.max(0, Math.round(Number.isFinite(far) ? far : 0));
|
||||
const room = Math.max(0, Math.floor(size) - MIN_SLICE_MIDDLE);
|
||||
if (a + b <= room) return [a, b];
|
||||
if (room <= 0) return [0, 0];
|
||||
// Proportional, so a frame that is dragged small keeps the shape of its corners.
|
||||
const scaled = Math.round((a / (a + b)) * room);
|
||||
return [scaled, room - scaled];
|
||||
}
|
||||
|
||||
/** The slice settings of a frame, as one value – what an inspector row and a command pass around. */
|
||||
export interface FrameSlice {
|
||||
border: FrameBorders;
|
||||
fill: FrameFill;
|
||||
}
|
||||
|
||||
export function frameSlice(frame: TextureFrame): FrameSlice {
|
||||
return { border: frameBorders(frame), fill: frameFill(frame) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the slice back, dropping the keys again once they mean nothing: a frame nobody sliced
|
||||
* reads and saves exactly as it did before #41, so undo of the first slice restores the old shape.
|
||||
*/
|
||||
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';
|
||||
else delete frame.fill;
|
||||
}
|
||||
|
||||
/** The slice keys exactly as the frame carries them – absence included, which undo owes back. */
|
||||
export function rawFrameSlice(frame: TextureFrame): Pick<TextureFrame, 'border' | 'fill'> {
|
||||
const raw: Pick<TextureFrame, 'border' | 'fill'> = {};
|
||||
if (frame.border) raw.border = { ...frame.border };
|
||||
if (frame.fill) raw.fill = frame.fill;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function restoreFrameSlice(frame: TextureFrame, raw: Pick<TextureFrame, 'border' | 'fill'>): void {
|
||||
if (raw.border) frame.border = { ...raw.border };
|
||||
else delete frame.border;
|
||||
if (raw.fill) frame.fill = raw.fill;
|
||||
else delete frame.fill;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds to whole pixels and forces the rectangle inside the image: a fractional frame samples
|
||||
* half of a neighbouring pixel along every edge, the classic sprite-sheet seam.
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||
import { readScope } from '../model/Scope';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile, normalizeFrameRect } from '../model/texture';
|
||||
import { isTextureFile, normalizeBorders, normalizeFrameRect, setFrameSlice } from '../model/texture';
|
||||
import { hasParentCycle } from '../model/walk';
|
||||
import {
|
||||
DECLARED_PORT_KINDS,
|
||||
@@ -433,7 +433,24 @@ function normalizeTexture(
|
||||
width,
|
||||
height,
|
||||
);
|
||||
frames.push({ id, name: typeof f.name === 'string' ? f.name : '', ...rect });
|
||||
const frame: TextureFrame = { id, name: typeof f.name === 'string' ? f.name : '', ...rect };
|
||||
// Against the frame, not the image: the borders are distances inside the cut-out. A frame
|
||||
// without them is one nobody sliced, and keeps saying so.
|
||||
const border = asRecord(f.border);
|
||||
setFrameSlice(frame, {
|
||||
border: normalizeBorders(
|
||||
{
|
||||
left: toNumber(border.left),
|
||||
top: toNumber(border.top),
|
||||
right: toNumber(border.right),
|
||||
bottom: toNumber(border.bottom),
|
||||
},
|
||||
rect.w,
|
||||
rect.h,
|
||||
),
|
||||
fill: f.fill === 'repeat' ? 'repeat' : 'stretch',
|
||||
});
|
||||
frames.push(frame);
|
||||
}
|
||||
return { file, width, height, frames };
|
||||
}
|
||||
|
||||
+22
-8
@@ -227,6 +227,15 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
const holder = findObjectHolder(state.project, holderId);
|
||||
return holder ? resolveObjects(state.project, holder) : [];
|
||||
};
|
||||
/** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */
|
||||
const pixelAt = (x: number, y: number): string => {
|
||||
const { pixels } = app.renderer.extract.pixels({
|
||||
target: app.stage,
|
||||
frame: new Rectangle(x, y, 1, 1),
|
||||
});
|
||||
const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0');
|
||||
return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`;
|
||||
};
|
||||
(window as unknown as { __gm: unknown }).__gm = {
|
||||
state,
|
||||
/** The Pixi application, for probes that need the renderer or the stage. */
|
||||
@@ -490,14 +499,19 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
hierarchyRowColor: (id: string) => hierarchy.rowColor(id),
|
||||
hierarchyExpandAll: () => hierarchy.expandAll(),
|
||||
hierarchyCollapseAll: () => hierarchy.collapseAll(),
|
||||
/** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */
|
||||
pixelAt: (x: number, y: number) => {
|
||||
const { pixels } = app.renderer.extract.pixels({
|
||||
target: app.stage,
|
||||
frame: new Rectangle(x, y, 1, 1),
|
||||
});
|
||||
const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0');
|
||||
return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`;
|
||||
pixelAt,
|
||||
/**
|
||||
* A horizontal run of pixels in one read. A check that has to tell a repeated pattern from a
|
||||
* stretched one needs dozens of samples, and dozens of round trips would cost more than the
|
||||
* step it belongs to.
|
||||
*/
|
||||
pixelRow: (x: number, y: number, count: number) => {
|
||||
const width = Math.max(1, Math.min(1024, Math.round(count)));
|
||||
const row: string[] = [];
|
||||
// One extract per pixel, not one strip: a wider frame comes back resampled, and a row that
|
||||
// blends its neighbours cannot tell a hard border from a soft one – which is what it is for.
|
||||
for (let i = 0; i < width; i++) row.push(pixelAt(x + i, y));
|
||||
return row;
|
||||
},
|
||||
/** The filter and wrap the GPU sampler actually got, next to the model's – only the driver knows the former. */
|
||||
textureFilter: (file: string, rect: FrameRect | null = null) => {
|
||||
|
||||
@@ -2,10 +2,22 @@ import {
|
||||
RemoveFrameCommand,
|
||||
RenameFrameCommand,
|
||||
SetFrameRectCommand,
|
||||
SetFrameSliceCommand,
|
||||
} from '../../core/commands/frameCommands';
|
||||
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture';
|
||||
import {
|
||||
frameBorders,
|
||||
frameFill,
|
||||
frameRect,
|
||||
type FrameRect,
|
||||
frameSlice,
|
||||
type FrameSlice,
|
||||
normalizeFrameRect,
|
||||
setFrameRect,
|
||||
setFrameSlice,
|
||||
} from '../../core/model/texture';
|
||||
import { Button } from '../../ui';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
@@ -18,6 +30,13 @@ const RECT_COMPONENTS = [
|
||||
{ key: 'h', label: 'Height' },
|
||||
] as const;
|
||||
|
||||
const BORDER_COMPONENTS = [
|
||||
{ key: 'left', label: 'Left' },
|
||||
{ key: 'top', label: 'Top' },
|
||||
{ key: 'right', label: 'Right' },
|
||||
{ key: 'bottom', label: 'Bottom' },
|
||||
] as const;
|
||||
|
||||
/** Structure-defining facts of a frame selection: none – only the frame disappearing rebuilds. */
|
||||
export function frameSignature(project: Project, assetId: string, frameId: string): string {
|
||||
return currentFrame(project, assetId, frameId) ? 'frame' : 'missing';
|
||||
@@ -44,6 +63,12 @@ export function buildFrameSection(host: InspectorHost, assetId: string, frameId:
|
||||
for (const component of RECT_COMPONENTS)
|
||||
rectRow(host, assetId, frameId, component.key, component.label, frame[component.key]);
|
||||
|
||||
addTitle(host, 'NINE-SLICE (IMAGE PIXELS)');
|
||||
for (const component of BORDER_COMPONENTS)
|
||||
borderRow(host, assetId, frameId, component.key, component.label);
|
||||
fillRow(host, assetId, frameId);
|
||||
addNote(host, 'All four at zero: the picture is drawn over the whole box, as it always was.');
|
||||
|
||||
addGap(host);
|
||||
host.body.add(
|
||||
new Button('Delete frame', {
|
||||
@@ -59,9 +84,78 @@ export function refreshFrameSection(host: InspectorHost, assetId: string, frameI
|
||||
if (!frame) return false;
|
||||
host.nameField?.setValue(frame.name);
|
||||
for (const component of RECT_COMPONENTS) host.fields.get(component.key)?.setValue(frame[component.key]);
|
||||
const border = frameBorders(frame);
|
||||
for (const component of BORDER_COMPONENTS)
|
||||
host.fields.get(`border-${component.key}`)?.setValue(border[component.key]);
|
||||
host.fields.get('fill')?.setValue(frameFill(frame));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One border of the nine-slice, in pixels of the frame. The row keeps the other three and the fill
|
||||
* as they are at that moment, so two rows edited one after the other do not undo each other.
|
||||
*/
|
||||
function borderRow(
|
||||
host: InspectorHost,
|
||||
assetId: string,
|
||||
frameId: string,
|
||||
key: keyof FrameBorders,
|
||||
label: string,
|
||||
): void {
|
||||
const frame = currentFrame(host.state.project, assetId, frameId);
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
{ key: `border-${key}`, label, kind: 'number', default: 0, min: 0, step: 1, decimals: 0 },
|
||||
frame ? frameBorders(frame)[key] : 0,
|
||||
sliceHandlers(host, assetId, frameId, (input, slice) => ({
|
||||
...slice,
|
||||
border: { ...slice.border, [key]: Number(input) },
|
||||
})),
|
||||
);
|
||||
host.fields.set(`border-${key}`, row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
/** How the parts between the corners fill their space – one choice for the edges and the middle. */
|
||||
function fillRow(host: InspectorHost, assetId: string, frameId: string): void {
|
||||
const frame = currentFrame(host.state.project, assetId, frameId);
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
{
|
||||
key: 'fill',
|
||||
label: 'Fill',
|
||||
kind: 'enum',
|
||||
default: 'stretch',
|
||||
options: [
|
||||
{ value: 'stretch', label: 'Stretch' },
|
||||
{ value: 'repeat', label: 'Repeat' },
|
||||
],
|
||||
},
|
||||
frame ? frameFill(frame) : 'stretch',
|
||||
sliceHandlers(host, assetId, frameId, (input, slice) => ({
|
||||
...slice,
|
||||
fill: textOf(input) === 'repeat' ? 'repeat' : 'stretch',
|
||||
})),
|
||||
);
|
||||
host.fields.set('fill', row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
function sliceHandlers(
|
||||
host: InspectorHost,
|
||||
assetId: string,
|
||||
frameId: string,
|
||||
apply: (input: PropertyValue, slice: FrameSlice) => FrameSlice,
|
||||
): FieldHandlers {
|
||||
return previewingHandlers<TextureFrame, FrameSlice>(host, {
|
||||
target: () => currentFrame(host.state.project, assetId, frameId),
|
||||
read: frameSlice,
|
||||
write: setFrameSlice,
|
||||
toModel: (input, frame) => apply(input, frameSlice(frame)),
|
||||
commit: (slice) => new SetFrameSliceCommand(assetId, frameId, slice),
|
||||
});
|
||||
}
|
||||
|
||||
/** A number row editing one component of the rectangle. Whole pixels: a frame indexes a bitmap. */
|
||||
function rectRow(
|
||||
host: InspectorHost,
|
||||
|
||||
@@ -6,16 +6,36 @@ import {
|
||||
Sprite,
|
||||
Text,
|
||||
} from 'pixi.js';
|
||||
import { AddFrameCommand, SetFrameRectCommand } from '../../core/commands/frameCommands';
|
||||
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||
import {
|
||||
AddFrameCommand,
|
||||
SetFrameRectCommand,
|
||||
SetFrameSliceCommand,
|
||||
} from '../../core/commands/frameCommands';
|
||||
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||
import { textOf, type Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { type FrameRect, MIN_FRAME_SIZE, normalizeFrameRect } from '../../core/model/texture';
|
||||
import {
|
||||
frameBorders,
|
||||
frameFill,
|
||||
type FrameRect,
|
||||
MIN_FRAME_SIZE,
|
||||
normalizeFrameRect,
|
||||
rawFrameSlice,
|
||||
restoreFrameSlice,
|
||||
setFrameSlice,
|
||||
} from '../../core/model/texture';
|
||||
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
|
||||
import { dashedLine } from '../../viewport/symbols';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import type { EditorState } from '../state';
|
||||
import { drawChecker } from './checker';
|
||||
import {
|
||||
borderAt,
|
||||
borderCursor,
|
||||
type BorderEdge,
|
||||
BORDER_EDGES,
|
||||
borderLine,
|
||||
dragBorder,
|
||||
dragFrame,
|
||||
fitView,
|
||||
type FrameHandle,
|
||||
@@ -40,10 +60,15 @@ const MIN_LABEL_SIZE = 28;
|
||||
const LABEL_INSET = 3;
|
||||
const LABEL_PAD = 2;
|
||||
|
||||
/** Either a handle of the frame's rectangle or one of its nine-slice border lines – never both. */
|
||||
interface FrameDrag {
|
||||
frameId: string;
|
||||
handle: FrameHandle;
|
||||
handle: FrameHandle | null;
|
||||
edge: BorderEdge | null;
|
||||
start: FrameRect;
|
||||
startBorder: FrameBorders;
|
||||
/** The slice keys as they were, so the command records the state that was really there. */
|
||||
startSlice: Pick<TextureFrame, 'border' | 'fill'>;
|
||||
pointer: Vec2;
|
||||
moved: boolean;
|
||||
}
|
||||
@@ -162,10 +187,24 @@ export class TextureCanvas extends Widget {
|
||||
const tolerance = HANDLE_SIZE / this.view.zoom;
|
||||
|
||||
// The selected frame is tested first, so its handles stay reachable under a frame drawn later.
|
||||
// Its resize handles before its border lines – a border still sitting on the frame's edge must
|
||||
// not take the grip that resizes the frame – but both before "move", which is the whole inside
|
||||
// of the frame and would otherwise swallow every line it holds.
|
||||
const selected = this.frame(this.selectedFrameId());
|
||||
const onSelected = selected ? handleAt(selected, point, tolerance) : null;
|
||||
const onBorder = selected ? borderAt(selected, frameBorders(selected), point, tolerance) : null;
|
||||
if (selected && onSelected && onSelected !== 'move') {
|
||||
this.startDrag(selected, onSelected, null, local);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (selected && onBorder) {
|
||||
this.startDrag(selected, null, onBorder, local);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (selected && onSelected) {
|
||||
this.startDrag(selected, onSelected, local);
|
||||
this.startDrag(selected, onSelected, null, local);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
@@ -174,7 +213,7 @@ export class TextureCanvas extends Widget {
|
||||
const frame = asset.frames[i]!;
|
||||
if (handleAt(frame, point, tolerance) === null) continue;
|
||||
this.state.select({ kind: 'frame', assetId: asset.id, frameId: frame.id });
|
||||
this.startDrag(frame, 'move', local);
|
||||
this.startDrag(frame, 'move', null, local);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
@@ -182,11 +221,19 @@ export class TextureCanvas extends Widget {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
private startDrag(frame: TextureFrame, handle: FrameHandle, local: Vec2): void {
|
||||
private startDrag(
|
||||
frame: TextureFrame,
|
||||
handle: FrameHandle | null,
|
||||
edge: BorderEdge | null,
|
||||
local: Vec2,
|
||||
): void {
|
||||
this.dragging = {
|
||||
frameId: frame.id,
|
||||
handle,
|
||||
edge,
|
||||
start: { x: frame.x, y: frame.y, w: frame.w, h: frame.h },
|
||||
startBorder: frameBorders(frame),
|
||||
startSlice: rawFrameSlice(frame),
|
||||
pointer: { x: local.x, y: local.y },
|
||||
moved: false,
|
||||
};
|
||||
@@ -228,10 +275,16 @@ export class TextureCanvas extends Widget {
|
||||
const asset = this.asset();
|
||||
const frame = drag && this.frame(drag.frameId);
|
||||
if (!drag || !asset || !frame) return;
|
||||
Object.assign(
|
||||
frame,
|
||||
normalizeFrameRect(dragFrame(drag.start, drag.handle, dx, dy), asset.width, asset.height),
|
||||
);
|
||||
if (drag.edge)
|
||||
setFrameSlice(frame, {
|
||||
border: dragBorder(drag.startBorder, drag.edge, dx, dy),
|
||||
fill: frameFill(frame),
|
||||
});
|
||||
else if (drag.handle)
|
||||
Object.assign(
|
||||
frame,
|
||||
normalizeFrameRect(dragFrame(drag.start, drag.handle, dx, dy), asset.width, asset.height),
|
||||
);
|
||||
this.state.preview();
|
||||
}
|
||||
|
||||
@@ -247,9 +300,15 @@ export class TextureCanvas extends Widget {
|
||||
const frame = this.frame(drag.frameId);
|
||||
if (drag.moved && asset && frame) {
|
||||
// The preview moved it already; put it back so the command records the real start.
|
||||
const next: FrameRect = { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
|
||||
Object.assign(frame, drag.start);
|
||||
this.state.execute(new SetFrameRectCommand(asset.id, drag.frameId, next));
|
||||
if (drag.edge) {
|
||||
const next = { border: frameBorders(frame), fill: frameFill(frame) };
|
||||
restoreFrameSlice(frame, drag.startSlice);
|
||||
this.state.execute(new SetFrameSliceCommand(asset.id, drag.frameId, next));
|
||||
} else {
|
||||
const next: FrameRect = { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
|
||||
Object.assign(frame, drag.start);
|
||||
this.state.execute(new SetFrameRectCommand(asset.id, drag.frameId, next));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -288,6 +347,16 @@ export class TextureCanvas extends Widget {
|
||||
const tolerance = HANDLE_SIZE / this.view.zoom;
|
||||
const selected = this.frame(this.selectedFrameId());
|
||||
const handle = selected ? handleAt(selected, point, tolerance) : null;
|
||||
if (handle && handle !== 'move') {
|
||||
this.cursor = handleCursor(handle);
|
||||
return;
|
||||
}
|
||||
// Same order as the press: a line inside the frame is a line, not the frame's own body.
|
||||
const edge = selected ? borderAt(selected, frameBorders(selected), point, tolerance) : null;
|
||||
if (edge) {
|
||||
this.cursor = borderCursor(edge);
|
||||
return;
|
||||
}
|
||||
if (handle) {
|
||||
this.cursor = handleCursor(handle);
|
||||
return;
|
||||
@@ -327,6 +396,7 @@ export class TextureCanvas extends Widget {
|
||||
rect: null,
|
||||
sourceWidth: asset.width,
|
||||
sourceHeight: asset.height,
|
||||
slice: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -350,7 +420,10 @@ export class TextureCanvas extends Widget {
|
||||
this.drawFrame(frame, frame.id === selectedId);
|
||||
}
|
||||
const selected = this.frame(selectedId);
|
||||
if (selected) this.drawHandles(selected);
|
||||
if (selected) {
|
||||
this.drawBorders(selected);
|
||||
this.drawHandles(selected);
|
||||
}
|
||||
}
|
||||
if (this.spanning?.active) {
|
||||
const rect = rectBetween(this.spanning.start, this.spanning.current);
|
||||
@@ -376,6 +449,21 @@ export class TextureCanvas extends Widget {
|
||||
this.syncLabel(frame, a, w, h, selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* The four nine-slice lines of the selected frame (#41). Drawn even when they all sit on its
|
||||
* edges: a frame nobody sliced still has them, and a line one cannot see is one nobody drags.
|
||||
*/
|
||||
private drawBorders(frame: TextureFrame): void {
|
||||
const border = frameBorders(frame);
|
||||
for (const edge of BORDER_EDGES) {
|
||||
const line = borderLine(frame, border, edge);
|
||||
const a = imageToLocal(this.view, line.x1, line.y1);
|
||||
const b = imageToLocal(this.view, line.x2, line.y2);
|
||||
dashedLine(this.overlay, a.x, a.y, b.x, b.y, 4, 3);
|
||||
}
|
||||
this.overlay.stroke({ width: 1, color: Theme.colors.marquee });
|
||||
}
|
||||
|
||||
private drawHandles(frame: TextureFrame): void {
|
||||
const half = HANDLE_SIZE / 2;
|
||||
for (const handle of frameHandles()) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Geometry for the Texture tab: no Pixi and no editor state, so it stays unit-testable. */
|
||||
import type { FrameBorders } from '../../core/model/Asset';
|
||||
import type { FrameRect } from '../../core/model/texture';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
|
||||
@@ -108,6 +109,68 @@ export function dragFrame(start: FrameRect, handle: FrameHandle, dx: number, dy:
|
||||
return rect;
|
||||
}
|
||||
|
||||
/** The four nine-slice border lines of a frame (#41), named by the frame edge each runs beside. */
|
||||
export type BorderEdge = 'left' | 'top' | 'right' | 'bottom';
|
||||
|
||||
export const BORDER_EDGES: readonly BorderEdge[] = ['left', 'top', 'right', 'bottom'];
|
||||
|
||||
/** Where a border line runs, in image pixels; vertical for left/right, horizontal for top/bottom. */
|
||||
export function borderLine(
|
||||
rect: FrameRect,
|
||||
border: FrameBorders,
|
||||
edge: BorderEdge,
|
||||
): { x1: number; y1: number; x2: number; y2: number } {
|
||||
if (edge === 'left' || edge === 'right') {
|
||||
const x = edge === 'left' ? rect.x + border.left : rect.x + rect.w - border.right;
|
||||
return { x1: x, y1: rect.y, x2: x, y2: rect.y + rect.h };
|
||||
}
|
||||
const y = edge === 'top' ? rect.y + border.top : rect.y + rect.h - border.bottom;
|
||||
return { x1: rect.x, y1: y, x2: rect.x + rect.w, y2: y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which border line a press at `point` grabs, or null. Tested only within the frame, and after the
|
||||
* frame's own handles: a border still sitting on the frame's edge would otherwise take the corner
|
||||
* that resizes the frame itself.
|
||||
*/
|
||||
export function borderAt(
|
||||
rect: FrameRect,
|
||||
border: FrameBorders,
|
||||
point: Vec2,
|
||||
tolerance: number,
|
||||
): BorderEdge | null {
|
||||
if (
|
||||
point.x < rect.x - tolerance ||
|
||||
point.x > rect.x + rect.w + tolerance ||
|
||||
point.y < rect.y - tolerance ||
|
||||
point.y > rect.y + rect.h + tolerance
|
||||
)
|
||||
return null;
|
||||
for (const edge of BORDER_EDGES) {
|
||||
const line = borderLine(rect, border, edge);
|
||||
const distance = line.x1 === line.x2 ? Math.abs(point.x - line.x1) : Math.abs(point.y - line.y1);
|
||||
if (distance <= tolerance) return edge;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The borders a drag of one line produces. Raw: keeping them inside the frame is
|
||||
* `normalizeBorders`' job, shared with the numbers typed into the inspector.
|
||||
*/
|
||||
export function dragBorder(border: FrameBorders, edge: BorderEdge, dx: number, dy: number): FrameBorders {
|
||||
const moved = { ...border };
|
||||
if (edge === 'left') moved.left += dx;
|
||||
else if (edge === 'right') moved.right -= dx;
|
||||
else if (edge === 'top') moved.top += dy;
|
||||
else moved.bottom -= dy;
|
||||
return moved;
|
||||
}
|
||||
|
||||
export function borderCursor(edge: BorderEdge): string {
|
||||
return edge === 'left' || edge === 'right' ? 'ew-resize' : 'ns-resize';
|
||||
}
|
||||
|
||||
/** The rectangle spanned by two points, in either order. */
|
||||
export function rectBetween(a: Vec2, b: Vec2): FrameRect {
|
||||
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The geometry a nine-sliced frame draws with (#41): no Pixi and no project, so it stays
|
||||
* unit-testable. A sprite is one quad per part rather than a shared 4×4 vertex grid, because a
|
||||
* repeated part needs its own UVs per tile – a grid can only stretch.
|
||||
*/
|
||||
import type { FrameFill } from '../core/model/Asset';
|
||||
import type { NineSlice } from '../core/model/texture';
|
||||
|
||||
/** How often a part may repeat before it stretches instead; a guard, not a design (see `sliceSpans`). */
|
||||
export const MAX_TILES = 64;
|
||||
|
||||
/** One part along an axis: `p0`..`p1` in the sprite's units, `t0`..`t1` in the frame's 0..1 texture space. */
|
||||
export interface Span {
|
||||
p0: number;
|
||||
p1: number;
|
||||
t0: number;
|
||||
t1: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts one axis is cut into. `near`/`far` are the two borders in source pixels, and they keep
|
||||
* that size in the sprite's units – that is the whole point of slicing. A sprite too small to hold
|
||||
* both is not left with overlapping corners: they are scaled down together until the middle is
|
||||
* exactly zero, so the seam sits where the two corners meet.
|
||||
*/
|
||||
export function sliceSpans(
|
||||
target: number,
|
||||
source: number,
|
||||
near: number,
|
||||
far: number,
|
||||
fill: FrameFill,
|
||||
): Span[] {
|
||||
if (!(target > 0) || !(source > 0)) return [];
|
||||
const shrink = near + far > target ? target / (near + far) : 1;
|
||||
const n = near * shrink;
|
||||
const f = far * shrink;
|
||||
const middle = Math.max(0, target - n - f);
|
||||
const sourceMiddle = source - near - far;
|
||||
const spans: Span[] = [];
|
||||
if (n > 0) spans.push({ p0: 0, p1: n, t0: 0, t1: near / source });
|
||||
if (middle > 0 && sourceMiddle > 0) {
|
||||
const t0 = near / source;
|
||||
const t1 = (source - far) / source;
|
||||
// Whole tiles of the frame's own size, the last one cut off where the middle ends. Sampling
|
||||
// with UVs past 1 would do it in one quad, but a frame is a cut-out of a sheet: past its edge
|
||||
// lies the neighbouring cell, not the frame again.
|
||||
const tiles = fill === 'repeat' ? Math.ceil(middle / sourceMiddle) : 1;
|
||||
if (fill === 'repeat' && tiles <= MAX_TILES) {
|
||||
for (let i = 0; i < tiles; i++) {
|
||||
const p0 = n + i * sourceMiddle;
|
||||
const p1 = Math.min(n + middle, p0 + sourceMiddle);
|
||||
spans.push({ p0, p1, t0, t1: t0 + ((p1 - p0) / sourceMiddle) * (t1 - t0) });
|
||||
}
|
||||
} else {
|
||||
spans.push({ p0: n, p1: n + middle, t0, t1 });
|
||||
}
|
||||
}
|
||||
if (f > 0) spans.push({ p0: target - f, p1: target, t0: (source - far) / source, t1: 1 });
|
||||
return spans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions in 0..1 of the sprite's box, as the unit quad they replace: the mesh carries the
|
||||
* sprite's size in its scale, so the geometry must not.
|
||||
*/
|
||||
export function sliceGeometry(
|
||||
slice: NineSlice,
|
||||
w: number,
|
||||
h: number,
|
||||
): { positions: Float32Array; uvs: Float32Array; indices: Uint32Array } {
|
||||
const columns = sliceSpans(w, slice.sourceW, slice.border.left, slice.border.right, slice.fill);
|
||||
const rows = sliceSpans(h, slice.sourceH, slice.border.top, slice.border.bottom, slice.fill);
|
||||
const positions: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
for (const row of rows) {
|
||||
for (const column of columns) {
|
||||
const base = positions.length / 2;
|
||||
positions.push(
|
||||
column.p0 / w,
|
||||
row.p0 / h,
|
||||
column.p1 / w,
|
||||
row.p0 / h,
|
||||
column.p1 / w,
|
||||
row.p1 / h,
|
||||
column.p0 / w,
|
||||
row.p1 / h,
|
||||
);
|
||||
uvs.push(column.t0, row.t0, column.t1, row.t0, column.t1, row.t1, column.t0, row.t1);
|
||||
indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
|
||||
}
|
||||
}
|
||||
return {
|
||||
positions: new Float32Array(positions),
|
||||
uvs: new Float32Array(uvs),
|
||||
indices: new Uint32Array(indices),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the geometry depends on. Rebuilding on every frame would throw away a buffer the GPU
|
||||
* already has; a sprite that only moves keeps the one it was given.
|
||||
*/
|
||||
export function sliceKey(slice: NineSlice, w: number, h: number): string {
|
||||
const b = slice.border;
|
||||
return `${b.left},${b.top},${b.right},${b.bottom}|${slice.fill}|${slice.sourceW}×${slice.sourceH}|${round(w)}×${round(h)}`;
|
||||
}
|
||||
|
||||
/** Sub-pixel changes of size do not change the picture, and would rebuild on every mouse move. */
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 4) / 4;
|
||||
}
|
||||
+27
-1
@@ -10,8 +10,10 @@ import {
|
||||
} from '../core/properties/bounds';
|
||||
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
|
||||
import { SHEET_EDGE } from '../core/shader/nodes';
|
||||
import type { NineSlice } from '../core/model/texture';
|
||||
import { Theme } from '../ui';
|
||||
import type { DrawItem } from './items';
|
||||
import { sliceGeometry, sliceKey } from './nineSlice';
|
||||
import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders';
|
||||
import type { TextureLibrary } from './TextureLibrary';
|
||||
|
||||
@@ -65,15 +67,39 @@ export function syncSpriteMesh(
|
||||
const edge = wornShader(item.props, shaders)?.edge ?? SHEET_EDGE;
|
||||
const texture = item.texture ? textures.get(item.texture, edge) : null;
|
||||
placeNode(mesh, item.m, size.w, size.h);
|
||||
// Only once there is a picture: slicing a placeholder would cut up the plain colour for nothing.
|
||||
syncGeometry(mesh, texture ? (item.texture?.slice ?? null) : null, size);
|
||||
mesh.texture = texture ?? Texture.WHITE;
|
||||
mesh.tint = Number(item.props.color ?? 0xffffff);
|
||||
syncShader(mesh, item.props, shaders);
|
||||
}
|
||||
|
||||
/** `Mesh.destroy` leaves the shared geometry alone and only lets go of its shader. */
|
||||
/**
|
||||
* The geometry a sliced sprite needs, built only when what it depends on changed. Keyed outside
|
||||
* the mesh so nothing of this leaks into Pixi's own object, and so a mesh that loses its slice
|
||||
* goes back to the shared quad rather than keeping a private copy of it.
|
||||
*/
|
||||
const sliceKeys = new WeakMap<Mesh, string>();
|
||||
|
||||
function syncGeometry(mesh: Mesh, slice: NineSlice | null, size: { w: number; h: number }): void {
|
||||
const key = slice ? sliceKey(slice, size.w, size.h) : '';
|
||||
if ((sliceKeys.get(mesh) ?? '') === key) return;
|
||||
const previous = mesh.geometry;
|
||||
mesh.geometry = slice ? new MeshGeometry(sliceGeometry(slice, size.w, size.h)) : UNIT_QUAD;
|
||||
if (previous !== UNIT_QUAD) previous.destroy();
|
||||
if (key) sliceKeys.set(mesh, key);
|
||||
else sliceKeys.delete(mesh);
|
||||
}
|
||||
|
||||
/**
|
||||
* `Mesh.destroy` never destroys its geometry, shared or not – so the shared quad survives, and a
|
||||
* sliced sprite's own buffers have to be let go of here.
|
||||
*/
|
||||
export function disposeSpriteMesh(mesh: Mesh): void {
|
||||
disposeShader(mesh);
|
||||
const geometry = mesh.geometry;
|
||||
mesh.destroy();
|
||||
if (geometry !== UNIT_QUAD) geometry.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user