Files
feuerwerk/src/editor/inspector/frameSection.ts
T
adminandClaude Opus 5.5 3e05cbbed5 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>
2026-10-03 19:53:51 +02:00

127 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
RemoveFrameCommand,
RenameFrameCommand,
SetFrameRectCommand,
} from '../../core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
import { findAssetOf, type Project } from '../../core/model/Project';
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';
const RECT_COMPONENTS = [
{ key: 'x', label: 'X' },
{ key: 'y', label: 'Y' },
{ key: 'w', label: 'Width' },
{ key: 'h', label: 'Height' },
] 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';
}
/** Name, the rectangle it cuts out of its texture, and the way to delete it. */
export function buildFrameSection(host: InspectorHost, assetId: string, frameId: string): void {
const asset = currentTexture(host.state.project, assetId);
const frame = asset?.frames.find((f) => f.id === frameId);
if (!asset || !frame) return;
addTitle(host, 'TEXTURE FRAME');
addNameField(host, frame.name, {
onChange: (name) => {
// The command rejects a blank name but cannot put the old text back into the field,
// so an empty one is not sent at all.
if (name.trim()) host.state.execute(new RenameFrameCommand(assetId, frameId, name.trim()));
else host.nameField?.setValue(frame.name);
},
});
addNote(host, `Frame of the texture "${asset.name}".`);
addTitle(host, 'RECTANGLE (IMAGE PIXELS)');
for (const component of RECT_COMPONENTS)
rectRow(host, assetId, frameId, component.key, component.label, frame[component.key]);
buildCutRows(host, { assetId, frameId }, 'IMAGE PIXELS');
addGap(host);
host.body.add(
new Button('Delete frame', {
variant: 'danger',
onClick: () => host.state.execute(new RemoveFrameCommand(assetId, frameId)),
}),
);
}
/** Pushes the frame's current name and rectangle into the rows. False when it is gone (rebuild). */
export function refreshFrameSection(host: InspectorHost, assetId: string, frameId: string): boolean {
const frame = currentFrame(host.state.project, assetId, frameId);
if (!frame) return false;
host.nameField?.setValue(frame.name);
for (const component of RECT_COMPONENTS) host.fields.get(component.key)?.setValue(frame[component.key]);
refreshCutRows(host, { assetId, frameId });
return true;
}
/** A number row editing one component of the rectangle. Whole pixels: a frame indexes a bitmap. */
function rectRow(
host: InspectorHost,
assetId: string,
frameId: string,
key: keyof FrameRect,
label: string,
value: number,
): void {
const row = createFieldRow(
host,
{ key, label, kind: 'number', default: 0, step: 1, decimals: 0 },
value,
rectHandlers(host, assetId, frameId, key),
);
host.fields.set(key, row);
host.body.add(row.row);
}
/**
* The whole rectangle with one component replaced; the other three are read from the model at
* that moment, so a frame dragged in the Texture tab in between is not undone. Normalising the
* preview with the same function the command uses keeps it on the sheet and identical to what
* is committed.
*/
function rectHandlers(
host: InspectorHost,
assetId: string,
frameId: string,
key: keyof FrameRect,
): FieldHandlers {
return previewingHandlers<TextureFrame, FrameRect>(host, {
target: () => currentFrame(host.state.project, assetId, frameId),
read: frameRect,
write: setFrameRect,
toModel: (input, frame) => {
const asset = currentTexture(host.state.project, assetId);
return normalizeFrameRect(
{ ...frameRect(frame), [key]: Number(input) },
asset?.width ?? 0,
asset?.height ?? 0,
);
},
commit: (rect) => new SetFrameRectCommand(assetId, frameId, rect),
});
}
/**
* Looked up inside the selected texture rather than by frame id alone, so a selection left over
* from a deleted texture cannot edit whichever frame later inherits the id.
*/
function currentFrame(project: Project, assetId: string, frameId: string): TextureFrame | undefined {
return currentTexture(project, assetId)?.frames.find((f) => f.id === frameId);
}
function currentTexture(project: Project, assetId: string): TextureAsset | undefined {
return findAssetOf(project, assetId, 'texture');
}