Let pixel art and vector art be nine-sliced and sorted (#308)
Nine-slice and the sort line hung on the texture frame alone. Drawing and sorting already read both off the TextureRef every picture hands out, but the generated pictures always said "no slice, no line". So the feature moves from the frame to the picture, without a second copy of any rule. - PictureCut (border, fill, sortLine) is one type. TextureFrame extends it, PixelArtAsset and VectorArtAsset carry it as a whole. The texture file format does not change, and no migration is needed. - The rules in texture.ts take a cut and a size: cutSortLine, setCutSlice, rawCutSlice and the rest replace the frame* versions. serialize.ts reads all three through one readCut. - cutTarget.ts resolves a target to cut and size: a texture frame, a pixel frame's size or the vector canvas. It lives apart from texture.ts because vectorart.ts reaches texture.ts itself. - SetSliceCommand and SetSortLineCommand take that target instead of (assetId, frameId). They refuse an asset that draws no picture. - generatedRef fills slice and sortLine from the drawing. The whole strip of a pixel art asset with several frames gets none, it is no picture of that size. With one frame asset and frame are the same. - Nine-slice scale is one shared schema row, now also on pixel art and vector art. - cutSection.ts holds the NINE-SLICE and SORTING rows once. The frame inspector and the drawing inspector both build them. - SheetCanvas draws a drawing's lines that are set, dashed as in the Texture tab. They are not dragged there, the painting tools own the canvas (as proposed in the issue). - drawOrder falls back to the picture's height when a ref has no rect, or a sort line on vector art would have been ignored. Tests: - Gate: 232 files, 3542 tests (+8: drawing-cut.test.ts, and vector art in sort-by-y.test.ts). Three counter-proofs bite: no cut on a drawing, a cut on the whole strip, no height fallback. - e2e 48: 44 checks (+1, sum 1732). 48l-ii reads red and blue where 12-pixel borders at scale 2 put them. Without borders both spots read green. - Looked at: the Pixel art tab with the lines, and the sliced sprite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d9c6bbfa4d
commit
3e05cbbed5
20 files changed
+666
-300
No files matched your search
@@ -1,7 +1,10 @@
|
||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
|
||||
import type { PictureCut } from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import type { CutSize } from '../../core/model/texture';
|
||||
import { type Size, Theme, textStyle, ui, Widget, zoomFactor, crispText } from '../../ui';
|
||||
import { imageToLocal, type SheetView, zoomAbout } from './sheetView';
|
||||
import { dashedLine } from '../../viewport/symbols';
|
||||
import { cutLines, imageToLocal, type SheetLine, type SheetView, zoomAbout } from './sheetView';
|
||||
|
||||
/**
|
||||
* The shell of a tab that shows one picture-like asset under a `SheetView` – the Texture, Pixel art
|
||||
@@ -104,6 +107,22 @@ export abstract class SheetCanvas<A> extends Widget {
|
||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||
}
|
||||
|
||||
/** The nine-slice and sort lines of a drawing (#308), dashed as the Texture tab draws a frame's. */
|
||||
protected drawCutLines(g: Graphics, cut: PictureCut, size: CutSize): void {
|
||||
const { borders, sortLine } = cutLines(cut, size);
|
||||
for (const line of borders) this.dashed(g, line, 4);
|
||||
g.stroke({ width: 1, color: Theme.colors.marquee });
|
||||
if (!sortLine) return;
|
||||
this.dashed(g, sortLine, 6);
|
||||
g.stroke({ width: 1, color: Theme.colors.hot });
|
||||
}
|
||||
|
||||
private dashed(g: Graphics, line: SheetLine, dash: number): void {
|
||||
const a = imageToLocal(this.view, line.x1, line.y1);
|
||||
const b = imageToLocal(this.view, line.x2, line.y2);
|
||||
dashedLine(g, a.x, a.y, b.x, b.y, dash, 3);
|
||||
}
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
const target = this.pressTarget();
|
||||
if (!target) return;
|
||||
|
||||
@@ -3,27 +3,27 @@ import {
|
||||
AddFrameCommand,
|
||||
SetFrameCollisionCommand,
|
||||
SetFrameRectCommand,
|
||||
SetFrameSliceCommand,
|
||||
SetFrameSortLineCommand,
|
||||
SetSliceCommand,
|
||||
SetSortLineCommand,
|
||||
} from '../../core/commands/frameCommands';
|
||||
import { polygonProblem } from '../../core/model/polygon';
|
||||
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||
import { textOf, type Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import {
|
||||
frameBorders,
|
||||
cutBorders,
|
||||
cutFill,
|
||||
cutSortLine,
|
||||
frameCollision,
|
||||
frameFill,
|
||||
type FrameRect,
|
||||
frameSortLine,
|
||||
MIN_FRAME_SIZE,
|
||||
normalizeFrameRect,
|
||||
normalizeSortLine,
|
||||
rawFrameSlice,
|
||||
restoreFrameSlice,
|
||||
rawCutSlice,
|
||||
restoreCutSlice,
|
||||
setCutSlice,
|
||||
setCutSortLine,
|
||||
setFrameCollision,
|
||||
setFrameSlice,
|
||||
setFrameSortLine,
|
||||
} from '../../core/model/texture';
|
||||
import { pressCount, Theme, textStyle, type Widget, crispText } from '../../ui';
|
||||
import {
|
||||
@@ -248,7 +248,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
const at = imageToLocal(
|
||||
{ panX: 0, panY: 0, zoom: this.view.zoom },
|
||||
frame.x,
|
||||
sortLineY(frame, frameSortLine(frame)),
|
||||
sortLineY(frame, cutSortLine(frame, frame)),
|
||||
);
|
||||
return { x: origin.x + at.x - SORT_KNOB_GAP - SORT_KNOB_SIZE / 2, y: origin.y + at.y };
|
||||
}
|
||||
@@ -321,7 +321,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
// 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;
|
||||
const onBorder = selected ? borderAt(selected, cutBorders(selected), point, tolerance) : null;
|
||||
const onSortLine = selected ? this.sortLineAt(selected, point, tolerance) : null;
|
||||
if (selected && onSortLine === 'knob') {
|
||||
this.startDrag(selected, null, null, local, true);
|
||||
@@ -496,8 +496,8 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
sortLine,
|
||||
startSortLine: frame.sortLine,
|
||||
start: { x: frame.x, y: frame.y, w: frame.w, h: frame.h },
|
||||
startBorder: frameBorders(frame),
|
||||
startSlice: rawFrameSlice(frame),
|
||||
startBorder: cutBorders(frame),
|
||||
startSlice: rawCutSlice(frame),
|
||||
pointer: { x: local.x, y: local.y },
|
||||
moved: false,
|
||||
};
|
||||
@@ -552,11 +552,12 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
const asset = this.asset();
|
||||
const frame = drag && this.frame(drag.frameId);
|
||||
if (!drag || !asset || !frame) return;
|
||||
if (drag.sortLine) setFrameSortLine(frame, normalizeSortLine(drag.startSortLine ?? 0, drag.start.h) - dy);
|
||||
if (drag.sortLine)
|
||||
setCutSortLine(frame, frame, normalizeSortLine(drag.startSortLine ?? 0, drag.start.h) - dy);
|
||||
else if (drag.edge)
|
||||
setFrameSlice(frame, {
|
||||
setCutSlice(frame, frame, {
|
||||
border: dragBorder(drag.startBorder, drag.edge, dx, dy),
|
||||
fill: frameFill(frame),
|
||||
fill: cutFill(frame),
|
||||
});
|
||||
else if (drag.handle)
|
||||
Object.assign(
|
||||
@@ -589,14 +590,14 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
if (drag.moved && asset && frame) {
|
||||
// The preview moved it already; put it back so the command records the real start.
|
||||
if (drag.sortLine) {
|
||||
const next = frameSortLine(frame);
|
||||
const next = cutSortLine(frame, frame);
|
||||
if (drag.startSortLine === undefined) delete frame.sortLine;
|
||||
else frame.sortLine = drag.startSortLine;
|
||||
this.state.execute(new SetFrameSortLineCommand(asset.id, drag.frameId, next));
|
||||
this.state.execute(new SetSortLineCommand({ assetId: asset.id, frameId: drag.frameId }, next));
|
||||
} else 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));
|
||||
const next = { border: cutBorders(frame), fill: cutFill(frame) };
|
||||
restoreCutSlice(frame, drag.startSlice);
|
||||
this.state.execute(new SetSliceCommand({ assetId: asset.id, frameId: drag.frameId }, next));
|
||||
} else {
|
||||
const next: FrameRect = { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
|
||||
Object.assign(frame, drag.start);
|
||||
@@ -646,7 +647,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
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;
|
||||
const edge = selected ? borderAt(selected, cutBorders(selected), point, tolerance) : null;
|
||||
if (edge) {
|
||||
this.cursor = borderCursor(edge);
|
||||
return;
|
||||
@@ -769,7 +770,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
* edges: a frame nobody sliced still has them, and a line one cannot see is one nobody drags.
|
||||
*/
|
||||
private drawBorders(view: SheetView, frame: TextureFrame): void {
|
||||
const border = frameBorders(frame);
|
||||
const border = cutBorders(frame);
|
||||
for (const edge of BORDER_EDGES) {
|
||||
const line = borderLine(frame, border, edge);
|
||||
const a = imageToLocal(view, line.x1, line.y1);
|
||||
@@ -781,7 +782,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
|
||||
/** The sort line (#228) across the selected frame, with its knob left of it: a line at 0 is on the edge. */
|
||||
private drawSortLine(view: SheetView, frame: TextureFrame): void {
|
||||
const y = sortLineY(frame, frameSortLine(frame));
|
||||
const y = sortLineY(frame, cutSortLine(frame, frame));
|
||||
const a = imageToLocal(view, frame.x, y);
|
||||
const b = imageToLocal(view, frame.x + frame.w, y);
|
||||
dashedLine(this.overlay, a.x, a.y, b.x, b.y, 6, 3);
|
||||
@@ -797,7 +798,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
private sortLineAt(frame: TextureFrame, point: Vec2, tolerance: number): 'knob' | 'line' | null {
|
||||
const gap = SORT_KNOB_GAP / this.view.zoom;
|
||||
const reach = (SORT_KNOB_GAP + SORT_KNOB_SIZE + 2) / this.view.zoom;
|
||||
return sortLineAt(frame, frameSortLine(frame), point, tolerance, gap, reach);
|
||||
return sortLineAt(frame, cutSortLine(frame, frame), point, tolerance, gap, reach);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/** 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 { FrameBorders, PictureCut } from '../../core/model/Asset';
|
||||
import {
|
||||
cutBorders,
|
||||
type CutSize,
|
||||
cutSortLine,
|
||||
type FrameRect,
|
||||
normalizeBorders,
|
||||
} from '../../core/model/texture';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
|
||||
/** Image pixel `(0, 0)` sits at (`panX`, `panY`) in widget-local pixels, at `zoom` per pixel. */
|
||||
@@ -114,12 +120,15 @@ export type BorderEdge = 'left' | 'top' | 'right' | 'bottom';
|
||||
|
||||
export const BORDER_EDGES: readonly BorderEdge[] = ['left', 'top', 'right', 'bottom'];
|
||||
|
||||
export interface SheetLine {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
/** 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 } {
|
||||
export function borderLine(rect: FrameRect, border: FrameBorders, edge: BorderEdge): SheetLine {
|
||||
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 };
|
||||
@@ -172,6 +181,24 @@ export function sortLineY(rect: FrameRect, line: number): number {
|
||||
return rect.y + rect.h - line;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nine-slice and sort lines a drawing shows in its tab (#308), in canvas pixels. Only those that
|
||||
* are set: there they are shown, not dragged, and a line on the edge would only hide the art's rim.
|
||||
*/
|
||||
export function cutLines(
|
||||
cut: PictureCut,
|
||||
size: CutSize,
|
||||
): { borders: SheetLine[]; sortLine: SheetLine | null } {
|
||||
const rect = { x: 0, y: 0, w: size.w, h: size.h };
|
||||
const border = normalizeBorders(cutBorders(cut), size.w, size.h);
|
||||
const borders = BORDER_EDGES.filter((edge) => border[edge] > 0).map((edge) =>
|
||||
borderLine(rect, border, edge),
|
||||
);
|
||||
const line = cutSortLine(cut, size);
|
||||
const y = sortLineY(rect, line);
|
||||
return { borders, sortLine: line > 0 ? { x1: 0, y1: y, x2: size.w, y2: y } : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a press grabs of the sort line: its knob left of the frame, reachable wherever the line sits,
|
||||
* or the line itself inside the frame. The knob spans from `gap` to `reach` left of the frame, in
|
||||
|
||||
Reference in new issue
Block a user