A frame that keeps its corners can be stretched to any size (#41)
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:
adminandClaude Opus 5 committed 2026-09-16 12:04:09 +02:00
1 parent b9f1a9d319
commit be8f2331de
17 files changed
+1091 -31

No files matched your search

+103 -15
View File
@@ -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()) {
+63
View File
@@ -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) };