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
@@ -57,6 +57,7 @@ import { clearSaved, hasSaved } from '../saves';
|
||||
import { SoundPreview } from '../sound/SoundPreview';
|
||||
import type { VariablesOwner } from '../state';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { buildCutRows, CUT_ROW_KEYS, refreshCutRows } from './cutSection';
|
||||
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addNameField, addNote, addTitle, type InspectorHost, sectionHeader } from './host';
|
||||
import { buildShapeListSection, shapeListSignature } from './shapeSection';
|
||||
@@ -154,6 +155,8 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void {
|
||||
addTitle(host, 'SCOPE');
|
||||
buildMachineScope(host, asset);
|
||||
}
|
||||
// A drawing is sliced and sorted as a whole, with the rows a texture frame has (#308).
|
||||
if (isDrawing(asset)) buildCutRows(host, { assetId }, 'CANVAS PIXELS');
|
||||
// The drawing itself: its shapes are picked here as a texture's frames are picked in its tab (#129).
|
||||
if (asset.type === 'vectorart') buildShapeListSection(host, asset);
|
||||
// A font has no properties at all, so without this its inspector would be the name and nothing.
|
||||
@@ -175,13 +178,18 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole
|
||||
host.nameField?.setValue(asset.name);
|
||||
const project = host.state.project;
|
||||
const values = shownValues(host, asset);
|
||||
const drawing = isDrawing(asset);
|
||||
for (const [key, row] of host.fields) {
|
||||
if (drawing && CUT_ROW_KEYS.has(key)) continue;
|
||||
row.setValue(values[key] ?? 0);
|
||||
row.setModified(isModified(project, asset, key));
|
||||
}
|
||||
if (drawing) refreshCutRows(host, { assetId });
|
||||
return true;
|
||||
}
|
||||
|
||||
const isDrawing = (asset: Asset): boolean => asset.type === 'pixelart' || asset.type === 'vectorart';
|
||||
|
||||
/** The switches, and the render layer (#302): both say whether the object is seen at all. */
|
||||
const isSwitch = (key: string): boolean =>
|
||||
key === RENDER_LAYER_KEY || COMMON_PROPS.some((def) => def.key === key);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* The NINE-SLICE and SORTING rows of one picture (#41, #228): a frame in the frame inspector, pixel
|
||||
* art and vector art as a whole in the asset inspector (#308). One set of rows for both.
|
||||
*/
|
||||
import { SetSliceCommand, SetSortLineCommand } from '../../core/commands/frameCommands';
|
||||
import type { FrameBorders } from '../../core/model/Asset';
|
||||
import { type CutTarget, findCut, type FoundCut } from '../../core/model/cutTarget';
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import {
|
||||
cutBorders,
|
||||
cutFill,
|
||||
cutSlice,
|
||||
type CutSlice,
|
||||
cutSortLine,
|
||||
DEFAULT_FILL,
|
||||
normalizeSortLine,
|
||||
setCutSlice,
|
||||
setCutSortLine,
|
||||
} from '../../core/model/texture';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
|
||||
const BORDER_COMPONENTS = [
|
||||
{ key: 'left', label: 'Left' },
|
||||
{ key: 'top', label: 'Top' },
|
||||
{ key: 'right', label: 'Right' },
|
||||
{ key: 'bottom', label: 'Bottom' },
|
||||
] as const;
|
||||
|
||||
/** The keys the rows stand under in `host.fields`. */
|
||||
export const CUT_ROW_KEYS: ReadonlySet<string> = new Set([
|
||||
...BORDER_COMPONENTS.map((component) => `border-${component.key}`),
|
||||
'fill',
|
||||
'sortLine',
|
||||
]);
|
||||
|
||||
/** `unit` names the pixels the numbers count: the image's for a frame, the canvas's for a drawing. */
|
||||
export function buildCutRows(host: InspectorHost, target: CutTarget, unit: string): void {
|
||||
addTitle(host, `NINE-SLICE (${unit})`);
|
||||
for (const component of BORDER_COMPONENTS) borderRow(host, target, component.key, component.label);
|
||||
fillRow(host, target);
|
||||
addNote(host, 'All four at zero: the picture is drawn over the whole box, as it always was.');
|
||||
|
||||
addTitle(host, `SORTING (${unit})`);
|
||||
sortLineRow(host, target);
|
||||
addNote(host, 'Where a sprite of this picture stands on a layer sorted by Y, up from the bottom edge.');
|
||||
}
|
||||
|
||||
export function refreshCutRows(host: InspectorHost, target: CutTarget): void {
|
||||
const found = findCut(host.state.project, target);
|
||||
if (!found) return;
|
||||
const border = cutBorders(found.cut);
|
||||
for (const component of BORDER_COMPONENTS)
|
||||
host.fields.get(`border-${component.key}`)?.setValue(border[component.key]);
|
||||
host.fields.get('fill')?.setValue(cutFill(found.cut));
|
||||
host.fields.get('sortLine')?.setValue(cutSortLine(found.cut, found.size));
|
||||
}
|
||||
|
||||
function sortLineRow(host: InspectorHost, target: CutTarget): void {
|
||||
const found = currentCut(host, target);
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
{ key: 'sortLine', label: 'Sort line', kind: 'number', default: 0, min: 0, step: 1, decimals: 0 },
|
||||
found ? cutSortLine(found.cut, found.size) : 0,
|
||||
previewingHandlers<FoundCut, number>(host, {
|
||||
target: () => currentCut(host, target),
|
||||
read: ({ cut, size }) => cutSortLine(cut, size),
|
||||
write: ({ cut, size }, line) => setCutSortLine(cut, size, line),
|
||||
toModel: (input, { size }) => normalizeSortLine(Number(input), size.h),
|
||||
commit: (line) => new SetSortLineCommand(target, line),
|
||||
}),
|
||||
);
|
||||
host.fields.set('sortLine', row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
/**
|
||||
* One border of the nine-slice, in pixels of the picture. 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, target: CutTarget, key: keyof FrameBorders, label: string): void {
|
||||
const found = currentCut(host, target);
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
{ key: `border-${key}`, label, kind: 'number', default: 0, min: 0, step: 1, decimals: 0 },
|
||||
found ? cutBorders(found.cut)[key] : 0,
|
||||
sliceHandlers(host, target, (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, target: CutTarget): void {
|
||||
const found = currentCut(host, target);
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
{
|
||||
key: 'fill',
|
||||
label: 'Fill',
|
||||
kind: 'enum',
|
||||
default: DEFAULT_FILL,
|
||||
options: [
|
||||
{ value: 'repeat', label: 'Repeat' },
|
||||
{ value: 'stretch', label: 'Stretch' },
|
||||
],
|
||||
},
|
||||
found ? cutFill(found.cut) : DEFAULT_FILL,
|
||||
sliceHandlers(host, target, (input, slice) => ({
|
||||
...slice,
|
||||
fill: textOf(input) === 'stretch' ? 'stretch' : 'repeat',
|
||||
})),
|
||||
);
|
||||
host.fields.set('fill', row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
function sliceHandlers(
|
||||
host: InspectorHost,
|
||||
target: CutTarget,
|
||||
apply: (input: PropertyValue, slice: CutSlice) => CutSlice,
|
||||
): FieldHandlers {
|
||||
return previewingHandlers<FoundCut, CutSlice>(host, {
|
||||
target: () => currentCut(host, target),
|
||||
read: ({ cut }) => cutSlice(cut),
|
||||
write: ({ cut, size }, slice) => setCutSlice(cut, size, slice),
|
||||
toModel: (input, { cut }) => apply(input, cutSlice(cut)),
|
||||
commit: (slice) => new SetSliceCommand(target, slice),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Looked up inside the selected asset rather than by frame id alone, so a selection left over
|
||||
* from a deleted texture cannot edit whichever frame later inherits the id.
|
||||
*/
|
||||
function currentCut(host: InspectorHost, target: CutTarget): FoundCut | undefined {
|
||||
return findCut(host.state.project, target) ?? undefined;
|
||||
}
|
||||
@@ -2,28 +2,12 @@ import {
|
||||
RemoveFrameCommand,
|
||||
RenameFrameCommand,
|
||||
SetFrameRectCommand,
|
||||
SetFrameSliceCommand,
|
||||
SetFrameSortLineCommand,
|
||||
} from '../../core/commands/frameCommands';
|
||||
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import {
|
||||
DEFAULT_FILL,
|
||||
frameBorders,
|
||||
frameFill,
|
||||
frameRect,
|
||||
type FrameRect,
|
||||
frameSlice,
|
||||
type FrameSlice,
|
||||
frameSortLine,
|
||||
normalizeFrameRect,
|
||||
normalizeSortLine,
|
||||
setFrameRect,
|
||||
setFrameSlice,
|
||||
setFrameSortLine,
|
||||
} from '../../core/model/texture';
|
||||
import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture';
|
||||
import { Button } from '../../ui';
|
||||
import { buildCutRows, refreshCutRows } from './cutSection';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
@@ -35,13 +19,6 @@ 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';
|
||||
@@ -68,15 +45,7 @@ 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.');
|
||||
|
||||
addTitle(host, 'SORTING (IMAGE PIXELS)');
|
||||
sortLineRow(host, assetId, frameId);
|
||||
addNote(host, 'Where a sprite of this frame stands on a layer sorted by Y, up from the bottom edge.');
|
||||
buildCutRows(host, { assetId, frameId }, 'IMAGE PIXELS');
|
||||
|
||||
addGap(host);
|
||||
host.body.add(
|
||||
@@ -93,97 +62,10 @@ 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));
|
||||
host.fields.get('sortLine')?.setValue(frameSortLine(frame));
|
||||
refreshCutRows(host, { assetId, frameId });
|
||||
return true;
|
||||
}
|
||||
|
||||
function sortLineRow(host: InspectorHost, assetId: string, frameId: string): void {
|
||||
const frame = currentFrame(host.state.project, assetId, frameId);
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
{ key: 'sortLine', label: 'Sort line', kind: 'number', default: 0, min: 0, step: 1, decimals: 0 },
|
||||
frame ? frameSortLine(frame) : 0,
|
||||
previewingHandlers<TextureFrame, number>(host, {
|
||||
target: () => currentFrame(host.state.project, assetId, frameId),
|
||||
read: frameSortLine,
|
||||
write: setFrameSortLine,
|
||||
toModel: (input, frame) => normalizeSortLine(Number(input), frame.h),
|
||||
commit: (line) => new SetFrameSortLineCommand(assetId, frameId, line),
|
||||
}),
|
||||
);
|
||||
host.fields.set('sortLine', row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: DEFAULT_FILL,
|
||||
options: [
|
||||
{ value: 'repeat', label: 'Repeat' },
|
||||
{ value: 'stretch', label: 'Stretch' },
|
||||
],
|
||||
},
|
||||
frame ? frameFill(frame) : DEFAULT_FILL,
|
||||
sliceHandlers(host, assetId, frameId, (input, slice) => ({
|
||||
...slice,
|
||||
fill: textOf(input) === 'stretch' ? 'stretch' : 'repeat',
|
||||
})),
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'pixi.js';
|
||||
import { EditPixelArtCommand } from '../../core/commands/pixelArtCommands';
|
||||
import type { PixelArtAsset, PixelFrame } from '../../core/model/Asset';
|
||||
import { drawnCutSize } from '../../core/model/cutTarget';
|
||||
import {
|
||||
celOf,
|
||||
composeFrame,
|
||||
@@ -442,6 +443,7 @@ export class PixelArtCanvas extends SheetCanvas<PixelArtAsset> {
|
||||
|
||||
if (zoom >= GRID_FROM_ZOOM) this.drawGrid(asset, panX, panY, zoom);
|
||||
this.overlay.rect(panX - 0.5, panY - 0.5, w + 1, h + 1).stroke({ width: 1, color: Theme.colors.border });
|
||||
this.drawCutLines(this.overlay, asset, drawnCutSize(asset));
|
||||
const hover = this.hover;
|
||||
if (hover && !stroke && this.tool !== 'fill' && this.tool !== 'picker') {
|
||||
const n = this.brush;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
VectorRing,
|
||||
VectorShape,
|
||||
} from '../../core/model/Asset';
|
||||
import { drawnCutSize } from '../../core/model/cutTarget';
|
||||
import { IDENTITY, type Mat } from '../../core/model/matrix';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
@@ -992,6 +993,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
paintDrawing(this.art, drawing, this.paints);
|
||||
this.paints.sweep();
|
||||
this.drawFrame(origin, size);
|
||||
this.drawCutLines(this.overlay, asset, drawnCutSize(asset));
|
||||
for (const shapeId of this.selectedShapeIds())
|
||||
if (shapeId !== this.selectedShapeId()) this.drawOutline(this.shape(shapeId));
|
||||
if (this.tool === 'paint') {
|
||||
|
||||
Reference in new issue
Block a user