Files
feuerwerk/src/editor/inspector/shapeSection.ts
T
adminandClaude Opus 5.5 9e869df063
game-maker / gate (push) Successful in 2m48s
game-maker / suite (push) Successful in 13m44s
Free a redrawn picture only once no shader names it (#293)
Editing a vector drawing made Pixi warn "destroyed while still bound
to a shader", twice with every change, once it had begun. It began
where an object wears the drawing through a shader asset. Each
change draws the picture anew and destroyed the one before it, but
the mesh of that object keeps its picture bound until it syncs. The
issue also asks for the button "Add shape" to go: a shape without
points cannot be given any, the Pen draws a new one.

- GeneratedPictures takes two more callbacks. inUse says that a
  picture cannot be freed yet, soon runs a retry once whoever asked
  is done.
- A replaced picture that is still in use waits in a list of its
  own. It is freed right after the redraw that asked, as a rule. On
  a later ask where its wearer was not part of that redraw.
- TextureLibrary tells a bound picture by the listeners for change
  on its source. Under WebGL the bind groups are the only ones that
  listen there. The retry is a microtask.
- The rule is asked for every drawn picture alike, whoever wears it.
- The inspector of a drawing and of a shape has no "Add shape" any
  more. "Delete shapes" for several selected shapes stays.

Decided here:

- Under the list of shapes a second note says "The Pen draws a new
  shape." while only the asset is selected.

Found on the way:

- Reproduced on a throwaway copy of the project the report came
  from: 6 warnings for 3 changes with the shader override on the
  object, none without it. After the change none, and no replaced
  picture is left waiting.
- The fixture project of the tests never showed the warning. No
  object there wore a drawing through a shader.

Tests:
- Gate on melektaus-atx: 211 files, 3219 tests (3209 + 10), exit 0
- e2e 47,48 on melektaus-atx: 113 checks, all pass
- e2e 28,30,31,39,40,47,48: 350 checks, all pass, run before the
  wording of the new note changed
- The first gate was red: an existing test read the wording of the
  note that had changed. Only three test files had been run after
  the change.
- No full run and no smokes. The sum of 1668 (1667 + 1) is reckoned,
  1667 is what CI counted for ec2fd90.
- Ten unit tests, five of them against the real bind group of Pixi
  in tests/viewport/bound-pictures.test.ts
- One e2e check in block 47b-ii of module 47: a drawing worn through
  a shader, three changes, no warning. The check on "Add shape" now
  reads that there is no such button.
- Nine unit counter-probes and two browser counter-probes all fail
  their tests. Without the change the new check counts 6 warnings.
- Not checked: a tilemap or a particle emitter holding a picture
  bound the same way. Nothing was looked at after the build, the
  new note included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:03:37 +02:00

906 lines
34 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.
/**
* The rows of a vector art shape (#129, stage 4, answer 8): the list of shapes with a preview each,
* the four buttons that move one in the drawing order, and the style of the selected one – fill,
* opacity and outline. The canvas size is an ordinary property and stands in the asset's own rows.
*
* The list is built for both selections: picking the asset shows the shapes without any of them
* being chosen, picking a shape shows the same list with its style underneath. That is why the
* asset's signature has to name the shapes too – a shape added, removed or renamed changes the
* rows, and only a rebuild puts the new list on screen.
*/
import {
AddVectorLayerCommand,
CombineVectorShapesCommand,
DuplicateVectorShapeCommand,
MoveVectorLayerCommand,
MoveVectorShapesCommand,
OrderVectorShapeCommand,
RemoveVectorLayerCommand,
RemoveVectorShapeCommand,
RenameVectorLayerCommand,
RenameVectorShapeCommand,
SetVectorLayerHiddenCommand,
SetVectorShapeStyleCommand,
} from '../../core/commands/vectorArtCommands';
import type {
VectorArtAsset,
VectorGradient,
VectorItem,
VectorPaint,
VectorShape,
VectorStroke,
} from '../../core/model/Asset';
import { type PropertyValue, textOf } from '../../core/model/Properties';
import { findAssetOf, type Project } from '../../core/model/Project';
import { findShape, SHAPE_FILL_DEFAULT, shapeExtent, vectorCanvasSize } from '../../core/model/vectorart';
import {
allShapes,
canOrderItem,
findVectorItem,
isVectorGroup,
placeOf,
selectedItems,
type ShapeOrderMove,
shapesIn,
treeRows,
} from '../../core/model/vectorTree';
import { combineBlocker, type ShapeBoolean } from '../../core/model/vectorBoolean';
import {
FILL_KINDS,
type FillKind,
fillKind,
fillPaintFor,
MIN_STOPS,
paintExtent,
STROKE_KINDS,
type StrokeKind,
strokeKind,
strokePaintFor,
withoutStop,
withStopChanged,
} from '../../core/model/vectorPaint';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, HStack, SortableList, Theme } from '../../ui';
import { removeShapes } from '../actions';
import { FramePreview } from '../framePreview';
import { listFold } from '../sectionFolds';
import { type EditorState, shapeSelection } from '../state';
import { activeVectorLayer, pickVectorLayer } from '../vectorart/activeLayer';
import { groupBlocker, groupShapes, groupsToDissolve, ungroupShapes } from '../vectorart/grouping';
import { GradientBar } from '../vectorart/GradientBar';
import { ShapeList } from '../vectorart/ShapeList';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
import { addTransformRows, hasShapeFrame, refreshTransformRows } from './shapeTransformRows';
/** The outline a shape gets when one is switched on: black, two canvas pixels wide. */
const DEFAULT_STROKE: VectorStroke = { color: 0x000000, width: 2 };
/** What a style row writes while it is being dragged; nothing is ever taken away by a preview. */
interface PreviewStyle {
fill?: number;
opacity?: number;
paint?: VectorPaint;
stroke?: VectorStroke;
}
/** Whose gradient a bar and its stop rows edit: the fill's or the outline's (#186). */
export type PaintPart = 'fill' | 'stroke';
const FILL_KIND_LABELS: Record<FillKind, string> = {
solid: 'Solid color',
linear: 'Linear gradient',
radial: 'Radial gradient',
texture: 'Texture',
};
const FILL_TYPE_DEF: PropertyDef = {
key: 'fillType',
label: 'Fill type',
kind: 'enum',
default: 'solid',
options: FILL_KINDS.map((value) => ({
value,
label: FILL_KIND_LABELS[value],
})),
};
const LINE_TYPE_DEF: PropertyDef = {
key: 'lineType',
label: 'Line type',
kind: 'enum',
default: 'solid',
options: STROKE_KINDS.map((value) => ({
value,
label: FILL_KIND_LABELS[value],
})),
};
const TEXTURE_DEF: PropertyDef = {
key: 'fillTexture',
label: 'Texture',
kind: 'texture',
default: '',
uploadedOnly: true,
};
/** The three rows of the picked stop; the outline's carry a prefix so both sets can stand at once. */
function stopDefs(part: PaintPart): { color: PropertyDef; alpha: PropertyDef; offset: PropertyDef } {
const prefix = part === 'fill' ? 'fillStop' : 'lineStop';
return {
color: { key: `${prefix}Color`, label: 'Stop color', kind: 'color', default: 0xffffff },
alpha: {
key: `${prefix}Alpha`,
label: 'Stop opacity',
kind: 'number',
default: 1,
min: 0,
max: 1,
step: 0.05,
decimals: 2,
},
offset: {
key: `${prefix}Offset`,
label: 'Stop position',
kind: 'number',
default: 0,
min: 0,
max: 1,
step: 0.01,
decimals: 2,
},
};
}
/**
* The stop picked in each bar, by asset, shape and part. It outlives a rebuild of the rows – undo
* rebuilds them – and falls back to the first stop when the one it names has gone.
*/
const pickedStops = new Map<string, number>();
function pickedStop(
assetId: string,
shapeId: string,
part: PaintPart,
gradient: VectorGradient | undefined,
): number {
const index = pickedStops.get(`${assetId}/${shapeId}/${part}`) ?? 0;
return gradient && index < gradient.stops.length ? index : 0;
}
/** The bars as they were last built, for the check scripts. */
const builtBars = new Map<PaintPart, GradientBar>();
export function currentGradientBar(part: PaintPart): GradientBar | null {
return builtBars.get(part) ?? null;
}
const ORDER_BUTTONS: { move: ShapeOrderMove; label: string }[] = [
{ move: 'front', label: 'To front' },
{ move: 'forward', label: 'Forward' },
{ move: 'backward', label: 'Backward' },
{ move: 'back', label: 'To back' },
];
const COMBINE_BUTTONS: { op: ShapeBoolean; label: string }[] = [
{ op: 'union', label: 'Union' },
{ op: 'subtract', label: 'Subtract' },
{ op: 'intersect', label: 'Intersect' },
{ op: 'exclude', label: 'Exclude' },
];
/** The layer the selected shapes stand on (#190); picking another one moves them there. */
const LAYER_KEY = 'shapeLayer';
const FILL_DEF: PropertyDef = { key: 'fill', label: 'Fill', kind: 'color', default: SHAPE_FILL_DEFAULT };
const OPACITY_DEF: PropertyDef = {
key: 'opacity',
label: 'Opacity',
kind: 'number',
default: 1,
min: 0,
max: 1,
step: 0.05,
decimals: 2,
};
const OUTLINE_DEF: PropertyDef = { key: 'outline', label: 'Outline', kind: 'boolean', default: false };
const STROKE_COLOR_DEF: PropertyDef = {
key: 'strokeColor',
label: 'Line color',
kind: 'color',
default: DEFAULT_STROKE.color,
};
const STROKE_WIDTH_DEF: PropertyDef = {
key: 'strokeWidth',
label: 'Line width',
kind: 'number',
default: DEFAULT_STROKE.width,
min: 0.1,
step: 0.5,
decimals: 1,
};
/** The lists as they were last built, so a check can address a row that carries a name, not a widget. */
let built: ShapeList | null = null;
let builtLayers: SortableList | null = null;
/** Null while no vector art shapes are on screen. */
export function currentShapeList(): ShapeList | null {
return built;
}
/** Null while no vector art is in the inspector. */
export function currentLayerList(): SortableList | null {
return builtLayers;
}
/**
* What a drawing makes of the rows: its layers, which of them is worked on, and the tree of that
* one – order, names and who stands in which group. `shapeId` is the selected shape, if one is.
*/
export function shapeListSignature(asset: VectorArtAsset, shapeId = ''): string {
const active = activeVectorLayer(asset, shapeId);
const layers = asset.layers.map((layer) => `${layer.id}:${layer.name}:${layer.hidden ? 'h' : 's'}`);
const tree = treeRows(active?.items ?? []).map((row) => `${row.id}:${row.name}:${row.depth}`);
return `${layers.join(',')}@${active?.id ?? ''}[${tree.join(',')}]`;
}
/**
* Structure-defining facts of a shape selection: the lists above it, whether the shape has an
* outline – its colour and width are rows of their own and appear with it – and whether the
* selected shapes can be combined, which switches the four buttons and the note under them.
*/
export function shapeSignature(
project: Project,
assetId: string,
shapeId: string,
shapeIds: readonly string[],
): string {
const asset = findAssetOf(project, assetId, 'vectorart');
const shape = asset && findShape(asset, shapeId);
if (!asset || !shape) return 'missing';
const blocked = combineBlocker(selectedShapes(asset, shapeIds)) ?? '';
const paints = `${fillKind(shape)}/${shape.stroke ? strokeKind(shape) : 'none'}`;
// A shape gets its TRANSFORM rows with its second point: before that it has no size to show.
const framed = hasShapeFrame(asset, shapeIds) ? 'framed' : 'bare';
return `${shapeListSignature(asset, shapeId)}|${paints}|${shapeIds.join(',')}|${blocked}|${framed}`;
}
/** The selected shapes in drawing order, the lowest first – the order they are combined in. */
function selectedShapes(asset: VectorArtAsset, shapeIds: readonly string[]): VectorShape[] {
return allShapes(asset).filter((shape) => shapeIds.includes(shape.id));
}
/** The shapes of the asset, with nothing chosen: what the asset's own section shows underneath. */
export function buildShapeListSection(host: InspectorHost, asset: VectorArtAsset): void {
addLayerList(host, asset, '');
addShapeList(host, asset, '', []);
addNote(host, 'The top row is drawn over the ones below it.', 'small');
// No button adds a shape (#293): one without points could not be given any.
addNote(host, 'The Pen draws a new shape.', 'small');
}
/**
* Name, place in the drawing order and style of the primary shape, under the lists it is picked
* from, and the buttons that combine all selected shapes into one (#187) or group them (#190).
*/
export function buildShapeSection(
host: InspectorHost,
assetId: string,
shapeId: string,
shapeIds: readonly string[],
): void {
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
const shape = asset && findShape(asset, shapeId);
if (!asset || !shape) return;
addLayerList(host, asset, shapeId);
addShapeList(host, asset, shapeId, shapeIds);
addNote(host, 'Drag a row to draw it earlier or later, or into a group.', 'small');
// A group picked as a whole is what the buttons move; otherwise it is the primary shape.
const items = selectedItems(asset.layers, shapeIds);
const ordered = items.length === 1 ? items[0]! : shapeId;
// Two rows of two: four of them side by side cut "Backward" down to "Backw…" at the panel's width.
for (let start = 0; start < ORDER_BUTTONS.length; start += 2) {
const order = new HStack({ gap: Theme.gap });
for (const { move, label } of ORDER_BUTTONS.slice(start, start + 2)) {
const button = new Button(label, {
variant: 'ghost',
onClick: () => void host.state.execute(new OrderVectorShapeCommand(assetId, ordered, move)),
});
// A shape already at the front of its list has nowhere to go forwards.
button.setEnabled(canOrderItem(asset.layers, ordered, move));
order.add(button, 1);
}
host.body.add(order);
}
addGroupButtons(host, asset, shapeId, shapeIds);
addTransformRows(host, assetId, shapeIds);
addCombineButtons(host, asset, shapeIds);
addTitle(host, 'VECTOR SHAPE');
addShapeName(host, assetId, shape);
addLayerRow(host, asset, shapeId, shapeIds);
fillRows(host, assetId, shape);
styleRow(
host,
assetId,
shapeId,
OPACITY_DEF,
(s) => s.opacity,
(input) => ({ opacity: Number(input) }),
'Set opacity',
);
outlineRow(host, assetId, shapeId, shape);
if (shape.stroke) {
kindRow(host, assetId, shape, 'stroke');
if (strokeKind(shape) === 'solid')
styleRow(
host,
assetId,
shapeId,
STROKE_COLOR_DEF,
(s) => s.stroke?.color ?? DEFAULT_STROKE.color,
(input, s) => ({ stroke: { ...strokeOf(s), color: Number(input) } }),
'Set line color',
);
else gradientRows(host, assetId, shapeId, 'stroke');
styleRow(
host,
assetId,
shapeId,
STROKE_WIDTH_DEF,
(s) => s.stroke?.width ?? DEFAULT_STROKE.width,
(input, s) => ({ stroke: { ...strokeOf(s), width: Math.max(0.1, Number(input)) } }),
'Set line width',
);
} else addNote(host, 'Without an outline only the fill is drawn.', 'small');
// One shape goes by the cross on its row; several at once only from here. There is no button
// that adds one (#293): a shape without points could not be given any, the Pen draws a new one.
if (shapeIds.length > 1) {
addGap(host);
host.body.add(
new Button('Delete shapes', {
variant: 'danger',
onClick: () => removeShapes(host.state, assetId, shapeIds),
}),
);
}
}
/**
* Group and Ungroup (#190). Group puts what is selected into a new group, a group picked as a whole
* going in as one. Ungroup takes the picked groups away, or the group the primary shape stands in.
*/
function addGroupButtons(
host: InspectorHost,
asset: VectorArtAsset,
shapeId: string,
shapeIds: readonly string[],
): void {
const row = new HStack({ gap: Theme.gap });
const group = new Button('Group', { variant: 'ghost', onClick: () => groupShapes(host.state, asset.id) });
const ungroup = new Button('Ungroup', {
variant: 'ghost',
onClick: () => ungroupShapes(host.state, asset.id),
});
group.setEnabled(groupBlocker(asset, shapeIds) === null);
ungroup.setEnabled(groupsToDissolve(asset, shapeId, shapeIds).length > 0);
row.add(group, 1);
row.add(ungroup, 1);
host.body.add(row);
}
/** The layer the selection stands on, as a row; with one layer there is nothing to choose. */
function addLayerRow(
host: InspectorHost,
asset: VectorArtAsset,
shapeId: string,
shapeIds: readonly string[],
): void {
if (asset.layers.length < 2) return;
const def: PropertyDef = {
key: LAYER_KEY,
label: 'Layer',
kind: 'enum',
default: '',
options: [...asset.layers].reverse().map((layer) => ({ value: layer.id, label: layer.name })),
};
const current = placeOf(asset.layers, shapeId)?.layer.id ?? '';
const row = createFieldRow(host, def, current, {
onInput: () => {},
onChange: (input) => moveShapesToLayer(host.state, asset.id, shapeIds, textOf(input)),
});
host.fields.set(LAYER_KEY, row);
host.body.add(row.row);
}
/** Moves the selected shapes, groups as wholes, on top of another layer. Exported as `setPaintKind` is. */
export function moveShapesToLayer(
state: EditorState,
assetId: string,
shapeIds: readonly string[],
layerId: string,
): void {
const asset = findAssetOf(state.project, assetId, 'vectorart');
if (!asset) return;
const items = selectedItems(asset.layers, shapeIds);
if (items.every((id) => placeOf(asset.layers, id)?.layer.id === layerId)) return;
state.execute(new MoveVectorShapesCommand(assetId, items, layerId));
}
/**
* Union, subtract, intersect, exclude – two rows of two, like the order buttons. They are there with
* one shape selected too, switched off, so the note under them can say how to get a second one.
*/
function addCombineButtons(host: InspectorHost, asset: VectorArtAsset, shapeIds: readonly string[]): void {
const shapes = selectedShapes(asset, shapeIds);
const blocked = combineBlocker(shapes);
addTitle(host, 'COMBINE');
for (let start = 0; start < COMBINE_BUTTONS.length; start += 2) {
const row = new HStack({ gap: Theme.gap });
for (const { op, label } of COMBINE_BUTTONS.slice(start, start + 2)) {
const button = new Button(label, {
variant: 'ghost',
onClick: () => {
const command = new CombineVectorShapesCommand(asset.id, shapeIds, op);
if (host.state.execute(command)) host.state.select(shapeSelection(asset.id, [command.shapeId]));
},
});
button.setEnabled(blocked === null);
row.add(button, 1);
}
host.body.add(row);
}
addNote(host, blocked ?? `The result keeps "${shapes[0]?.name ?? ''}", the lowest.`, 'small');
}
/** Pushes the shape's current name and style into the rows. False when it is gone (rebuild). */
export function refreshShapeSection(host: InspectorHost, assetId: string, shapeId: string): boolean {
const shape = currentShape(host, assetId, shapeId);
if (!shape) return false;
host.fields.get('name')?.setValue(shape.name);
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
if (asset) host.fields.get(LAYER_KEY)?.setValue(placeOf(asset.layers, shapeId)?.layer.id ?? '');
refreshTransformRows(host, assetId, host.state.selectedShapeIds);
host.fields.get('fill')?.setValue(shape.fill);
host.fields.get(FILL_TYPE_DEF.key)?.setValue(fillKind(shape));
host.fields.get(LINE_TYPE_DEF.key)?.setValue(strokeKind(shape));
host.fields.get(TEXTURE_DEF.key)?.setValue(shape.paint?.type === 'texture' ? shape.paint.texture : '');
refreshStopRows(host, assetId, shape, 'fill');
refreshStopRows(host, assetId, shape, 'stroke');
host.fields.get('opacity')?.setValue(shape.opacity);
host.fields.get('outline')?.setValue(Boolean(shape.stroke));
host.fields.get('strokeColor')?.setValue(shape.stroke?.color ?? DEFAULT_STROKE.color);
host.fields.get('strokeWidth')?.setValue(shape.stroke?.width ?? DEFAULT_STROKE.width);
return true;
}
/** The layers of the drawing, the top one drawn last; the marked one is the one being worked on. */
function addLayerList(host: InspectorHost, asset: VectorArtAsset, shapeId: string): void {
const { state } = host;
const active = activeVectorLayer(asset, shapeId);
const list = new SortableList({
title: 'Layers',
...listFold('inspector', 'Layers'),
reversed: true,
keepOne: true,
onPick: (layerId) => pickVectorLayer(state, asset.id, layerId),
onMove: (from, to) => void state.execute(new MoveVectorLayerCommand(asset.id, from, to)),
onToggleHidden: (layerId) => {
const layer = asset.layers.find((l) => l.id === layerId);
if (layer) state.execute(new SetVectorLayerHiddenCommand(asset.id, layerId, !layer.hidden));
},
onAdd: () => {
const command = new AddVectorLayerCommand(asset.id, active?.id ?? '');
if (state.execute(command)) pickVectorLayer(state, asset.id, command.layerId);
},
onRemove: (layerId) => void state.execute(new RemoveVectorLayerCommand(asset.id, layerId)),
onRename: (layerId, name) => void state.execute(new RenameVectorLayerCommand(asset.id, layerId, name)),
});
list.setItems(
asset.layers.map((layer) => ({ id: layer.id, label: layer.name, hidden: layer.hidden === true })),
active ? [active.id] : [],
);
builtLayers = list;
host.body.add(list);
}
/** The tree of the active layer; a group whose shapes are all selected is marked with them. */
function addShapeList(
host: InspectorHost,
asset: VectorArtAsset,
shapeId: string,
selectedIds: readonly string[],
): void {
const layer = activeVectorLayer(asset, shapeId);
const rows = treeRows(layer?.items ?? []);
if (!layer || rows.length === 0) {
built = null;
addTitle(host, 'SHAPES');
addNote(
host,
allShapes(asset).length === 0
? 'Nothing drawn yet – take the Pen in the Vector art tab.'
: 'Nothing on this layer yet – the Pen draws onto it.',
);
return;
}
const { state } = host;
const list = new ShapeList(state, asset.id, rows, markedRows(asset, selectedIds), {
onPick: (itemId, toggle) => pickShapeRow(state, asset.id, itemId, toggle),
onDrop: (itemId, drop) =>
void state.execute(new MoveVectorShapesCommand(asset.id, [itemId], layer.id, drop)),
onDuplicate: (itemId) => {
const command = new DuplicateVectorShapeCommand(asset.id, itemId);
if (state.execute(command)) pickShapeRow(state, asset.id, command.shapeId, false);
},
onRemove: (itemId) => removeShape(host, asset.id, itemId),
onRename: (itemId, name) => void state.execute(new RenameVectorShapeCommand(asset.id, itemId, name)),
});
built = list;
host.body.add(list);
}
/** The selected shapes, and every group all of whose shapes are selected. */
function markedRows(asset: VectorArtAsset, shapeIds: readonly string[]): string[] {
const picked = new Set(shapeIds);
const groups: string[] = [];
const walk = (items: readonly VectorItem[]): void => {
for (const item of items) {
if (!isVectorGroup(item)) continue;
if (shapesIn(item.items).every((shape) => picked.has(shape.id))) groups.push(item.id);
walk(item.items);
}
};
for (const layer of asset.layers) walk(layer.items);
return [...shapeIds, ...groups];
}
/** The shapes a row stands for: itself, or all that is in the group. */
function shapeIdsOf(state: EditorState, assetId: string, itemId: string): string[] {
const asset = findAssetOf(state.project, assetId, 'vectorart');
const item = asset && findVectorItem(asset, itemId);
if (!item) return [];
return isVectorGroup(item) ? shapesIn(item.items).map((shape) => shape.id) : [item.id];
}
/**
* A row pressed. A group's row selects all that is in it. With Ctrl or Shift the row's shapes join
* the selection, or leave it when all of them were in it already. Exported as `setPaintKind` is.
*/
export function pickShapeRow(state: EditorState, assetId: string, itemId: string, toggle: boolean): void {
const ids = shapeIdsOf(state, assetId, itemId);
if (ids.length === 0) return;
if (!toggle) {
state.select(shapeSelection(assetId, ids));
return;
}
const held =
state.selection?.kind === 'shape' && state.selection.assetId === assetId ? state.selectedShapeIds : [];
const all = ids.every((id) => held.includes(id));
const next = all
? held.filter((id) => !ids.includes(id))
: [...held, ...ids.filter((id) => !held.includes(id))];
state.select(shapeSelection(assetId, next) ?? { kind: 'asset', assetId });
}
/** The cross on a row: the item goes, and with it out of the selection – the rest stays selected. */
function removeShape(host: InspectorHost, assetId: string, itemId: string): void {
const gone = shapeIdsOf(host.state, assetId, itemId);
const rest = host.state.selectedShapeIds.filter((id) => !gone.includes(id));
const wasSelected = rest.length !== host.state.selectedShapeIds.length;
if (!host.state.execute(new RemoveVectorShapeCommand(assetId, itemId)) || !wasSelected) return;
host.state.select(rest.length > 0 ? shapeSelection(assetId, rest) : { kind: 'asset', assetId });
}
/**
* The name row. Not `addNameField`: the shape's name also stands in the list right above it, and a
* blank one is refused by the command, which cannot put the old text back into the field.
*/
function addShapeName(host: InspectorHost, assetId: string, shape: VectorShape): void {
const row = createFieldRow(host, { key: 'name', label: 'Name', kind: 'text', default: '' }, shape.name, {
onInput: () => {},
onChange: (value) => {
const name = textOf(value).trim();
if (name && name !== shape.name)
host.state.execute(new RenameVectorShapeCommand(assetId, shape.id, name));
else host.fields.get('name')?.setValue(shape.name);
},
});
host.fields.set('name', row);
host.nameField = null;
host.body.add(row.row);
}
/** An outline switched on gets a black line; switched off it is taken away, not made invisible. */
function outlineRow(host: InspectorHost, assetId: string, shapeId: string, shape: VectorShape): void {
const row = createFieldRow(host, OUTLINE_DEF, Boolean(shape.stroke), {
onInput: () => {},
onChange: (value) => {
const current = currentShape(host, assetId, shapeId);
if (!current) return;
const stroke = value ? (current.stroke ?? DEFAULT_STROKE) : null;
host.state.execute(
new SetVectorShapeStyleCommand(
assetId,
shapeId,
{ stroke },
value ? 'Add outline' : 'Remove outline',
),
);
},
});
host.fields.set('outline', row);
host.body.add(row.row);
}
/**
* One style row, previewing into the shape while a colour is dragged and committing on release –
* the same rule every other row follows. `toStyle` is asked on the shape as it is at that moment,
* so a line width typed after a colour does not take the colour back.
*/
function styleRow(
host: InspectorHost,
assetId: string,
shapeId: string,
def: PropertyDef,
read: (shape: VectorShape) => number,
toStyle: (input: PropertyValue, shape: VectorShape) => PreviewStyle,
label: string,
): void {
const shape = currentShape(host, assetId, shapeId);
const handlers: FieldHandlers = previewingHandlers<VectorShape, PropertyValue>(host, {
target: () => currentShape(host, assetId, shapeId),
read,
write: (target, value) => applyPreview(target, toStyle(value, target)),
commit: (value) =>
new SetVectorShapeStyleCommand(
assetId,
shapeId,
toStyle(value, shapeNow(host, assetId, shapeId)),
label,
),
});
const row = createFieldRow(host, def, shape ? read(shape) : def.default, handlers);
host.fields.set(def.key, row);
host.body.add(row.row);
}
/** Writes a previewed style straight into the shape; the command records the value from before. */
function applyPreview(shape: VectorShape, style: PreviewStyle): void {
if (style.fill !== undefined) shape.fill = style.fill;
if (style.opacity !== undefined) shape.opacity = Math.max(0, Math.min(1, style.opacity));
if (style.paint !== undefined) shape.paint = style.paint;
if (style.stroke !== undefined) shape.stroke = { ...style.stroke };
}
function strokeOf(shape: VectorShape): VectorStroke {
return shape.stroke ?? DEFAULT_STROKE;
}
function currentShape(host: InspectorHost, assetId: string, shapeId: string): VectorShape | undefined {
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
return asset ? findShape(asset, shapeId) : undefined;
}
/** The shape as it is right now, or an empty stand-in – a commit built after the selection went. */
function shapeNow(host: InspectorHost, assetId: string, shapeId: string): VectorShape {
return (
currentShape(host, assetId, shapeId) ?? {
id: shapeId,
name: '',
rings: [],
fill: SHAPE_FILL_DEFAULT,
opacity: 1,
}
);
}
/**
* The fill: its kind, and under it what that kind is made of – the colour, the gradient's bar and
* stops, or the picture. The colour row goes while a paint fills the shape; the colour itself stays
* on the shape and is what "Solid colour" brings back.
*/
function fillRows(host: InspectorHost, assetId: string, shape: VectorShape): void {
kindRow(host, assetId, shape, 'fill');
const kind = fillKind(shape);
if (kind === 'solid')
styleRow(
host,
assetId,
shape.id,
FILL_DEF,
(s) => s.fill,
(input) => ({ fill: Number(input) }),
'Set fill',
);
else if (kind === 'texture') textureRow(host, assetId, shape);
else gradientRows(host, assetId, shape.id, 'fill');
}
/** "Fill type" or "Line type"; what picking one does is `setPaintKind`. */
function kindRow(host: InspectorHost, assetId: string, shape: VectorShape, part: PaintPart): void {
const def = part === 'fill' ? FILL_TYPE_DEF : LINE_TYPE_DEF;
const value = part === 'fill' ? fillKind(shape) : strokeKind(shape);
const row = createFieldRow(host, def, value, {
onInput: () => {},
onChange: (input) => setPaintKind(host.state, assetId, shape.id, part, textOf(input)),
});
host.fields.set(def.key, row);
host.body.add(row.row);
}
/**
* Switches the fill or the outline to another kind: the paint is laid out over the shape anew, a
* gradient keeps its colours when it only changes between linear and radial, and it is one undo
* step. Exported because the row's menu is the only other way in, and a test has no menus.
*/
export function setPaintKind(
state: EditorState,
assetId: string,
shapeId: string,
part: PaintPart,
kind: string,
): void {
const asset = findAssetOf(state.project, assetId, 'vectorart');
const shape = asset && findShape(asset, shapeId);
if (!asset || !shape) return;
const extent = paintExtent(shapeExtent(shape), vectorCanvasSize(asset.props));
if (part === 'fill') {
if (kind === fillKind(shape) || !(FILL_KINDS as readonly string[]).includes(kind)) return;
const paint = fillPaintFor(shape, kind as FillKind, extent);
state.execute(new SetVectorShapeStyleCommand(assetId, shapeId, { paint }, 'Fill type'));
} else if (shape.stroke) {
if (kind === strokeKind(shape) || !(STROKE_KINDS as readonly string[]).includes(kind)) return;
const paint = strokePaintFor(shape, kind as StrokeKind, extent);
const { color, width } = shape.stroke;
const stroke: VectorStroke = paint ? { color, width, paint } : { color, width };
state.execute(new SetVectorShapeStyleCommand(assetId, shapeId, { stroke }, 'Line type'));
}
}
/** The picture of a texture fill; only uploaded textures and their frames are offered. */
function textureRow(host: InspectorHost, assetId: string, shape: VectorShape): void {
const value = shape.paint?.type === 'texture' ? shape.paint.texture : '';
const row = createFieldRow(host, TEXTURE_DEF, value, {
onInput: () => {},
onChange: (input) => setFillTexture(host.state, assetId, shape.id, textOf(input)),
});
host.fields.set(TEXTURE_DEF.key, row);
host.body.add(row.row);
addNote(host, 'Paint tool: drag the corners of one tile.', 'small');
}
/** Puts a picture into a texture fill; exported for the same reason as `setPaintKind`. */
export function setFillTexture(state: EditorState, assetId: string, shapeId: string, texture: string): void {
const asset = findAssetOf(state.project, assetId, 'vectorart');
const shape = asset && findShape(asset, shapeId);
if (shape?.paint?.type !== 'texture') return;
const paint = { ...shape.paint, texture };
state.execute(new SetVectorShapeStyleCommand(assetId, shapeId, { paint }, 'Fill texture'));
}
/**
* The bar of a gradient and the three rows of its picked stop, then the button that takes the
* picked stop out – there to be seen with two stops too, switched off, since a gradient needs two.
*/
function gradientRows(host: InspectorHost, assetId: string, shapeId: string, part: PaintPart): void {
const key = `${assetId}/${shapeId}/${part}`;
const gradientOf = (): VectorGradient | undefined => {
const shape = currentShape(host, assetId, shapeId);
return shape ? gradientIn(shape, part) : undefined;
};
const perFrame = new FramePreview(host.state, 'gradient-stop');
const bar = new GradientBar(host.state, {
gradient: gradientOf,
selected: () => pickedStop(assetId, shapeId, part, gradientOf()),
select: (index) => {
pickedStops.set(key, index);
const shape = currentShape(host, assetId, shapeId);
if (shape) refreshStopRows(host, assetId, shape, part);
bar.invalidate();
},
preview: (gradient) => {
const shape = currentShape(host, assetId, shapeId);
if (!shape) return;
applyPreview(shape, withGradient(shape, part, gradient));
perFrame.request();
},
commit: (before, after, label) => {
perFrame.cancel();
const shape = currentShape(host, assetId, shapeId);
if (!shape) return;
applyPreview(shape, withGradient(shape, part, before));
host.state.execute(
new SetVectorShapeStyleCommand(assetId, shapeId, withGradient(shape, part, after), label),
);
},
});
builtBars.set(part, bar);
host.body.add(bar);
const defs = stopDefs(part);
const stopRow = (
def: PropertyDef,
read: (stop: { color: number; alpha: number; offset: number }) => number,
field: 'color' | 'alpha' | 'offset',
label: string,
) =>
styleRow(
host,
assetId,
shapeId,
def,
(s) => {
const gradient = gradientIn(s, part);
const stop = gradient?.stops[pickedStop(assetId, shapeId, part, gradient)];
return stop ? read(stop) : Number(def.default);
},
(input, s) => {
const gradient = gradientIn(s, part);
if (!gradient) return {};
const index = pickedStop(assetId, shapeId, part, gradient);
const next = withStopChanged(gradient, index, { [field]: Number(input) });
return next ? withGradient(s, part, next) : {};
},
label,
);
stopRow(defs.color, (stop) => stop.color, 'color', 'Set stop color');
stopRow(defs.alpha, (stop) => stop.alpha, 'alpha', 'Set stop opacity');
stopRow(defs.offset, (stop) => stop.offset, 'offset', 'Move color stop');
const remove = new Button('Remove stop', {
variant: 'ghost',
onClick: () => {
const shape = currentShape(host, assetId, shapeId);
const gradient = shape && gradientIn(shape, part);
if (!shape || !gradient) return;
const next = withoutStop(gradient, pickedStop(assetId, shapeId, part, gradient));
if (!next) return;
pickedStops.set(key, 0);
host.state.execute(
new SetVectorShapeStyleCommand(
assetId,
shapeId,
withGradient(shape, part, next),
'Remove color stop',
),
);
},
});
remove.setEnabled((gradientOf()?.stops.length ?? 0) > MIN_STOPS);
removeButtons.set(part, remove);
host.body.add(remove);
addNote(host, 'Click the bar to add a stop. Paint tool: drag the ends.', 'small');
}
/** The "Remove stop" buttons as last built; a stop added or taken out switches them. */
const removeButtons = new Map<PaintPart, Button>();
/** The picked stop's values into its three rows, after a pick, an undo or a drag on the bar. */
function refreshStopRows(host: InspectorHost, assetId: string, shape: VectorShape, part: PaintPart): void {
const gradient = gradientIn(shape, part);
if (!gradient) return;
const stop = gradient.stops[pickedStop(assetId, shape.id, part, gradient)];
const defs = stopDefs(part);
if (stop) {
host.fields.get(defs.color.key)?.setValue(stop.color);
host.fields.get(defs.alpha.key)?.setValue(stop.alpha);
host.fields.get(defs.offset.key)?.setValue(stop.offset);
}
removeButtons.get(part)?.setEnabled(gradient.stops.length > MIN_STOPS);
}
function gradientIn(shape: VectorShape, part: PaintPart): VectorGradient | undefined {
const paint = part === 'fill' ? shape.paint : shape.stroke?.paint;
return paint && paint.type !== 'texture' ? paint : undefined;
}
/** A style that puts `gradient` where `part` says: the fill's paint, or the outline's. */
function withGradient(shape: VectorShape, part: PaintPart, gradient: VectorGradient): PreviewStyle {
return part === 'fill' ? { paint: gradient } : { stroke: { ...strokeOf(shape), paint: gradient } };
}