Add a Texture tab for defining frames

A new Texture tab next to Scene/Game/Graph shows a texture sheet and lets
frames be drawn on it: drag to create, move, resize via eight handles, edit
name and rect in the inspector, Delete to remove. Every change is a command,
so one gesture is one undo step. "Slice..." cuts a grid from columns and rows
with optional offset and spacing, previewing the resulting frame count and
cell size live.

Sprites pick a frame instead of the whole texture through the same `texture`
property: it now holds either a texture id or a frame id. Frame ids come from
the same generator and are document-wide unique, so one picker, one property
and one set of instance overrides cover both cases, and every sprite written
before this still means the same thing.

A Graphics fill ignores a Pixi sub-texture's frame and draws the whole image,
because it derives its uvs from the shape bounds and the inverted style
matrix. The cutout is therefore a matrix (`viewport/textureFill.ts`), which
keeps a sheet at a single GPU upload no matter how many of its frames are on
screen at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 14:27:23 +02:00
1 parent 5a6af74e44
commit 35a1a23af8
29 files changed
+2650 -53

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
import type { TextureAsset } from '../../core/model/Asset';
import { DEFAULT_SLICE, type SliceOptions, sliceFrames } from '../../core/model/texture';
import { Button, Checkbox, HStack, Label, NumberField, Popup, Spacer, Theme, type Widget } from '../../ui';
/** Width of the label column, matching the inspector's so the two read as one language. */
const LABEL_WIDTH = 76;
const FIELD_WIDTH = 76;
/**
* Cuts a sheet into a grid. Asks for the number of columns and rows rather than a cell size:
* everyone knows how many tiles their sheet has, and deriving the cell size from the picture
* cannot disagree with it.
*
* The dialog states, live, how many frames the current numbers would produce and how big each
* one is. That readout is the whole point – it turns "did I mean 8×4 or 4×8" from something
* you find out by undoing into something you see before committing.
*/
export class SliceDialog extends Popup {
private readonly options: SliceOptions = { ...DEFAULT_SLICE };
private replace = true;
private readonly summary: Label;
private readonly confirm: Button;
constructor(
private readonly asset: TextureAsset,
onSlice: (options: SliceOptions, replace: boolean) => void,
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.minWidth = 340;
this.content.add(new Label(`Slice "${asset.name}" into a grid`, { style: 'bodyBold' }));
this.content.add(new Label(`Picture is ${asset.width} × ${asset.height} px`, { style: 'muted' }));
this.content.add(this.pair('Columns', 'columns', 1, 'Rows', 'rows', 1));
this.content.add(this.pair('Offset X', 'offsetX', 0, 'Offset Y', 'offsetY', 0));
this.content.add(this.pair('Spacing X', 'spacingX', 0, 'Spacing Y', 'spacingY', 0));
const replaceRow = new HStack({ gap: Theme.gap, align: 'center' });
replaceRow.minHeight = Theme.rowHeight;
const box = new Checkbox(this.replace);
box.onChange = (value) => {
this.replace = Boolean(value);
};
replaceRow.add(box);
replaceRow.add(new Label('Replace the frames this texture already has', { style: 'muted' }));
this.content.add(replaceRow);
this.summary = new Label('', { style: 'muted' });
this.content.add(this.summary);
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
buttons.add(new Spacer(), 1);
const cancel = new Button('Cancel');
cancel.onClick = this.closeThen();
buttons.add(cancel);
this.confirm = new Button('Slice', { variant: 'primary' });
this.confirm.onClick = this.closeThen(() => onSlice({ ...this.options }, this.replace));
buttons.add(this.confirm);
this.content.add(buttons);
this.refreshSummary();
}
/** Two numeric fields on one row – the grid's parameters come in x/y pairs. */
private pair(
labelA: string,
keyA: keyof SliceOptions,
minA: number,
labelB: string,
keyB: keyof SliceOptions,
minB: number,
): Widget {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(this.labelFor(labelA));
row.add(this.fieldFor(keyA, minA));
row.add(new Spacer(Theme.padding));
row.add(this.labelFor(labelB));
row.add(this.fieldFor(keyB, minB));
row.add(new Spacer(), 1);
return row;
}
private labelFor(text: string): Label {
const label = new Label(text, { style: 'muted' });
label.minWidth = LABEL_WIDTH;
return label;
}
private fieldFor(key: keyof SliceOptions, min: number): NumberField {
const field = new NumberField(this.options[key], {
min,
step: 1,
onChange: (value) => {
this.options[key] = Math.round(Number(value));
this.refreshSummary();
},
});
field.minWidth = FIELD_WIDTH;
return field;
}
/** Live readout of what the current numbers would do, including the refusal case. */
private refreshSummary(): void {
const rects = sliceFrames(this.asset.width, this.asset.height, this.options);
const first = rects[0];
this.summary.setText(
first
? `${rects.length} frames of ${first.w} × ${first.h} px`
: 'These numbers do not fit the picture – no frames.',
);
this.summary.setStyle(first ? 'muted' : 'body');
this.confirm.setEnabled(rects.length > 0);
this.refreshLayout();
}
}